Skip to content

Commit cb206d5

Browse files
Initial implementation of LiveComponent
1 parent 8dcac80 commit cb206d5

File tree

8 files changed

+253
-7
lines changed

8 files changed

+253
-7
lines changed

.paket/Paket.Restore.targets

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,16 @@
2727
<PaketBootStrapperExePath Condition=" '$(PaketBootStrapperExePath)' == '' AND Exists('$(PaketRootPath)paket.bootstrapper.exe')">$(PaketRootPath)paket.bootstrapper.exe</PaketBootStrapperExePath>
2828
<PaketBootStrapperExePath Condition=" '$(PaketBootStrapperExePath)' == '' ">$(PaketToolsPath)paket.bootstrapper.exe</PaketBootStrapperExePath>
2929
<PaketBootStrapperExeDir Condition=" Exists('$(PaketBootStrapperExePath)') " >$([System.IO.Path]::GetDirectoryName("$(PaketBootStrapperExePath)"))\</PaketBootStrapperExeDir>
30-
30+
3131
<PaketBootStrapperCommand Condition=" '$(OS)' == 'Windows_NT' ">"$(PaketBootStrapperExePath)"</PaketBootStrapperCommand>
3232
<PaketBootStrapperCommand Condition=" '$(OS)' != 'Windows_NT' ">$(MonoPath) --runtime=v4.0.30319 "$(PaketBootStrapperExePath)"</PaketBootStrapperCommand>
3333

34+
<!-- Disable automagic references for F# DotNet SDK -->
35+
<!-- This will not do anything for other project types -->
36+
<!-- see https://github.com/fsharp/fslang-design/blob/master/tooling/FST-1002-fsharp-in-dotnet-sdk.md -->
37+
<DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
38+
<DisableImplicitSystemValueTupleReference>true</DisableImplicitSystemValueTupleReference>
39+
3440
<!-- Disable Paket restore under NCrunch build -->
3541
<PaketRestoreDisabled Condition="'$(NCrunch)' == '1'">True</PaketRestoreDisabled>
3642

@@ -130,7 +136,7 @@
130136
<!-- Parse our simple 'paket.restore.cached' json ...-->
131137
<PaketRestoreCachedSplitObject Include="$([System.Text.RegularExpressions.Regex]::Split(`$(PaketRestoreCachedContents)`, `{|}|,`))"></PaketRestoreCachedSplitObject>
132138
<!-- Keep Key, Value ItemGroup-->
133-
<PaketRestoreCachedKeyValue Include="@(PaketRestoreCachedSplitObject)"
139+
<PaketRestoreCachedKeyValue Include="@(PaketRestoreCachedSplitObject)"
134140
Condition=" $([System.Text.RegularExpressions.Regex]::Split(`%(Identity)`, `&quot;: &quot;`).Length) &gt; 1 ">
135141
<Key>$([System.Text.RegularExpressions.Regex]::Split(`%(Identity)`, `": "`)[0].Replace(`"`, ``).Replace(` `, ``))</Key>
136142
<Value>$([System.Text.RegularExpressions.Regex]::Split(`%(Identity)`, `": "`)[1].Replace(`"`, ``).Replace(` `, ``))</Value>
@@ -163,7 +169,7 @@
163169
<Exec Command='$(PaketBootStrapperCommand)' Condition=" '$(PaketBootstrapperStyle)' == 'classic' AND Exists('$(PaketBootStrapperExePath)') AND !(Exists('$(PaketExePath)'))" ContinueOnError="false" />
164170
<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'" />
165171
<Exec Command='$(PaketCommand) restore' Condition=" '$(PaketRestoreRequired)' == 'true' AND '$(PaketDisableGlobalRestore)' != 'true' " ContinueOnError="false" />
166-
172+
167173
<!-- Step 2 Detect project specific changes -->
168174
<ItemGroup>
169175
<MyTargetFrameworks Condition="'$(TargetFramework)' != '' " Include="$(TargetFramework)"></MyTargetFrameworks>

