Skip to content

Commit

Permalink
Initial implementation of LiveComponent
Browse files Browse the repository at this point in the history
  • Loading branch information
Krzysztof-Cieslak committed Jun 19, 2020
1 parent 8dcac80 commit cb206d5
Show file tree
Hide file tree
Showing 8 changed files with 253 additions and 7 deletions.
12 changes: 9 additions & 3 deletions .paket/Paket.Restore.targets
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,16 @@
<PaketBootStrapperExePath Condition=" '$(PaketBootStrapperExePath)' == '' AND Exists('$(PaketRootPath)paket.bootstrapper.exe')">$(PaketRootPath)paket.bootstrapper.exe</PaketBootStrapperExePath>
<PaketBootStrapperExePath Condition=" '$(PaketBootStrapperExePath)' == '' ">$(PaketToolsPath)paket.bootstrapper.exe</PaketBootStrapperExePath>
<PaketBootStrapperExeDir Condition=" Exists('$(PaketBootStrapperExePath)') " >$([System.IO.Path]::GetDirectoryName("$(PaketBootStrapperExePath)"))\</PaketBootStrapperExeDir>

<PaketBootStrapperCommand Condition=" '$(OS)' == 'Windows_NT' ">"$(PaketBootStrapperExePath)"</PaketBootStrapperCommand>
<PaketBootStrapperCommand Condition=" '$(OS)' != 'Windows_NT' ">$(MonoPath) --runtime=v4.0.30319 "$(PaketBootStrapperExePath)"</PaketBootStrapperCommand>

<!-- Disable automagic references for F# DotNet SDK -->
<!-- This will not do anything for other project types -->
<!-- see https://github.com/fsharp/fslang-design/blob/master/tooling/FST-1002-fsharp-in-dotnet-sdk.md -->
<DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
<DisableImplicitSystemValueTupleReference>true</DisableImplicitSystemValueTupleReference>

<!-- Disable Paket restore under NCrunch build -->
<PaketRestoreDisabled Condition="'$(NCrunch)' == '1'">True</PaketRestoreDisabled>

Expand Down Expand Up @@ -130,7 +136,7 @@
<!-- Parse our simple 'paket.restore.cached' json ...-->
<PaketRestoreCachedSplitObject Include="$([System.Text.RegularExpressions.Regex]::Split(`$(PaketRestoreCachedContents)`, `{|}|,`))"></PaketRestoreCachedSplitObject>
<!-- Keep Key, Value ItemGroup-->
<PaketRestoreCachedKeyValue Include="@(PaketRestoreCachedSplitObject)"
<PaketRestoreCachedKeyValue Include="@(PaketRestoreCachedSplitObject)"
Condition=" $([System.Text.RegularExpressions.Regex]::Split(`%(Identity)`, `&quot;: &quot;`).Length) &gt; 1 ">
<Key>$([System.Text.RegularExpressions.Regex]::Split(`%(Identity)`, `": "`)[0].Replace(`"`, ``).Replace(` `, ``))</Key>
<Value>$([System.Text.RegularExpressions.Regex]::Split(`%(Identity)`, `": "`)[1].Replace(`"`, ``).Replace(` `, ``))</Value>
Expand Down Expand Up @@ -163,7 +169,7 @@
<Exec Command='$(PaketBootStrapperCommand)' Condition=" '$(PaketBootstrapperStyle)' == 'classic' AND Exists('$(PaketBootStrapperExePath)') AND !(Exists('$(PaketExePath)'))" ContinueOnError="false" />
<Error Text="Stop build because of PAKET_ERROR_ON_MSBUILD_EXEC and we need a full restore (hashes don't match)" Condition=" '$(PAKET_ERROR_ON_MSBUILD_EXEC)' == 'true' AND '$(PaketRestoreRequired)' == 'true' AND '$(PaketDisableGlobalRestore)' != 'true'" />
<Exec Command='$(PaketCommand) restore' Condition=" '$(PaketRestoreRequired)' == 'true' AND '$(PaketDisableGlobalRestore)' != 'true' " ContinueOnError="false" />

