Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Live components #247

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the concrete type XmlNode ties this implementation of live components with the view engine of Giraffe. It won't be possible to use Feliz.ViewEngine which uses a different type called ReactElement.

Would it be possible to abstract the view output into the type declaration? i.e.

type LiveComponenetBuilderState<'State, 'Msg, 'View> = {
   View: (HttpContext -> ClientInfo -> 'State -> 'View) 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>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>
2 changes: 2 additions & 0 deletions src/Saturn.LiveView/paket.references
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Elmish
FSharp.Core
Loading