Saturn.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AzureADAuthSample", "sample
4545
EndProject
4646
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "WorkerSample", "sample\WorkerSample\WorkerSample.fsproj", "{34881EBD-FAB7-45A5-8166-45B9CC85A0E0}"
4747
EndProject
48+
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Saturn.LiveView", "src\Saturn.LiveView\Saturn.LiveView.fsproj", "{1F68DB09-7A15-4D86-822D-5D9F62FE1372}"
49+
EndProject
4850
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmark", "benchmark", "{CA96E36B-6981-4056-AAE8-1D6FA3CD07E4}"
4951
EndProject
5052
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "GiraffeBench", "benchmark\GiraffeBench\GiraffeBench.fsproj", "{FC8B2AB6-79A4-48CC-9B83-AF84DEF17BA7}"
@@ -304,6 +306,18 @@ Global
304306
{2885CF04-BCEE-457B-B013-36FE935030BF}.Release|x64.Build.0 = Release|Any CPU
305307
{2885CF04-BCEE-457B-B013-36FE935030BF}.Release|x86.ActiveCfg = Release|Any CPU
306308
{2885CF04-BCEE-457B-B013-36FE935030BF}.Release|x86.Build.0 = Release|Any CPU
309+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
310+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|Any CPU.Build.0 = Debug|Any CPU
311+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|x64.ActiveCfg = Debug|Any CPU
312+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|x64.Build.0 = Debug|Any CPU
313+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|x86.ActiveCfg = Debug|Any CPU
314+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Debug|x86.Build.0 = Debug|Any CPU
315+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|Any CPU.ActiveCfg = Release|Any CPU
316+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|Any CPU.Build.0 = Release|Any CPU
317+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|x64.ActiveCfg = Release|Any CPU
318+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|x64.Build.0 = Release|Any CPU
319+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|x86.ActiveCfg = Release|Any CPU
320+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372}.Release|x86.Build.0 = Release|Any CPU
307321
EndGlobalSection
308322
GlobalSection(NestedProjects) = preSolution
309323
{8DBA089A-7C24-4E87-870B-E0774654F376} = {F2C8C347-845F-42E4-A702-7381C4B4087F}
@@ -326,5 +340,6 @@ Global
326340
{34881EBD-FAB7-45A5-8166-45B9CC85A0E0} = {511FB392-5714-4028-97F3-F883F81B43DB}
327341
{FC8B2AB6-79A4-48CC-9B83-AF84DEF17BA7} = {CA96E36B-6981-4056-AAE8-1D6FA3CD07E4}
328342
{2885CF04-BCEE-457B-B013-36FE935030BF} = {CA96E36B-6981-4056-AAE8-1D6FA3CD07E4}
343+
{1F68DB09-7A15-4D86-822D-5D9F62FE1372} = {F2C8C347-845F-42E4-A702-7381C4B4087F}
329344
EndGlobalSection
330345
EndGlobal

paket.dependencies

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ nuget protobuf-net.Grpc.AspNetCore
1919
nuget protobuf-net.Grpc.HttpClient
2020
nuget FSharp.Control.Websockets >= 0.2
2121
nuget FSharp.Core >= 4.2.3
22+
nuget Elmish
2223

2324
group Docs
2425
source https://api.nuget.org/v3/index.json

paket.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ NUGET
3131
System.Reflection.TypeExtensions (>= 4.3)
3232
System.Xml.XmlDocument (>= 4.3)
3333
CommandLineParser (2.8)
34+
Elmish (3.0.6)
35+
FSharp.Core (>= 4.6.2)
3436
Expecto (9.0)
3537
FSharp.Core (>= 4.6)
3638
Mono.Cecil (>= 0.11.2)