<!-- Step 2 Detect project specific changes -->
<ItemGroup>
<MyTargetFrameworks Condition="'$(TargetFramework)' != '' " Include="$(TargetFramework)"></MyTargetFrameworks>
Expand Down
15 changes: 15 additions & 0 deletions Saturn.sln
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AzureADAuthSample", "sample
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "WorkerSample", "sample\WorkerSample\WorkerSample.fsproj", "{34881EBD-FAB7-45A5-8166-45B9CC85A0E0}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Saturn.LiveView", "src\Saturn.LiveView\Saturn.LiveView.fsproj", "{1F68DB09-7A15-4D86-822D-5D9F62FE1372}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmark", "benchmark", "{CA96E36B-6981-4056-AAE8-1D6FA3CD07E4}"
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "GiraffeBench", "benchmark\GiraffeBench\GiraffeBench.fsproj", "{FC8B2AB6-79A4-48CC-9B83-AF84DEF17BA7}"
Expand Down Expand Up @@ -304,6 +306,18 @@ Global
{2885CF04-BCEE-457B-B013-36FE935030BF}.Release|x64.Build.0 = Release|Any CPU
{2885CF04-BCEE-457B-B013-36FE935030BF}.Release|x86.ActiveCfg = Release|Any CPU
{2885CF04-BCEE-457B-B013-36FE935030BF}.Release|x86.Build.0 = Release|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|x64.ActiveCfg = Debug|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|x64.Build.0 = Debug|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|x86.ActiveCfg = Debug|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|x86.Build.0 = Debug|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|Any CPU.Build.0 = Release|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|x64.ActiveCfg = Release|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|x64.Build.0 = Release|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|x86.ActiveCfg = Release|Any CPU
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{8DBA089A-7C24-4E87-870B-E0774654F376} = {F2C8C347-845F-42E4-A702-7381C4B4087F}
Expand All @@ -326,5 +340,6 @@ Global
{34881EBD-FAB7-45A5-8166-45B9CC85A0E0} = {511FB392-5714-4028-97F3-F883F81B43DB}
{FC8B2AB6-79A4-48CC-9B83-AF84DEF17BA7} = {CA96E36B-6981-4056-AAE8-1D6FA3CD07E4}
{2885CF04-BCEE-457B-B013-36FE935030BF} = {CA96E36B-6981-4056-AAE8-1D6FA3CD07E4}
{1F68DB09-7A15-4D86-822D-5D9F62FE1372} = {F2C8C347-845F-42E4-A702-7381C4B4087F}
EndGlobalSection
EndGlobal
1 change: 1 addition & 0 deletions paket.dependencies
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ nuget protobuf-net.Grpc.AspNetCore
nuget protobuf-net.Grpc.HttpClient
nuget FSharp.Control.Websockets >= 0.2
nuget FSharp.Core >= 4.2.3
nuget Elmish

group Docs
source https://api.nuget.org/v3/index.json
Expand Down
2 changes: 2 additions & 0 deletions paket.lock
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ NUGET
System.Reflection.TypeExtensions (>= 4.3)
System.Xml.XmlDocument (>= 4.3)
CommandLineParser (2.8)
Elmish (3.0.6)
FSharp.Core (>= 4.6.2)
Expecto (9.0)
FSharp.Core (>= 4.6)
Mono.Cecil (>= 0.11.2)
Expand Down
192 changes: 192 additions & 0 deletions src/Saturn.LiveView/LiveView.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
namespace Saturn

open Channels
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.DependencyInjection
open System.Threading.Tasks
open Giraffe.GiraffeViewEngine
open Elmish
open FSharp.Control.Tasks.V2

module LiveComponenet =
type ILiveComponenet =
abstract member InternalChannel : IChannel with get

type LiveComponentMsg = {Event: string; ElementId: string; Data: string}
type internal ViewUpdateMsg = {ComponentId: string; Data: string}

[<AutoOpen>]
module LiveComponentBuilder =
open LiveComponenet