src/Saturn.LiveView/LiveView.fs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
namespace Saturn
2+
3+
open Channels
4+
open Microsoft.AspNetCore.Http
5+
open Microsoft.Extensions.DependencyInjection
6+
open System.Threading.Tasks
7+
open Giraffe.GiraffeViewEngine
8+
open Elmish
9+
open FSharp.Control.Tasks.V2
10+
11+
module LiveComponenet =
12+
type ILiveComponenet =
13+
abstract member InternalChannel : IChannel with get
14+
15+
type LiveComponentMsg = {Event: string; ElementId: string; Data: string}
16+
type internal ViewUpdateMsg = {ComponentId: string; Data: string}
17+
18+
[<AutoOpen>]
19+
module LiveComponentBuilder =
20+
open LiveComponenet
21+
22+
type LiveComponenetBuilderState<'State, 'Msg> = {
23+
Join: (HttpContext -> ClientInfo -> Task<JoinResult>) option
24+
Init: (HttpContext -> ClientInfo -> (Cmd<'Msg> -> unit) -> Task<'State * Cmd<'Msg>>) option
25+
Update: (HttpContext -> ClientInfo -> 'Msg -> 'State -> Task<'State * Cmd<'Msg>>) option
26+
View: (HttpContext -> ClientInfo -> 'State -> XmlNode) option
27+
MessageMap: (HttpContext -> ClientInfo -> LiveComponentMsg -> 'Msg) option
28+
}
29+
30+
type internal StateMsg<'State, 'Msg> =
31+
| Init of HttpContext * ClientInfo
32+
| SetState of 'State
33+
| Dispatch of Cmd<'Msg>
34+
| Update of 'Msg
35+
36+
37+
type LiveComponenetBuilder<'State, 'Msg> internal (componentId: string) =
38+
39+
member __.Yield (_) : LiveComponenetBuilderState<'State, 'Msg> =
40+
{Join = None; Init = None; Update = None; View = None; MessageMap = None}
41+
42+
[<CustomOperation("join")>]
43+
///Action executed when client tries to join the channel.
44+
///You can either return `Ok` if channel allows join, or reject it with `Rejected`
45+
///Typical cases for rejection may include authorization/authentication,
46+
///not being able to handle more connections or other business logic reasons.
47+
///
48+
/// As arguments, `join` action gets:
49+
/// * current `HttpContext` for the request
50+
/// * `ClientInfo` instance representing additional information about client sending request
51+
member __.Join (state, handler) : LiveComponenetBuilderState<'State, 'Msg> =
52+
{state with Join = handler}
53+
54+
[<CustomOperation("init")>]
55+
///Action executed after client succesfully join the channel. Used to set initial state of the compnent.
56+
///
57+
/// As arguments, `init` action gets:
58+
/// * current `HttpContext` for the request
59+
/// * `ClientInfo` instance representing additional information about client sending request
60+
/// * `(Cmd<'Msg> -> unit)` function that can be used to dispatch additional messages (for example used when in `init` you can subscribe to external events)
61+
///
62+
/// Returns: `Task<'State * Cmd<'Msg>>`
63+
member __.Init (state, handler) : LiveComponenetBuilderState<'State, 'Msg> =
64+
{state with Init = handler}
65+
66+
[<CustomOperation("update")>]
67+
///Action executed after client performs some event in the component
68+
///
69+
/// As arguments, `update` action gets:
70+
/// * current `HttpContext` for the request
71+
/// * `ClientInfo` instance representing additional information about client sending request
72+
/// * message `'Msg` that represetns event that happened
73+
///
74+
/// Returns: `Task<'State * Cmd<'Msg>>`
75+
member __.Update (state, handler) : LiveComponenetBuilderState<'State, 'Msg> =
76+
{state with Update = handler}
77+
78+
[<CustomOperation("view")>]
79+
///Function responsible for mapping current state to the view
80+
///
81+
/// As arguments, `view` action gets:
82+
/// * current `HttpContext` for the request
83+
/// * `ClientInfo` instance representing additional information about client sending request
84+
/// * current state `'State`
85+
///
86+
/// Returns: `XmlNode` (Giraffe.ViewEngine)
87+
member __.View (state, handler) : LiveComponenetBuilderState<'State, 'Msg> =
88+
{state with View = handler}
89+
90+
[<CustomOperation("message_map")>]
91+
///Function responsible for mapping raw messages into component domain messages
92+
///
93+
/// As arguments, `message_map` action gets:
94+
/// * current `HttpContext` for the request
95+
/// * `ClientInfo` instance representing additional information about client sending request
96+
/// * instance of `LiveComponentMsg` representing raw message
97+
///
98+
/// Returns: `'Msg` representing domain message
99+
member __.MessageMap (state, handler) : LiveComponenetBuilderState<'State, 'Msg> =
100+
{state with MessageMap = handler}
101+
102+
member __.Run (state : LiveComponenetBuilderState<'State, 'Msg>) : ILiveComponenet =
103+
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."
104+
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."
105+
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."
106+
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."
107+
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."
108+
109+
110+
let joinH = state.Join.Value
111+
let initH = state.Init.Value
112+
let viewH = state.View.Value
113+
let updateH = state.Update.Value
114+
let mmH = state.MessageMap.Value
115+
116+
let c =
117+
let rec stateMP = MailboxProcessor.Start(fun inbox ->
118+
119+
let rec messageLoop(state: 'State, (ctx: HttpContext), ci) = async {
120+
let! msg = inbox.Receive()
121+
let! newState, ctx, ci =
122+
match msg with
123+
| Init (ctx, ci) ->
124+
async { return state, ctx, ci}
125+
| SetState (state) ->
126+
async {
127+
let clientHub = ctx.RequestServices.GetService<ISocketHub> ()
128+
let viewTemplate = viewH ctx ci state
129+
let viewStr = Giraffe.GiraffeViewEngine.renderHtmlDocument viewTemplate
130+
let viewMsg = {ComponentId = componentId; Data = viewStr}
131+
do! clientHub.SendMessageToClient ci "liveComponent" viewMsg |> Async.AwaitTask
132+
133+
return state, ctx, ci
134+
}
135+
| Update msg ->
136+
async {
137+
let! (state, cmd) = (updateH ctx ci msg state |> Async.AwaitTask)
138+
139+
let clientHub = ctx.RequestServices.GetService<ISocketHub> ()
140+
let viewTemplate = viewH ctx ci state
141+
let viewStr = Giraffe.GiraffeViewEngine.renderHtmlDocument viewTemplate
142+
let viewMsg = {ComponentId = componentId; Data = viewStr}
143+
do! clientHub.SendMessageToClient ci "liveComponent" viewMsg |> Async.AwaitTask
144+
145+
inbox.Post (Dispatch cmd)
146+
return state, ctx, ci
147+
}
148+
| Dispatch (cmd: Cmd<'Msg>) ->
149+
async {
150+
cmd |> List.iter (fun n -> n (Update >> inbox.Post) )
151+
return state, ctx, ci
152+
}
153+
return! messageLoop (newState, ctx, ci) }
154+
155+
let inState = Unchecked.defaultof<'State>
156+
let inCtx = Unchecked.defaultof<HttpContext>
157+
let inCi = Unchecked.defaultof<ClientInfo>
158+
messageLoop (inState, inCtx, inCi)
159+
)
160+
161+
channel {
162+
join (fun ctx si -> task {
163+
let! res = joinH ctx si
164+
match res with
165+
| JoinResult.Ok ->
166+
stateMP.Post (Init (ctx, si))
167+
let! (s,cmd) = initH ctx si (Dispatch >> stateMP.Post)
168+
stateMP.Post (SetState s)
169+
stateMP.Post (Dispatch cmd)
170+
| _ ->
171+
()
172+
return res
173+
})
174+
175+
handle "liveComponent" (fun ctx si (msg: Message<LiveComponentMsg>) -> task {
176+
let m = mmH ctx si msg.Payload
177+
stateMP.Post (Update m)
178+
return ()
179+
})
180+
181+
terminate (fun ctx si -> task {
182+
(stateMP :> System.IDisposable).Dispose()
183+
return ()
184+
})
185+
}
186+
187+
{ new ILiveComponenet with
188+
member __.InternalChannel with get () = c
189+
}
190+
191+
let liveComponent<'State, 'Msg> id = LiveComponenetBuilder<'State, 'Msg>(id)
192+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>Library</OutputType>
4+
<TargetFrameworks>netstandard2.0; netcoreapp3.1</TargetFrameworks>
5+
<DebugType>portable</DebugType>
6+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
7+
<Description>Saturn LiveView - rich, real-time user experience with server-rendered HTML.</Description>
8+
</PropertyGroup>
9+
<ItemGroup>
10+
<ProjectReference Include="..\Saturn\Saturn.fsproj">
11+
<Name>Saturn.fsproj</Name>
12+
</ProjectReference>
13+
</ItemGroup>
14+
<ItemGroup>
15+
<Compile Include="LiveView.fs" />
16+
</ItemGroup>
17+
18+
<ItemGroup Condition=" '$(TargetFramework)' == 'netcoreapp3.1' ">
19+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
20+
</ItemGroup>
21+
22+
<Import Project="..\..\.paket\Paket.Restore.targets" />
23+
</Project>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Elmish
2+
3+
group netstandard2.0
4+
FSharp.Core framework: netstandard2.0
5+
Microsoft.AspNetCore.Mvc.Abstractions framework: netstandard2.0
6+
7+
group netcoreapp3.1
8+
FSharp.Core framework: netstandard2.0

src/Saturn/Channels.fs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ module Channels =
5252
/// 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`)
5353
type ISocketHub =
5454
abstract member SendMessageToClients: ChannelPath -> Topic -> 'a -> Task<unit>
55-
abstract member SendMessageToClient: SocketId -> Topic -> 'a -> Task<unit>
55+
abstract member SendMessageToClient: ClientInfo -> Topic -> 'a -> Task<unit>
5656
abstract member SendMessageToClientsFilter: (ClientInfo -> bool) -> Topic -> 'a -> Task<unit>
5757

5858
/// A type that wraps access to connected websockets by endpoint
@@ -99,9 +99,8 @@ module Channels =
9999
return ()
100100
}
101101

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

0 commit comments

Comments
 (0)