type LiveComponenetBuilderState<'State, 'Msg> = {
Join: (HttpContext -> ClientInfo -> Task<JoinResult>) option
Init: (HttpContext -> ClientInfo -> (Cmd<'Msg> -> unit) -> Task<'State * Cmd<'Msg>>) option
Update: (HttpContext -> ClientInfo -> 'Msg -> 'State -> Task<'State * Cmd<'Msg>>) option
View: (HttpContext -> ClientInfo -> 'State -> XmlNode) option
MessageMap: (HttpContext -> ClientInfo -> LiveComponentMsg -> 'Msg) option
}

type internal StateMsg<'State, 'Msg> =
| Init of HttpContext * ClientInfo
| SetState of 'State
| Dispatch of Cmd<'Msg>
| Update of 'Msg


type LiveComponenetBuilder<'State, 'Msg> internal (componentId: string) =

member __.Yield (_) : LiveComponenetBuilderState<'State, 'Msg> =
{Join = None; Init = None; Update = None; View = None; MessageMap = None}

[<CustomOperation("join")>]
///Action executed when client tries to join the channel.
///You can either return `Ok` if channel allows join, or reject it with `Rejected`
///Typical cases for rejection may include authorization/authentication,
///not being able to handle more connections or other business logic reasons.
///
/// As arguments, `join` action gets:
/// * current `HttpContext` for the request
/// * `ClientInfo` instance representing additional information about client sending request
member __.Join (state, handler) : LiveComponenetBuilderState<'State, 'Msg> =
{state with Join = handler}

[<CustomOperation("init")>]
///Action executed after client succesfully join the channel. Used to set initial state of the compnent.
///
/// As arguments, `init` action gets:
/// * current `HttpContext` for the request
/// * `ClientInfo` instance representing additional information about client sending request
/// * `(Cmd<'Msg> -> unit)` function that can be used to dispatch additional messages (for example used when in `init` you can subscribe to external events)
///
/// Returns: `Task<'State * Cmd<'Msg>>`
member __.Init (state, handler) : LiveComponenetBuilderState<'State, 'Msg> =
{state with Init = handler}

[<CustomOperation("update")>]
///Action executed after client performs some event in the component
///
/// As arguments, `update` action gets:
/// * current `HttpContext` for the request
/// * `ClientInfo` instance representing additional information about client sending request
/// * message `'Msg` that represetns event that happened
///
/// Returns: `Task<'State * Cmd<'Msg>>`
member __.Update (state, handler) : LiveComponenetBuilderState<'State, 'Msg> =
{state with Update = handler}

[<CustomOperation("view")>]
///Function responsible for mapping current state to the view
///
/// As arguments, `view` action gets:
/// * current `HttpContext` for the request
/// * `ClientInfo` instance representing additional information about client sending request
/// * current state `'State`
///
/// Returns: `XmlNode` (Giraffe.ViewEngine)
member __.View (state, handler) : LiveComponenetBuilderState<'State, 'Msg> =
{state with View = handler}

[<CustomOperation("message_map")>]
///Function responsible for mapping raw messages into component domain messages
///
/// As arguments, `message_map` action gets:
/// * current `HttpContext` for the request
/// * `ClientInfo` instance representing additional information about client sending request
/// * instance of `LiveComponentMsg` representing raw message
///
/// Returns: `'Msg` representing domain message
member __.MessageMap (state, handler) : LiveComponenetBuilderState<'State, 'Msg> =
{state with MessageMap = handler}

member __.Run (state : LiveComponenetBuilderState<'State, 'Msg>) : ILiveComponenet =
if state.Join.IsNone then failwith "Join is required operation for any Live Component. Please use `join` operation in your `liveComponent` CE to define it."
if state.Init.IsNone then failwith "Init is required operation for any Live Component. Please use `init` operation in your `liveComponent` CE to define it."
if state.View.IsNone then failwith "View is required operation for any Live Component. Please use `view` operation in your `liveComponent` CE to define it."
if state.Update.IsNone then failwith "Update is required operation for any Live Component. Please use `update` operation in your `liveComponent` CE to define it."
if state.MessageMap.IsNone then failwith "MessageMap is required operation for any Live Component. Please use `message_map` operation in your `liveComponent` CE to define it."


let joinH = state.Join.Value
let initH = state.Init.Value
let viewH = state.View.Value
let updateH = state.Update.Value
let mmH = state.MessageMap.Value

let c =
let rec stateMP = MailboxProcessor.Start(fun inbox ->

let rec messageLoop(state: 'State, (ctx: HttpContext), ci) = async {
let! msg = inbox.Receive()
let! newState, ctx, ci =
match msg with
| Init (ctx, ci) ->
async { return state, ctx, ci}
| SetState (state) ->
async {
let clientHub = ctx.RequestServices.GetService<ISocketHub> ()
let viewTemplate = viewH ctx ci state
let viewStr = Giraffe.GiraffeViewEngine.renderHtmlDocument viewTemplate
let viewMsg = {ComponentId = componentId; Data = viewStr}
do! clientHub.SendMessageToClient ci "liveComponent" viewMsg |> Async.AwaitTask

return state, ctx, ci
}
| Update msg ->
async {
let! (state, cmd) = (updateH ctx ci msg state |> Async.AwaitTask)

let clientHub = ctx.RequestServices.GetService<ISocketHub> ()
let viewTemplate = viewH ctx ci state
let viewStr = Giraffe.GiraffeViewEngine.renderHtmlDocument viewTemplate
let viewMsg = {ComponentId = componentId; Data = viewStr}
do! clientHub.SendMessageToClient ci "liveComponent" viewMsg |> Async.AwaitTask

inbox.Post (Dispatch cmd)
return state, ctx, ci
}
| Dispatch (cmd: Cmd<'Msg>) ->
async {
cmd |> List.iter (fun n -> n (Update >> inbox.Post) )
return state, ctx, ci
}
return! messageLoop (newState, ctx, ci) }

let inState = Unchecked.defaultof<'State>
let inCtx = Unchecked.defaultof<HttpContext>
let inCi = Unchecked.defaultof<ClientInfo>
messageLoop (inState, inCtx, inCi)
)

channel {
join (fun ctx si -> task {
let! res = joinH ctx si
match res with
| JoinResult.Ok ->
stateMP.Post (Init (ctx, si))
let! (s,cmd) = initH ctx si (Dispatch >> stateMP.Post)
stateMP.Post (SetState s)
stateMP.Post (Dispatch cmd)
| _ ->
()
return res
})

handle "liveComponent" (fun ctx si (msg: Message<LiveComponentMsg>) -> task {
let m = mmH ctx si msg.Payload
stateMP.Post (Update m)
return ()
})

terminate (fun ctx si -> task {
(stateMP :> System.IDisposable).Dispose()
return ()
})
}

{ new ILiveComponenet with
member __.InternalChannel with get () = c
}

let liveComponent<'State, 'Msg> id = LiveComponenetBuilder<'State, 'Msg>(id)

23 changes: 23 additions & 0 deletions src/Saturn.LiveView/Saturn.LiveView.fsproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFrameworks>netstandard2.0; netcoreapp3.1</TargetFrameworks>
<DebugType>portable</DebugType>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>Saturn LiveView - rich, real-time user experience with server-rendered HTML.</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Saturn\Saturn.fsproj">
<Name>Saturn.fsproj</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<Compile Include="LiveView.fs" />
</ItemGroup>

<ItemGroup Condition=" '$(TargetFramework)' == 'netcoreapp3.1' ">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<Import Project="..\..\.paket\Paket.Restore.targets" />
</Project>
8 changes: 8 additions & 0 deletions src/Saturn.LiveView/paket.references
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Elmish

group netstandard2.0
FSharp.Core framework: netstandard2.0
Microsoft.AspNetCore.Mvc.Abstractions framework: netstandard2.0

group netcoreapp3.1
FSharp.Core framework: netstandard2.0
7 changes: 3 additions & 4 deletions src/Saturn/Channels.fs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ module Channels =
/// You can get instance of it with `ctx.GetService&lt;Saturn.Channels.ISocketHub>()` from any place that has access to HttpContext instance (`controller` actions, `channel` actions, normal `HttpHandler`)
type ISocketHub =
abstract member SendMessageToClients: ChannelPath -> Topic -> 'a -> Task<unit>
abstract member SendMessageToClient: SocketId -> Topic -> 'a -> Task<unit>
abstract member SendMessageToClient: ClientInfo -> Topic -> 'a -> Task<unit>
abstract member SendMessageToClientsFilter: (ClientInfo -> bool) -> Topic -> 'a -> Task<unit>

/// A type that wraps access to connected websockets by endpoint
Expand Down Expand Up @@ -99,9 +99,8 @@ module Channels =
return ()
}

member __.SendMessageToClient path clientId topic payload = task {
let ci = {SocketId = clientId; ChannelPath = path}
match sockets.TryGetValue ci with
member __.SendMessageToClient clientInfo topic payload = task {
match sockets.TryGetValue clientInfo with
| true, socket ->
let msg = { Topic = topic; Ref = ""; Payload = payload }
do! sendMessage msg socket
Expand Down

0 comments on commit cb206d5

Please sign in to comment.