diff --git a/.github/workflows/sdk-csharp-nuget.yml b/.github/workflows/sdk-csharp-nuget.yml index cada77945..372297ec6 100644 --- a/.github/workflows/sdk-csharp-nuget.yml +++ b/.github/workflows/sdk-csharp-nuget.yml @@ -1,4 +1,4 @@ -name: Build & Publish NuGet to GitHub Registry +name: StarFederation.Datastar Nuget on: workflow_dispatch: diff --git a/.github/workflows/sdk-fsharp-nuget.yml b/.github/workflows/sdk-fsharp-nuget.yml index 9a5b5379e..a59daa95f 100644 --- a/.github/workflows/sdk-fsharp-nuget.yml +++ b/.github/workflows/sdk-fsharp-nuget.yml @@ -1,4 +1,4 @@ -name: Build & Publish NuGet to GitHub Registry +name: StarFederation.Datastar.FSharp NuGet on: workflow_dispatch: diff --git a/build/consts_fsharp.qtpl b/build/consts_fsharp.qtpl index 31e932c58..18442d568 100644 --- a/build/consts_fsharp.qtpl +++ b/build/consts_fsharp.qtpl @@ -38,7 +38,7 @@ module Consts = {%- for _, enum := range data.Enums -%} module {%s enum.Name.Pascal %} = - let toString this = + let inline toString this = match this with {%- for _, entry := range enum.Values -%} | {%s enum.Name.Pascal %}.{%s entry.Name.Pascal %} -> "{%s entry.Value %}" diff --git a/examples/dotnet/csharp/HelloWorld/Program.cs b/examples/dotnet/csharp/HelloWorld/Program.cs index d3a27b417..a36dedca6 100644 --- a/examples/dotnet/csharp/HelloWorld/Program.cs +++ b/examples/dotnet/csharp/HelloWorld/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Text.Json.Serialization; using StarFederation.Datastar.DependencyInjection; @@ -22,18 +23,20 @@ public static void Main(string[] args) WebApplication app = builder.Build(); app.UseStaticFiles(); - app.MapGet("/hello-world", async (IDatastarServerSentEventService sse, IDatastarSignalsReaderService signals) => + app.MapGet("/hello-world", async (IDatastarService datastarService) => { - Signals mySignals = await signals.ReadSignalsAsync(); + Signals? mySignals = await datastarService.ReadSignalsAsync(); + Debug.Assert(mySignals != null, nameof(mySignals) + " != null"); + for (int index = 0; index < Message.Length; ++index) { - await sse.MergeFragmentsAsync($"""
{Message[..index]}
"""); + await datastarService.PatchElementsAsync($"""
{Message[..index]}
"""); if (!char.IsWhiteSpace(Message[index])) { await Task.Delay(TimeSpan.FromMilliseconds(mySignals.Delay.GetValueOrDefault(0))); } } - await sse.MergeFragmentsAsync($"""
{Message}
"""); + await datastarService.PatchElementsAsync($"""
{Message}
"""); }); app.Run(); diff --git a/examples/dotnet/fsharp/HelloWorld/Program.fs b/examples/dotnet/fsharp/HelloWorld/Program.fs index 46004c1e8..9c95c1f9e 100644 --- a/examples/dotnet/fsharp/HelloWorld/Program.fs +++ b/examples/dotnet/fsharp/HelloWorld/Program.fs @@ -1,26 +1,16 @@ namespace HelloWorld #nowarn "20" open System -open System.Collections.Generic -open System.IO -open System.Linq -open System.Text -open System.Threading open System.Threading.Tasks -open Microsoft.AspNetCore open Microsoft.AspNetCore.Builder -open Microsoft.AspNetCore.Hosting open Microsoft.AspNetCore.Http -open Microsoft.AspNetCore.HttpsPolicy -open Microsoft.Extensions.Configuration open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Hosting -open Microsoft.Extensions.Logging open StarFederation.Datastar.FSharp module Program = [] - type MySignals = { delay:float } + type MySignals = { delay:int } let [] Message = "Hello, world!" @@ -33,20 +23,18 @@ module Program = app.UseStaticFiles() app.MapGet("/hello-world", Func(fun ctx -> task { - let sseHandler : ISendServerEvent = ServerSentEventHttpHandler ctx.HttpContext.Response - do! sseHandler.StartServerEventStream() + do! ServerSentEventGenerator.StartServerEventStreamAsync(ctx.HttpContext.Response) - let signalsHandler : IReadSignals = SignalsHttpHandler ctx.HttpContext.Request - let! signals = signalsHandler.ReadSignalsAsync() + let! signals = ServerSentEventGenerator.ReadSignalsAsync(ctx.HttpContext.Request) let delayMs = (signals |> ValueOption.map _.delay |> ValueOption.defaultValue 1000) [0 .. (Message.Length - 1)] |> Seq.map (fun length -> Message[0..length]) |> Seq.map (fun message -> $"""
{message}
""") - |> Seq.map ServerSentEventGenerator.MergeFragments - |> Seq.map (fun sse -> async { - do! sse |> sseHandler.SendServerEvent |> Async.AwaitTask - do! Async.Sleep(TimeSpan.FromMilliseconds(delayMs)) }) + |> Seq.map (fun element -> async { + do! ServerSentEventGenerator.PatchElementsAsync(ctx.HttpContext.Response, element) |> Async.AwaitTask + do! Async.Sleep delayMs + } ) |> Async.Sequential |> Async.RunSynchronously })) diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index c110ea847..75635d474 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -25,21 +25,21 @@ using System.Text.Json; using System.Text.Json.Serialization; // add as an ASP Service -// allows injection of IServerSentEventService, to respond to a request with a Datastar friendly ServerSentEvent -// and ISignals, to read the signals sent by the client +// allows injection of IDatastarService, to respond to a request with a Datastar friendly ServerSentEvent +// and to read the signals sent by the client builder.Services.AddDatastar(); // displayDate - merging a fragment -app.MapGet("/displayDate", async (IDatastarServerSentEventService sse) => +app.MapGet("/displayDate", async (IDatastarService datastarService) => { string today = DateTime.Now.ToString("%y-%M-%d %h:%m:%s"); - await sse.MergeFragmentsAsync($"""
{today}
"""); + await datastarService.PatchElementsAsync($"""
{today}
"""); }); // removeDate - removing a fragment -app.MapGet("/removeDate", async (IDatastarServerSentEventService sse) => { await sse.RemoveFragmentsAsync("#date"); }); +app.MapGet("/removeDate", async (IDatastarService datastarService) => { await datastarService.RemoveFragmentAsync("#date"); }); -public record Signals { +public record MySignals { [JsonPropertyName("input")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Input { get; init; } = null; @@ -52,11 +52,11 @@ public record Signals { } // changeOutput - reads the signals, update the Output, and merge back -app.MapPost("/changeOutput", async (IDatastarServerSentEventService sse, IDatastarSignalsReaderService dsSignals) => ... +app.MapPost("/changeOutput", async (IDatastarService datastarService) => ... { - Signals signals = await dsSignals.ReadSignalsAsync(); - Signals newSignals = new() { Output = $"Your Input: {signals.Input}" }; - await sse.MergeSignalsAsync(newSignals.Serialize()); + MySignals signals = await datastarService.ReadSignalsAsync(); + MySignals newSignals = new() { Output = $"Your Input: {signals.Input}" }; + await datastarService.PatchSignalsAsync(newSignals.Serialize()); }); ``` @@ -78,8 +78,6 @@ public IActionResult Test_GetSignals([FromSignals] MySignals signals) => ... public IActionResult Test_GetValues([FromSignals] string myString, [FromSignals] int myInt) => ... -public IActionResult Test_GetInner([FromSignals] MySignals.InnerSignals myInner) => ... - public IActionResult Test_GetInnerPathed([FromSignals(Path = "myInner")] MySignals.InnerSignals myInnerOther) => ... public IActionResult Test_GetInnerValues([FromSignals(Path = "myInner.myInnerString")] string myInnerStringOther, [FromSignals(Path = "myInner.myInnerInt")] int myInnerIntOther) => ... diff --git a/sdk/dotnet/csharp/src/DependencyInjection/Services.cs b/sdk/dotnet/csharp/src/DependencyInjection/Services.cs index dada17fb0..4d138945b 100644 --- a/sdk/dotnet/csharp/src/DependencyInjection/Services.cs +++ b/sdk/dotnet/csharp/src/DependencyInjection/Services.cs @@ -1,25 +1,29 @@ - -using System.Security.Cryptography; using System.Text.Json; -using Microsoft.FSharp.Collections; +using Microsoft.Extensions.Primitives; using Microsoft.FSharp.Core; using Core = StarFederation.Datastar.FSharp; namespace StarFederation.Datastar.DependencyInjection; -public interface IDatastarServerSentEventService +public interface IDatastarService { - void AddHeaders(params KeyValuePair[] httpHeaders); - Task StartServerEventStream(); - Task MergeFragmentsAsync(string fragments, MergeFragmentsOptions? options = null); - Task RemoveFragmentsAsync(string selector, RemoveFragmentsOptions? options = null); - Task MergeSignalsAsync(string dataSignals, MergeSignalsOptions? options = null); - Task RemoveSignalsAsync(IEnumerable paths, EventOptions? options = null); + Task StartServerEventStreamAsync(IEnumerable>? additionalHeaders = null); + Task StartServerEventStreamAsync(IEnumerable>? additionalHeaders = null); + + Task PatchElementsAsync(string fragments, PatchElementsOptions? options = null); + + Task RemoveElementAsync(string selector, RemoveElementOptions? options = null); + + /// + /// Note: If TType is string then it is assumed that it is an already serialized Signals, otherwise serialize with jsonSerializerOptions + /// + Task PatchSignalsAsync(TType signals, JsonSerializerOptions? jsonSerializerOptions = null, PatchSignalsOptions? patchSignalsOptions = null); + + /// + /// Execute a JS script on the client. Note: Do NOT include "<script>" encapsulation + /// Task ExecuteScriptAsync(string script, ExecuteScriptOptions? options = null); -} -public interface IDatastarSignalsReaderService -{ /// /// Get the serialized signals as a stream /// @@ -38,38 +42,37 @@ public interface IDatastarSignalsReaderService Task ReadSignalsAsync(JsonSerializerOptions? options = null); } -internal class ServerSentEventService(Core.ISendServerEvent handler) : IDatastarServerSentEventService +internal class DatastarService(Core.ServerSentEventGenerator serverSentEventGenerator) : IDatastarService { - public void AddHeaders(params KeyValuePair[] httpHeaders) => _additionalHeaders.AddRange(httpHeaders ?? []); - - public Task StartServerEventStream() => handler.StartServerEventStream(_additionalHeaders.Select(kv => kv.AsTuple()).ToArray()); + public Task StartServerEventStreamAsync(IEnumerable>? additionalHeaders) => + serverSentEventGenerator.StartServerEventStreamAsync(additionalHeaders ?? []); - public Task MergeFragmentsAsync(string fragments, MergeFragmentsOptions? options = null) => handler.SendServerEvent(Core.ServerSentEventGenerator.MergeFragments(fragments, options ?? new())); + public Task StartServerEventStreamAsync(IEnumerable>? additionalHeaders) => + serverSentEventGenerator.StartServerEventStreamAsync(additionalHeaders?.Select(kvp => new KeyValuePair(kvp.Key, new StringValues(kvp.Value))) ?? []); - public Task RemoveFragmentsAsync(string selector, RemoveFragmentsOptions? options = null) => handler.SendServerEvent(Core.ServerSentEventGenerator.RemoveFragments(selector, options ?? new())); + public Task PatchElementsAsync(string fragments, PatchElementsOptions? options = null) => + serverSentEventGenerator.PatchElementsAsync(fragments, options ?? Core.PatchElementsOptions.Defaults); - public Task MergeSignalsAsync(string dataSignals, MergeSignalsOptions? options = null) => handler.SendServerEvent(Core.ServerSentEventGenerator.MergeSignals(dataSignals, options ?? new())); + public Task RemoveElementAsync(string selector, RemoveElementOptions? options = null) => + serverSentEventGenerator.RemoveElementAsync(selector, options ?? Core.RemoveElementOptions.Defaults); - public Task RemoveSignalsAsync(IEnumerable paths, EventOptions? options = null) => handler.SendServerEvent(Core.ServerSentEventGenerator.RemoveSignals(paths, options ?? new())); + public Task PatchSignalsAsync(TType signals, JsonSerializerOptions? jsonSerializerOptions = null, PatchSignalsOptions? patchSignalsOptions = null) => + serverSentEventGenerator.PatchSignalsAsync(signals as string ?? JsonSerializer.Serialize(signals, jsonSerializerOptions), patchSignalsOptions ?? Core.PatchSignalsOptions.Defaults); - public Task ExecuteScriptAsync(string script, ExecuteScriptOptions? options = null) => handler.SendServerEvent(Core.ServerSentEventGenerator.ExecuteScript(script, options ?? new())); + public Task ExecuteScriptAsync(string script, ExecuteScriptOptions? options = null) => + serverSentEventGenerator.ExecuteScriptAsync(script, options ?? Core.ExecuteScriptOptions.Defaults); - private List> _additionalHeaders = new(); -} - -internal class SignalsReaderService(Core.IReadSignals handler) : IDatastarSignalsReaderService -{ - public Stream GetSignalsStream() => handler.GetSignalsStream(); + public Stream GetSignalsStream() => serverSentEventGenerator.GetSignalsStream(); public async Task ReadSignalsAsync() { - string? signals = await handler.ReadSignalsAsync(); + string? signals = await serverSentEventGenerator.ReadSignalsAsync(); return String.IsNullOrEmpty(signals) ? null : signals; } public async Task ReadSignalsAsync(JsonSerializerOptions? jsonSerializerOptions = null) { - FSharpValueOption read = await handler.ReadSignalsAsync(jsonSerializerOptions ?? JsonSerializerOptions.Default); - return read.IsSome ? read.Value : default(TType?); + FSharpValueOption read = await serverSentEventGenerator.ReadSignalsAsync(jsonSerializerOptions ?? Core.JsonSerializerOptions.SignalsDefault); + return read.IsSome ? read.Value : default; } } \ No newline at end of file diff --git a/sdk/dotnet/csharp/src/DependencyInjection/ServicesProvider.cs b/sdk/dotnet/csharp/src/DependencyInjection/ServicesProvider.cs index d56464004..626c72db0 100644 --- a/sdk/dotnet/csharp/src/DependencyInjection/ServicesProvider.cs +++ b/sdk/dotnet/csharp/src/DependencyInjection/ServicesProvider.cs @@ -10,18 +10,12 @@ public static IServiceCollection AddDatastar(this IServiceCollection serviceColl { serviceCollection .AddHttpContextAccessor() - .AddScoped(svcPvd => + .AddScoped(svcPvd => { IHttpContextAccessor? httpContextAccessor = svcPvd.GetService(); - Core.IReadSignals signalsHttpHandler = new Core.SignalsHttpHandler(httpContextAccessor!.HttpContext!.Request); - return new SignalsReaderService(signalsHttpHandler); - }) - .AddScoped(svcPvd => - { - IHttpContextAccessor? httpContextAccessor = svcPvd.GetService(); - Core.ISendServerEvent sseHttpHandler = new Core.ServerSentEventHttpHandler(httpContextAccessor!.HttpContext!.Response); - return new ServerSentEventService(sseHttpHandler); + Core.ServerSentEventGenerator serverSentEventGenerator = new(httpContextAccessor); + return new DatastarService(serverSentEventGenerator); }); return serviceCollection; } -} \ No newline at end of file +} diff --git a/sdk/dotnet/csharp/src/DependencyInjection/Types.cs b/sdk/dotnet/csharp/src/DependencyInjection/Types.cs index 5295fe29e..4347e96f5 100644 --- a/sdk/dotnet/csharp/src/DependencyInjection/Types.cs +++ b/sdk/dotnet/csharp/src/DependencyInjection/Types.cs @@ -3,67 +3,67 @@ namespace StarFederation.Datastar.DependencyInjection; -public class MergeFragmentsOptions +public class PatchElementsOptions { public string? Selector { get; init; } = null; - public FragmentMergeMode MergeMode { get; init; } = Consts.DefaultFragmentMergeMode; - public bool UseViewTransition { get; init; } = Consts.DefaultFragmentsUseViewTransitions; + public ElementPatchMode PatchMode { get; init; } = Consts.DefaultElementPatchMode; + public bool UseViewTransition { get; init; } = Consts.DefaultElementsUseViewTransitions; public string? EventId { get; init; } = null; public TimeSpan Retry { get; init; } = Consts.DefaultSseRetryDuration; - public static implicit operator FSharpValueOption(MergeFragmentsOptions options) => ToFSharp(options); - public static implicit operator Core.MergeFragmentsOptions(MergeFragmentsOptions options) => ToFSharp(options); + public static implicit operator FSharpValueOption(PatchElementsOptions options) => ToFSharp(options); + public static implicit operator Core.PatchElementsOptions(PatchElementsOptions options) => ToFSharp(options); - private static Core.MergeFragmentsOptions ToFSharp(MergeFragmentsOptions options) + private static Core.PatchElementsOptions ToFSharp(PatchElementsOptions options) { - return new Core.MergeFragmentsOptions( + return new Core.PatchElementsOptions( options.Selector ?? FSharpValueOption.ValueNone, - From(options.MergeMode), + From(options.PatchMode), options.UseViewTransition, options.EventId ?? FSharpValueOption.ValueNone, options.Retry ); - static Core.FragmentMergeMode From(FragmentMergeMode fragmentMergeMode) => fragmentMergeMode switch + static Core.ElementPatchMode From(ElementPatchMode patchElementsMode) => patchElementsMode switch { - FragmentMergeMode.Morph => Core.FragmentMergeMode.Morph, - FragmentMergeMode.Inner => Core.FragmentMergeMode.Inner, - FragmentMergeMode.Outer => Core.FragmentMergeMode.Outer, - FragmentMergeMode.Prepend => Core.FragmentMergeMode.Prepend, - FragmentMergeMode.Append => Core.FragmentMergeMode.Append, - FragmentMergeMode.Before => Core.FragmentMergeMode.Before, - FragmentMergeMode.After => Core.FragmentMergeMode.After, - FragmentMergeMode.UpsertAttributes => Core.FragmentMergeMode.UpsertAttributes, - _ => throw new ArgumentOutOfRangeException(nameof(fragmentMergeMode), fragmentMergeMode, message: null) + ElementPatchMode.Inner => Core.ElementPatchMode.Inner, + ElementPatchMode.Outer => Core.ElementPatchMode.Outer, + ElementPatchMode.Prepend => Core.ElementPatchMode.Prepend, + ElementPatchMode.Append => Core.ElementPatchMode.Append, + ElementPatchMode.Before => Core.ElementPatchMode.Before, + ElementPatchMode.After => Core.ElementPatchMode.After, + ElementPatchMode.Remove => Core.ElementPatchMode.Remove, + ElementPatchMode.Replace => Core.ElementPatchMode.Replace, + _ => throw new ArgumentOutOfRangeException(nameof(patchElementsMode), patchElementsMode, null) }; } } -public class MergeSignalsOptions +public class PatchSignalsOptions { - public bool OnlyIfMissing { get; init; } = Consts.DefaultMergeSignalsOnlyIfMissing; + public bool OnlyIfMissing { get; init; } = Consts.DefaultPatchSignalsOnlyIfMissing; public string? EventId { get; init; } = null; public TimeSpan Retry { get; init; } = Consts.DefaultSseRetryDuration; - public static implicit operator Core.MergeSignalsOptions(MergeSignalsOptions options) => ToFSharp(options); - public static implicit operator FSharpValueOption(MergeSignalsOptions options) => ToFSharp(options); + public static implicit operator Core.PatchSignalsOptions(PatchSignalsOptions options) => ToFSharp(options); + public static implicit operator FSharpValueOption(PatchSignalsOptions options) => ToFSharp(options); - private static Core.MergeSignalsOptions ToFSharp(MergeSignalsOptions options) => new( + private static Core.PatchSignalsOptions ToFSharp(PatchSignalsOptions options) => new( options.OnlyIfMissing, options.EventId ?? FSharpValueOption.ValueNone, options.Retry); } -public class RemoveFragmentsOptions +public class RemoveElementOptions { - public bool UseViewTransition { get; init; } = Consts.DefaultFragmentsUseViewTransitions; + public bool UseViewTransition { get; init; } = Consts.DefaultElementsUseViewTransitions; public string? EventId { get; init; } = null; public TimeSpan Retry { get; init; } = Consts.DefaultSseRetryDuration; - public static implicit operator Core.RemoveFragmentsOptions(RemoveFragmentsOptions options) => ToFSharp(options); - public static implicit operator FSharpValueOption(RemoveFragmentsOptions options) => ToFSharp(options); + public static implicit operator Core.RemoveElementOptions(RemoveElementOptions options) => ToFSharp(options); + public static implicit operator FSharpValueOption(RemoveElementOptions options) => ToFSharp(options); - private static Core.RemoveFragmentsOptions ToFSharp(RemoveFragmentsOptions options) => new( + private static Core.RemoveElementOptions ToFSharp(RemoveElementOptions options) => new( options.UseViewTransition, options.EventId ?? FSharpValueOption.ValueNone, options.Retry); @@ -71,8 +71,6 @@ public class RemoveFragmentsOptions public class ExecuteScriptOptions { - public bool AutoRemove { get; init; } = Consts.DefaultExecuteScriptAutoRemove; - public string[] Attributes { get; init; } = []; public string? EventId { get; init; } = null; public TimeSpan Retry { get; init; } = Consts.DefaultSseRetryDuration; @@ -80,19 +78,6 @@ public class ExecuteScriptOptions public static implicit operator FSharpValueOption(ExecuteScriptOptions options) => ToFSharp(options); private static Core.ExecuteScriptOptions ToFSharp(ExecuteScriptOptions options) => new( - options.AutoRemove, - options.Attributes, options.EventId ?? FSharpValueOption.ValueNone, options.Retry); } - -public class EventOptions -{ - public string? EventId { get; init; } = null; - public TimeSpan Retry { get; init; } = Consts.DefaultSseRetryDuration; - - public static implicit operator Core.EventOptions(EventOptions options) => ToFSharp(options); - public static implicit operator FSharpValueOption(EventOptions options) => ToFSharp(options); - - private static Core.EventOptions ToFSharp(EventOptions options) => new(options.EventId, options.Retry); -} \ No newline at end of file diff --git a/sdk/dotnet/csharp/src/ModelBinding/FromSignalAttribute.cs b/sdk/dotnet/csharp/src/ModelBinding/FromSignalAttribute.cs index 5c4aa2f62..3cdc63b9d 100644 --- a/sdk/dotnet/csharp/src/ModelBinding/FromSignalAttribute.cs +++ b/sdk/dotnet/csharp/src/ModelBinding/FromSignalAttribute.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Core = StarFederation.Datastar.FSharp; namespace StarFederation.Datastar.ModelBinding; @@ -7,7 +8,7 @@ public class DatastarSignalsBindingSource(string path, JsonSerializerOptions? js { public const string BindingSourceName = "DatastarSignalsSource"; public string BindingPath { get; } = path; - public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions ?? JsonSerializerOptions.Default; + public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions ?? Core.JsonSerializerOptions.SignalsDefault; } /// @@ -23,6 +24,6 @@ public class DatastarSignalsBindingSource(string path, JsonSerializerOptions? js public class FromSignalsAttribute : Attribute, IBindingSourceMetadata { public string Path { get; set; } = String.Empty; - public JsonSerializerOptions JsonSerializerOptions { get; set; } = JsonSerializerOptions.Default; + public JsonSerializerOptions JsonSerializerOptions { get; set; } = Core.JsonSerializerOptions.SignalsDefault; public BindingSource BindingSource => new DatastarSignalsBindingSource(Path, JsonSerializerOptions); } \ No newline at end of file diff --git a/sdk/dotnet/csharp/src/ModelBinding/MvcServiceProvider.cs b/sdk/dotnet/csharp/src/ModelBinding/MvcServiceProvider.cs index a96b507f1..89c287bc2 100644 --- a/sdk/dotnet/csharp/src/ModelBinding/MvcServiceProvider.cs +++ b/sdk/dotnet/csharp/src/ModelBinding/MvcServiceProvider.cs @@ -8,7 +8,7 @@ public static class ServiceCollectionExtensionMethods public static IServiceCollection AddDatastarMvc(this IServiceCollection serviceCollection) { // ReSharper disable once SuspiciousTypeConversion.Global - if (!serviceCollection.Any(_ => _.ServiceType == typeof(IDatastarSignalsReaderService))) + if (!serviceCollection.Any(_ => _.ServiceType == typeof(IDatastarService))) { throw new Exception($"{nameof(AddDatastarMvc)} requires that {nameof(StarFederation.Datastar.DependencyInjection.ServiceCollectionExtensionMethods.AddDatastar)} is added first"); } diff --git a/sdk/dotnet/csharp/src/ModelBinding/SignalsModelBinder.cs b/sdk/dotnet/csharp/src/ModelBinding/SignalsModelBinder.cs index 3a51c7877..7cd027582 100644 --- a/sdk/dotnet/csharp/src/ModelBinding/SignalsModelBinder.cs +++ b/sdk/dotnet/csharp/src/ModelBinding/SignalsModelBinder.cs @@ -6,7 +6,7 @@ namespace StarFederation.Datastar.ModelBinding; -public class SignalsModelBinder(ILogger logger, IDatastarSignalsReaderService signalsReader) : IModelBinder +public class SignalsModelBinder(ILogger logger, IDatastarService signalsReader) : IModelBinder { public async Task BindModelAsync(ModelBindingContext bindingContext) { diff --git a/sdk/dotnet/csharp/src/StarFederation.Datastar.csproj b/sdk/dotnet/csharp/src/StarFederation.Datastar.csproj index 833c972c9..c898d4d7d 100644 --- a/sdk/dotnet/csharp/src/StarFederation.Datastar.csproj +++ b/sdk/dotnet/csharp/src/StarFederation.Datastar.csproj @@ -6,7 +6,7 @@ enable enable StarFederation.Datastar - 1.0.0-beta.6 + 1.0.0-beta.7 StarFederation.Datastar Greg Holden and contributors SDK for ServerSentEvents and convenience methods for Datastar diff --git a/sdk/dotnet/csharp/src/Utility.cs b/sdk/dotnet/csharp/src/Utility.cs index 3abbf0992..76db3cf05 100644 --- a/sdk/dotnet/csharp/src/Utility.cs +++ b/sdk/dotnet/csharp/src/Utility.cs @@ -62,4 +62,5 @@ internal static class Utilities } public static Tuple AsTuple(this KeyValuePair keyValuePair) => new(keyValuePair.Key, keyValuePair.Value); + public static Tuple AsTuple(this (TKey, TValue) keyValuePair) => new(keyValuePair.Item1, keyValuePair.Item2); } \ No newline at end of file diff --git a/sdk/dotnet/fsharp/src/Consts.fs b/sdk/dotnet/fsharp/src/Consts.fs index 67a012fe9..974d8fdaa 100644 --- a/sdk/dotnet/fsharp/src/Consts.fs +++ b/sdk/dotnet/fsharp/src/Consts.fs @@ -52,7 +52,7 @@ module Consts = let [] DatastarDatalineOnlyIfMissing = "onlyIfMissing" module ElementPatchMode = - let toString this = + let inline toString this = match this with | ElementPatchMode.Outer -> "outer" | ElementPatchMode.Inner -> "inner" @@ -64,7 +64,7 @@ module Consts = | ElementPatchMode.After -> "after" module EventType = - let toString this = + let inline toString this = match this with | EventType.PatchElements -> "datastar-patch-elements" | EventType.PatchSignals -> "datastar-patch-signals" \ No newline at end of file diff --git a/sdk/dotnet/fsharp/src/HttpHandlers.fs b/sdk/dotnet/fsharp/src/HttpHandlers.fs deleted file mode 100644 index 5467fa14d..000000000 --- a/sdk/dotnet/fsharp/src/HttpHandlers.fs +++ /dev/null @@ -1,111 +0,0 @@ -namespace StarFederation.Datastar.FSharp - -open System.IO -open System.Text -open System.Text.Json -open System.Threading -open System.Threading.Tasks -open Microsoft.AspNetCore.Http -open Microsoft.Extensions.Primitives -open Microsoft.Net.Http.Headers - -/// Implementation of ISendServerEvent, for sending SSEs to the HttpResponse -[] -type ServerSentEventHttpHandler (httpResponse:HttpResponse) = - let mutable _startResponseTask : Task = null - let _startResponseLock = obj() - - static member StartServerEventStream (httpResponse:HttpResponse, additionalHeaders:(string * string)[], cancellationToken:CancellationToken) = - let task = backgroundTask { - let setHeader (httpResponse:HttpResponse) (name, content:string) = - if httpResponse.Headers.ContainsKey(name) |> not then - httpResponse.Headers.Add(name, StringValues(content)) - - seq { - (HeaderNames.ContentType, "text/event-stream") - if (httpResponse.HttpContext.Request.Protocol = HttpProtocol.Http11) then - ("Connection", "keep-alive") - yield! additionalHeaders - } |> Seq.iter (setHeader httpResponse) - do! httpResponse.StartAsync(cancellationToken) - do! httpResponse.Body.FlushAsync(cancellationToken) - } - task :> Task - static member StartServerEventStream (httpResponse, additionalHeader) = ServerSentEventHttpHandler.StartServerEventStream(httpResponse, additionalHeader, CancellationToken.None) - - static member SendServerEvent (httpResponse:HttpResponse, sse, cancellationToken:CancellationToken) = - let task = task { - let serializedEvent = sse |> ServerSentEvent.serialize |> Encoding.UTF8.GetBytes - return! httpResponse.BodyWriter.WriteAsync(serializedEvent, cancellationToken) - } - task :> Task - static member SendServerEvent (sse, httpResponse) = ServerSentEventHttpHandler.SendServerEvent(httpResponse, sse, CancellationToken.None) - - interface ISendServerEvent with - - member this.StartServerEventStream (additionalHeaders, cancellationToken) = - lock _startResponseLock (fun () -> if _startResponseTask = null then _startResponseTask <- ServerSentEventHttpHandler.StartServerEventStream(httpResponse, additionalHeaders, cancellationToken)) - _startResponseTask - member this.StartServerEventStream(additionalHeaders) = (this:>ISendServerEvent).StartServerEventStream(additionalHeaders, CancellationToken.None) - member this.StartServerEventStream() = (this:>ISendServerEvent).StartServerEventStream(Array.empty, CancellationToken.None) - - member this.SendServerEvent(sse, cancellationToken) = task { - do! (this :> ISendServerEvent).StartServerEventStream(Array.empty, cancellationToken) - return! ServerSentEventHttpHandler.SendServerEvent(httpResponse, sse, cancellationToken) - } - member this.SendServerEvent(sse) = (this:>ISendServerEvent).SendServerEvent(sse, CancellationToken.None) - -/// Implementation of IReadSignals, for reading the Signals from the HttpRequest -[] -type SignalsHttpHandler (httpRequest:HttpRequest) = - - static member GetSignalsStream (httpRequest:HttpRequest) = - match httpRequest.Method with - | System.Net.WebRequestMethods.Http.Get -> - match httpRequest.Query.TryGetValue(Consts.DatastarKey) with - | true, stringValues when stringValues.Count > 0 -> (new MemoryStream(Encoding.UTF8.GetBytes(stringValues[0])) :> Stream) - | _ -> Stream.Null - | _ -> httpRequest.Body - - static member ReadSignalsAsync (httpRequest:HttpRequest, cancellationToken:CancellationToken) = task { - match httpRequest.Method with - | System.Net.WebRequestMethods.Http.Get -> - match httpRequest.Query.TryGetValue(Consts.DatastarKey) with - | true, stringValues when stringValues.Count > 0 -> return (stringValues[0] |> Signals.create) - | _ -> return Signals.empty - | _ -> - try - use readResult = new StreamReader(httpRequest.Body) - let! signals = readResult.ReadToEndAsync(cancellationToken) - return (signals |> Signals.create) - with _ -> return Signals.empty - } - - static member ReadSignalsAsync (httpRequest:HttpRequest) = SignalsHttpHandler.ReadSignalsAsync(httpRequest, CancellationToken.None) - - static member ReadSignalsAsync<'T> (httpRequest:HttpRequest, jsonSerializerOptions:JsonSerializerOptions, cancellationToken:CancellationToken) = task { - try - match httpRequest.Method with - | System.Net.WebRequestMethods.Http.Get -> - match httpRequest.Query.TryGetValue(Consts.DatastarKey) with - | true, stringValues when stringValues.Count > 0 -> - return ValueSome (JsonSerializer.Deserialize<'T>(stringValues[0], jsonSerializerOptions)) - | _ -> - return ValueNone - | _ -> - let! t = JsonSerializer.DeserializeAsync<'T>(httpRequest.Body, jsonSerializerOptions, cancellationToken) - return (ValueSome t) - with _ -> return ValueNone - } - - static member ReadSignalsAsync<'T> (httpRequest:HttpRequest, cancellationToken:CancellationToken) = SignalsHttpHandler.ReadSignalsAsync<'T>(httpRequest, JsonSerializerOptions.Default, cancellationToken) - static member ReadSignalsAsync<'T> (httpRequest:HttpRequest, jsonSerializerOptions:JsonSerializerOptions) = SignalsHttpHandler.ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions, CancellationToken.None) - static member ReadSignalsAsync<'T> (httpRequest:HttpRequest) = SignalsHttpHandler.ReadSignalsAsync<'T>(httpRequest, JsonSerializerOptions.Default) - - interface IReadSignals with - member this.GetSignalsStream() = SignalsHttpHandler.GetSignalsStream(httpRequest) - member this.ReadSignalsAsync(): Task = SignalsHttpHandler.ReadSignalsAsync(httpRequest) - member this.ReadSignalsAsync(cancellationToken: CancellationToken): Task = SignalsHttpHandler.ReadSignalsAsync(httpRequest, cancellationToken) - member this.ReadSignalsAsync<'T>(): Task<'T voption> = SignalsHttpHandler.ReadSignalsAsync<'T>(httpRequest) - member this.ReadSignalsAsync<'T>(jsonSerializerOptions: JsonSerializerOptions): Task<'T voption> = SignalsHttpHandler.ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions) - member this.ReadSignalsAsync<'T>(jsonSerializerOptions, cancellationToken) = SignalsHttpHandler.ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions, cancellationToken) \ No newline at end of file diff --git a/sdk/dotnet/fsharp/src/ServerSentEvent.fs b/sdk/dotnet/fsharp/src/ServerSentEvent.fs new file mode 100644 index 000000000..364c3845c --- /dev/null +++ b/sdk/dotnet/fsharp/src/ServerSentEvent.fs @@ -0,0 +1,56 @@ +namespace StarFederation.Datastar.FSharp + +open System +open System.Buffers +open System.Text + +module internal ServerSentEvent = + let private eventPrefix = "event: "B + let private idPrefix = "id: "B + let private retryPrefix = "retry: "B + let private dataPrefix = "data: "B + + let inline private writeUtf8String (str:string) (writer:IBufferWriter) = + let span = writer.GetSpan(Encoding.UTF8.GetByteCount(str)) + let bytesWritten = Encoding.UTF8.GetBytes(str.AsSpan(), span) + writer.Advance(bytesWritten) + writer + + let inline private writeUtf8Literal (bytes:byte[]) (writer:IBufferWriter) = + let span = writer.GetSpan(bytes.Length) + bytes.AsSpan().CopyTo(span) + writer.Advance(bytes.Length) + writer + + let inline writeNewline (writer:IBufferWriter) = + let span = writer.GetSpan(1) + span[0] <- 10uy // '\n' + writer.Advance(1) + + let inline sendEventType eventType writer = + writer + |> writeUtf8Literal eventPrefix + |> writeUtf8String (Consts.EventType.toString eventType) + |> writeNewline + + let inline sendId id writer = + match id with + | ValueSome idValue -> + writer + |> writeUtf8Literal idPrefix + |> writeUtf8String idValue + |> writeNewline + | _ -> () + + let inline sendRetry (retry:TimeSpan) writer = + if retry <> Consts.DefaultSseRetryDuration then + writer + |> writeUtf8Literal retryPrefix + |> writeUtf8String (retry.TotalMilliseconds.ToString()) + |> writeNewline + + let inline sendDataLine dataLine writer = + writer + |> writeUtf8Literal dataPrefix + |> writeUtf8String dataLine + |> writeNewline diff --git a/sdk/dotnet/fsharp/src/ServerSentEventGenerator.fs b/sdk/dotnet/fsharp/src/ServerSentEventGenerator.fs index 14ea8e200..5bc947b2e 100644 --- a/sdk/dotnet/fsharp/src/ServerSentEventGenerator.fs +++ b/sdk/dotnet/fsharp/src/ServerSentEventGenerator.fs @@ -1,57 +1,268 @@ namespace StarFederation.Datastar.FSharp +open System.Collections.Concurrent +open System.Collections.Generic +open System.IO +open System.Text +open System.Text.Json +open System.Threading +open System.Threading.Tasks +open Microsoft.AspNetCore.Http +open Microsoft.Extensions.Primitives open StarFederation.Datastar.FSharp.Utility -[] -type ServerSentEventGenerator = - static member MergeFragments(fragments, options:MergeFragmentsOptions) = - { EventType = MergeFragments - Id = options.EventId - Retry = options.Retry - DataLines = [| - if (options.Selector |> ValueOption.isSome) then $"{Consts.DatastarDatalineSelector} {options.Selector |> ValueOption.get |> Selector.value}" - if (options.MergeMode <> Consts.DefaultFragmentMergeMode) then $"{Consts.DatastarDatalineMergeMode} {options.MergeMode |> Consts.FragmentMergeMode.toString}" - if (options.UseViewTransition <> Consts.DefaultFragmentsUseViewTransitions) then $"{Consts.DatastarDatalineUseViewTransition} %A{options.UseViewTransition}" - yield! (fragments |> String.split String.newLines |> Seq.map (fun fragmentLine -> $"{Consts.DatastarDatalineFragments} %s{fragmentLine}")) - |] } - static member MergeFragments fragments = ServerSentEventGenerator.MergeFragments (fragments, MergeFragmentsOptions.defaults) - - static member RemoveFragments(selector, options:RemoveFragmentsOptions) = - { EventType = RemoveFragments - Id = options.EventId - Retry = options.Retry - DataLines = [| - $"{Consts.DatastarDatalineSelector} {selector |> Selector.value}" - if (options.UseViewTransition <> Consts.DefaultFragmentsUseViewTransitions) then $"{Consts.DatastarDatalineUseViewTransition} %A{options.UseViewTransition}" - |] } - static member RemoveFragments selector = ServerSentEventGenerator.RemoveFragments(selector, RemoveFragmentsOptions.defaults) - - static member MergeSignals(signals, options:MergeSignalsOptions) = - { EventType = MergeSignals - Id = options.EventId - Retry = options.Retry - DataLines = [| - if (options.OnlyIfMissing <> Consts.DefaultMergeSignalsOnlyIfMissing) then $"{Consts.DatastarDatalineOnlyIfMissing} %A{options.OnlyIfMissing}" - yield! signals |> Signals.value |> String.split String.newLines |> Seq.map (fun dataLine -> $"{Consts.DatastarDatalineSignals} %s{dataLine}") - |] } - static member MergeSignals signals = ServerSentEventGenerator.MergeSignals(signals, MergeSignalsOptions.defaults) - - static member RemoveSignals(signalPaths, options:EventOptions) = - let paths' = signalPaths |> Seq.map SignalPath.value |> String.concat " " - { EventType = RemoveSignals - Id = options.EventId - Retry = options.Retry - DataLines = [| $"{Consts.DatastarDatalinePaths} {paths'}" |] } - static member RemoveSignals signalPaths = ServerSentEventGenerator.RemoveSignals(signalPaths, EventOptions.defaults) - - static member ExecuteScript(script, options:ExecuteScriptOptions) = - { EventType = ExecuteScript - Id = options.EventId - Retry = options.Retry - DataLines = [| - if (options.AutoRemove <> Consts.DefaultExecuteScriptAutoRemove) then $"{Consts.DatastarDatalineAutoRemove} %A{options.AutoRemove}" - if (not <| Seq.forall2 (=) options.Attributes [| Consts.DefaultExecuteScriptAttributes |] ) then - yield! options.Attributes |> Seq.map (fun attr -> $"{Consts.DefaultExecuteScriptAttributes} {attr}") - yield! script |> String.split String.newLines |> Seq.map (fun scriptLine -> $"{Consts.DatastarDatalineScript} %s{scriptLine}") - |] } - static member ExecuteScript script = ServerSentEventGenerator.ExecuteScript(script, ExecuteScriptOptions.defaults) +[] +type ServerSentEventGenerator(httpContextAccessor:IHttpContextAccessor) = + let httpRequest = httpContextAccessor.HttpContext.Request + let httpResponse = httpContextAccessor.HttpContext.Response + let mutable _startResponseTask : Task = null + let _startResponseLock = obj() + let _eventQueue = ConcurrentQueue Task>() + + static member StartServerEventStreamAsync(httpResponse:HttpResponse, additionalHeaders:KeyValuePair seq, cancellationToken:CancellationToken) = + let task = backgroundTask { + httpResponse.Headers.ContentType <- "text/event-stream" + if (httpResponse.HttpContext.Request.Protocol = HttpProtocol.Http11) then + httpResponse.Headers.Connection <- "keep-alive" + for KeyValue(name, content) in additionalHeaders do + match httpResponse.Headers.TryGetValue(name) with + | false, _ -> httpResponse.Headers.Add(name, content) + | true, _ -> () + do! httpResponse.StartAsync(cancellationToken) + return! httpResponse.BodyWriter.FlushAsync(cancellationToken) + } + task :> Task + + static member PatchElementsAsync(httpResponse:HttpResponse, elements:string, options:PatchElementsOptions, cancellationToken:CancellationToken) = + let writer = httpResponse.BodyWriter + writer |> ServerSentEvent.sendEventType PatchElements + writer |> ServerSentEvent.sendId options.EventId + writer |> ServerSentEvent.sendRetry options.Retry + + match options.Selector with + | ValueSome selector -> writer |> ServerSentEvent.sendDataLine $"{Consts.DatastarDatalineSelector} {Selector.value selector}" + | _ -> () + + if options.PatchMode <> Consts.DefaultElementPatchMode then + writer |> ServerSentEvent.sendDataLine $"{Consts.DatastarDatalineMode} {Consts.ElementPatchMode.toString options.PatchMode}" + + if options.UseViewTransition <> Consts.DefaultElementsUseViewTransitions then + writer |> ServerSentEvent.sendDataLine $"{Consts.DatastarDatalineUseViewTransition} %A{options.UseViewTransition}" + + for segment in String.splitLinesToSegments elements do + writer |> ServerSentEvent.sendDataLine (String.buildDataLine Consts.DatastarDatalineElements segment) + + writer |> ServerSentEvent.writeNewline + + writer.FlushAsync(cancellationToken).AsTask() :> Task + + static member RemoveElementAsync(httpResponse:HttpResponse, selector:Selector, options:RemoveElementOptions, cancellationToken:CancellationToken) = + let writer = httpResponse.BodyWriter + writer |> ServerSentEvent.sendEventType PatchElements + writer |> ServerSentEvent.sendId options.EventId + writer |> ServerSentEvent.sendRetry options.Retry + + writer |> ServerSentEvent.sendDataLine $"{Consts.DatastarDatalineSelector} {selector |> Selector.value}" + writer |> ServerSentEvent.sendDataLine $"{Consts.DatastarDatalineMode} {ElementPatchMode.Remove |> Consts.ElementPatchMode.toString}" + + if options.UseViewTransition <> Consts.DefaultElementsUseViewTransitions then + writer |> ServerSentEvent.sendDataLine $"{Consts.DatastarDatalineUseViewTransition} %A{options.UseViewTransition}" + + writer |> ServerSentEvent.writeNewline + + writer.FlushAsync(cancellationToken).AsTask() :> Task + + static member PatchSignalsAsync(httpResponse:HttpResponse, signals:Signals, options:PatchSignalsOptions, cancellationToken:CancellationToken) = + let writer = httpResponse.BodyWriter + writer |> ServerSentEvent.sendEventType PatchSignals + writer |> ServerSentEvent.sendId options.EventId + writer |> ServerSentEvent.sendRetry options.Retry + + if options.OnlyIfMissing <> Consts.DefaultPatchSignalsOnlyIfMissing then + writer |> ServerSentEvent.sendDataLine $"{Consts.DatastarDatalineOnlyIfMissing} %A{options.OnlyIfMissing}" + + for segment in String.splitLinesToSegments (Signals.value signals) do + writer |> ServerSentEvent.sendDataLine (String.buildDataLine Consts.DatastarDatalineSignals segment) + + writer |> ServerSentEvent.writeNewline + + writer.FlushAsync(cancellationToken).AsTask() :> Task + + static member ExecuteScriptAsync(httpResponse:HttpResponse, script:string, options:ExecuteScriptOptions, cancellationToken:CancellationToken) = + let writer = httpResponse.BodyWriter + writer |> ServerSentEvent.sendEventType PatchElements + writer |> ServerSentEvent.sendId options.EventId + writer |> ServerSentEvent.sendRetry options.Retry + + writer |> ServerSentEvent.sendDataLine $"{Consts.DatastarDatalineElements} " + + writer |> ServerSentEvent.writeNewline + + writer.FlushAsync(cancellationToken).AsTask() :> Task + + static member GetSignalsStream(httpRequest:HttpRequest) = + match httpRequest.Method with + | System.Net.WebRequestMethods.Http.Get -> + match httpRequest.Query.TryGetValue(Consts.DatastarKey) with + | true, stringValues when stringValues.Count > 0 -> (new MemoryStream(Encoding.UTF8.GetBytes(stringValues[0])) :> Stream) + | _ -> Stream.Null + | _ -> httpRequest.Body + + static member ReadSignalsAsync(httpRequest:HttpRequest, cancellationToken:CancellationToken) = + task { + match httpRequest.Method with + | System.Net.WebRequestMethods.Http.Get -> + match httpRequest.Query.TryGetValue(Consts.DatastarKey) with + | true, stringValues when stringValues.Count > 0 -> return (stringValues[0] |> Signals.create) + | _ -> return Signals.empty + | _ -> + try + use readResult = new StreamReader(httpRequest.Body) + let! signals = readResult.ReadToEndAsync(cancellationToken) + return (signals |> Signals.create) + with _ -> return Signals.empty + } + + static member ReadSignalsAsync<'T>(httpRequest:HttpRequest, jsonSerializerOptions:JsonSerializerOptions, cancellationToken:CancellationToken) = + task { + try + match httpRequest.Method with + | System.Net.WebRequestMethods.Http.Get -> + match httpRequest.Query.TryGetValue(Consts.DatastarKey) with + | true, stringValues when stringValues.Count > 0 -> + return ValueSome (JsonSerializer.Deserialize<'T>(stringValues[0], jsonSerializerOptions)) + | _ -> + return ValueNone + | _ -> + let! t = JsonSerializer.DeserializeAsync<'T>(httpRequest.Body, jsonSerializerOptions, cancellationToken) + return (ValueSome t) + with _ -> return ValueNone + } + + member this.StartServerEventStreamAsync(additionalHeaders, cancellationToken) = + lock _startResponseLock (fun () -> + if _startResponseTask = null + then _startResponseTask <- ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, additionalHeaders, cancellationToken) + ) + _startResponseTask + + member private this.SendEventAsync(sendEventTask:unit -> Task, cancellationToken:CancellationToken) = + task { + _eventQueue.Enqueue(sendEventTask) + do! + if _startResponseTask <> null + then _startResponseTask + else ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, Seq.empty, cancellationToken) + let (_, sendEventTask') = _eventQueue.TryDequeue() + return! sendEventTask' () + } + + member this.PatchElementsAsync(elements, options, cancellationToken) = + let sendTask = fun () -> ServerSentEventGenerator.PatchElementsAsync(httpResponse, elements, options, cancellationToken) + this.SendEventAsync (sendTask, cancellationToken) :> Task + + member this.RemoveElementAsync(selector, options, cancellationToken) = + let sendTask = fun () -> ServerSentEventGenerator.RemoveElementAsync(httpResponse, selector, options, cancellationToken) + this.SendEventAsync (sendTask, cancellationToken) :> Task + + member this.PatchSignalsAsync(signals, options, cancellationToken) = + let sendTask = fun () -> ServerSentEventGenerator.PatchSignalsAsync(httpResponse, signals, options, cancellationToken) + this.SendEventAsync (sendTask, cancellationToken) :> Task + + member this.ExecuteScriptAsync(script, options, cancellationToken) = + let sendTask = fun () -> ServerSentEventGenerator.ExecuteScriptAsync(httpResponse, script, options, cancellationToken) + this.SendEventAsync (sendTask, cancellationToken) :> Task + + member this.GetSignalsStream() = + ServerSentEventGenerator.GetSignalsStream(httpRequest) + + member this.ReadSignalsAsync(cancellationToken) : Task = + ServerSentEventGenerator.ReadSignalsAsync(httpRequest, cancellationToken) + + member this.ReadSignalsAsync<'T>(jsonSerializerOptions, cancellationToken) = + ServerSentEventGenerator.ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions, cancellationToken) + + // + // SHORT HAND METHODS + // + static member StartServerEventStreamAsync(httpResponse, additionalHeaders) = + ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, additionalHeaders, httpResponse.HttpContext.RequestAborted) + static member StartServerEventStreamAsync(httpResponse) = + ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, Seq.empty, httpResponse.HttpContext.RequestAborted) + static member StartServerEventStreamAsync(httpResponse, cancellationToken) = + ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, Seq.empty, cancellationToken) + + static member PatchElementsAsync(httpResponse, elements, options) = + ServerSentEventGenerator.PatchElementsAsync(httpResponse, elements, options, httpResponse.HttpContext.RequestAborted) + static member PatchElementsAsync(httpResponse, elements) = + ServerSentEventGenerator.PatchElementsAsync(httpResponse, elements, PatchElementsOptions.Defaults, httpResponse.HttpContext.RequestAborted) + + static member RemoveElementAsync(httpResponse, selector, options) = + ServerSentEventGenerator.RemoveElementAsync(httpResponse, selector, options, httpResponse.HttpContext.RequestAborted) + static member RemoveElementAsync(httpResponse, selector) = + ServerSentEventGenerator.RemoveElementAsync(httpResponse, selector, RemoveElementOptions.Defaults, httpResponse.HttpContext.RequestAborted) + + static member PatchSignalsAsync(httpResponse, signals, options) = + ServerSentEventGenerator.PatchSignalsAsync(httpResponse, signals, options, httpResponse.HttpContext.RequestAborted) + static member PatchSignalsAsync(httpResponse, signals) = + ServerSentEventGenerator.PatchSignalsAsync(httpResponse, signals, PatchSignalsOptions.Defaults, httpResponse.HttpContext.RequestAborted) + + static member ExecuteScriptAsync(httpResponse, script, options) = + ServerSentEventGenerator.ExecuteScriptAsync(httpResponse, script, options, httpResponse.HttpContext.RequestAborted) + static member ExecuteScriptAsync(httpResponse, script) = + ServerSentEventGenerator.ExecuteScriptAsync(httpResponse, script, ExecuteScriptOptions.Defaults, httpResponse.HttpContext.RequestAborted) + + static member ReadSignalsAsync(httpRequest) = + ServerSentEventGenerator.ReadSignalsAsync(httpRequest, cancellationToken=httpRequest.HttpContext.RequestAborted) + static member ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions) = + ServerSentEventGenerator.ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions, httpRequest.HttpContext.RequestAborted) + static member ReadSignalsAsync<'T>(httpRequest) = + ServerSentEventGenerator.ReadSignalsAsync<'T>(httpRequest, JsonSerializerOptions.SignalsDefault, httpRequest.HttpContext.RequestAborted) + + member this.StartServerEventStreamAsync(additionalHeaders) = + ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, additionalHeaders, httpResponse.HttpContext.RequestAborted) + member this.StartServerEventStreamAsync(cancellationToken:CancellationToken) = + ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, Seq.empty, cancellationToken) + member this.StartServerEventStreamAsync() = + ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, Seq.empty, httpResponse.HttpContext.RequestAborted) + + member this.PatchElementsAsync(elements, options) = + ServerSentEventGenerator.PatchElementsAsync(httpResponse, elements, options, httpResponse.HttpContext.RequestAborted) + member this.PatchElementsAsync(elements, cancellationToken) = + ServerSentEventGenerator.PatchElementsAsync(httpResponse, elements, PatchElementsOptions.Defaults, cancellationToken) + member this.PatchElementsAsync(elements) = + ServerSentEventGenerator.PatchElementsAsync(httpResponse, elements, PatchElementsOptions.Defaults, httpResponse.HttpContext.RequestAborted) + + member this.RemoveElementAsync(selector, options) = + ServerSentEventGenerator.RemoveElementAsync(httpResponse, selector, options, httpResponse.HttpContext.RequestAborted) + member this.RemoveElementAsync(selector, cancellationToken) = + ServerSentEventGenerator.RemoveElementAsync(httpResponse, selector, RemoveElementOptions.Defaults, cancellationToken) + member this.RemoveElementAsync(selector) = + ServerSentEventGenerator.RemoveElementAsync(httpResponse, selector, RemoveElementOptions.Defaults, httpResponse.HttpContext.RequestAborted) + + member this.PatchSignalsAsync(signals, options) = + ServerSentEventGenerator.PatchSignalsAsync(httpResponse, signals, options, httpResponse.HttpContext.RequestAborted) + member this.PatchSignalsAsync(signals, cancellationToken) = + ServerSentEventGenerator.PatchSignalsAsync(httpResponse, signals, PatchSignalsOptions.Defaults, cancellationToken) + member this.PatchSignalsAsync(signals) = + ServerSentEventGenerator.PatchSignalsAsync(httpResponse, signals, PatchSignalsOptions.Defaults, httpResponse.HttpContext.RequestAborted) + + member this.ExecuteScriptAsync(script, options) = + ServerSentEventGenerator.ExecuteScriptAsync(httpResponse, script, options, httpResponse.HttpContext.RequestAborted) + member this.ExecuteScriptAsync(script, cancellationToken) = + ServerSentEventGenerator.ExecuteScriptAsync(httpResponse, script, ExecuteScriptOptions.Defaults, cancellationToken) + member this.ExecuteScriptAsync(script) = + ServerSentEventGenerator.ExecuteScriptAsync(httpResponse, script, ExecuteScriptOptions.Defaults, httpResponse.HttpContext.RequestAborted) + + member this.ReadSignalsAsync(): Task = + ServerSentEventGenerator.ReadSignalsAsync(httpRequest, httpRequest.HttpContext.RequestAborted) + member this.ReadSignalsAsync<'T>(jsonSerializerOptions) = + ServerSentEventGenerator.ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions, httpRequest.HttpContext.RequestAborted) + member this.ReadSignalsAsync<'T>(cancellationToken) = + ServerSentEventGenerator.ReadSignalsAsync<'T>(httpRequest, JsonSerializerOptions.SignalsDefault, cancellationToken) + member this.ReadSignalsAsync<'T>() = + ServerSentEventGenerator.ReadSignalsAsync<'T>(httpRequest, JsonSerializerOptions.SignalsDefault, httpRequest.HttpContext.RequestAborted) diff --git a/sdk/dotnet/fsharp/src/StarFederation.Datastar.FSharp.fsproj b/sdk/dotnet/fsharp/src/StarFederation.Datastar.FSharp.fsproj index 7dea5792b..35fc12e10 100644 --- a/sdk/dotnet/fsharp/src/StarFederation.Datastar.FSharp.fsproj +++ b/sdk/dotnet/fsharp/src/StarFederation.Datastar.FSharp.fsproj @@ -1,7 +1,7 @@ StarFederation.Datastar.FSharp - 1.0.0-beta.1 + 1.0.0-beta.2 StarFederation.Datastar.FSharp disabled @@ -49,11 +49,11 @@ README.md - + + - diff --git a/sdk/dotnet/fsharp/src/Types.fs b/sdk/dotnet/fsharp/src/Types.fs index f8b5ea84b..f75198ecc 100644 --- a/sdk/dotnet/fsharp/src/Types.fs +++ b/sdk/dotnet/fsharp/src/Types.fs @@ -2,20 +2,11 @@ namespace StarFederation.Datastar.FSharp open System open System.Collections.Generic -open System.IO open System.Text.Json open System.Text.Json.Nodes open System.Text.RegularExpressions -open System.Threading -open System.Threading.Tasks open StarFederation.Datastar.FSharp.Utility -type ServerSentEvent = - { EventType: EventType - Id: string voption - Retry: TimeSpan - DataLines: string[] } - /// /// Signals read to and from Datastar on the front end /// @@ -31,68 +22,59 @@ type SignalPath = string /// type Selector = string -type MergeFragmentsOptions = +[] +type PatchElementsOptions = { Selector: Selector voption - MergeMode: FragmentMergeMode + PatchMode: ElementPatchMode UseViewTransition: bool EventId: string voption Retry: TimeSpan } -type MergeSignalsOptions = - { OnlyIfMissing: bool - EventId: string voption - Retry: TimeSpan } -type RemoveFragmentsOptions = + with + static member Defaults = + { Selector = ValueNone + PatchMode = Consts.DefaultElementPatchMode + UseViewTransition = Consts.DefaultElementsUseViewTransitions + EventId = ValueNone + Retry = Consts.DefaultSseRetryDuration } + +[] +type RemoveElementOptions = { UseViewTransition: bool EventId: string voption Retry: TimeSpan } -type ExecuteScriptOptions = - { AutoRemove: bool - Attributes: string[] + with + static member Defaults = + { UseViewTransition = Consts.DefaultElementsUseViewTransitions + EventId = ValueNone + Retry = Consts.DefaultSseRetryDuration } + +[] +type PatchSignalsOptions = + { OnlyIfMissing: bool EventId: string voption Retry: TimeSpan } -type EventOptions = { EventId: string voption; Retry: TimeSpan } - -/// -/// Read the signals from the request -/// -type IReadSignals = - abstract GetSignalsStream : unit -> Stream - // - abstract ReadSignalsAsync : unit -> Task - abstract ReadSignalsAsync : CancellationToken -> Task - abstract ReadSignalsAsync<'T> : unit -> Task<'T voption> - abstract ReadSignalsAsync<'T> : JsonSerializerOptions -> Task<'T voption> - abstract ReadSignalsAsync<'T> : JsonSerializerOptions * CancellationToken -> Task<'T voption> - -/// -/// Can send SSEs to the client -/// -type ISendServerEvent = - abstract StartServerEventStream : unit -> Task - abstract StartServerEventStream : additionalHeaders:(string * string)[] -> Task - abstract StartServerEventStream : additionalHeaders:(string * string)[] * CancellationToken -> Task - // - abstract SendServerEvent : ServerSentEvent -> Task - abstract SendServerEvent : ServerSentEvent * CancellationToken -> Task - -module ServerSentEvent = - let serialize sse = - seq { - $"event: {sse.EventType |> Consts.EventType.toString}" - - if sse.Id |> ValueOption.isSome - then $"id: {sse.Id |> ValueOption.get}" - - if (sse.Retry <> Consts.DefaultSseRetryDuration) - then $"retry: {sse.Retry.TotalMilliseconds}" + with + static member Defaults = + { OnlyIfMissing = Consts.DefaultPatchSignalsOnlyIfMissing + EventId = ValueNone + Retry = Consts.DefaultSseRetryDuration } - yield! sse.DataLines |> Array.map (fun dataLine -> $"data: {dataLine}") +[] +type ExecuteScriptOptions = + { EventId: string voption; Retry: TimeSpan } + with + static member Defaults = + { EventId = ValueNone + Retry = Consts.DefaultSseRetryDuration } - ""; ""; "" - } |> String.concat "\n" +module JsonSerializerOptions = + let SignalsDefault = + let options = JsonSerializerOptions() + options.PropertyNameCaseInsensitive <- true + options module Signals = - let value (signals:Signals) : string = signals.ToString() + let inline value (signals:Signals) : string = signals let create (signalsString:string) = Signals signalsString let tryCreate (signalsString:string) = try @@ -102,8 +84,7 @@ module Signals = let empty = Signals "{ }" module SignalPath = - let value (signalPath:SignalPath) = signalPath.ToString() - let kebabValue signals = signals |> value |> String.toKebab + let inline value (signalPath:SignalPath) = signalPath let isValidKey (signalPathKey:string) = signalPathKey |> String.isPopulated && signalPathKey.ToCharArray() |> Seq.forall (fun chr -> Char.IsLetter chr || Char.IsNumber chr || chr = '_') let isValid (signalPathString:string) = signalPathString.Split('.') |> Array.forall isValidKey @@ -116,9 +97,10 @@ module SignalPath = then SignalPath signalPathString else failwith $"{signalPathString} is not a valid signal path" let create = sp - let keys signalPath = signalPath |> value |> String.split ["."] + let kebabValue signals = signals |> value |> String.toKebab + let keys (signalPath:SignalPath) = signalPath.Split('.') let createJsonNodePathToValue<'T> signalPath (signalValue:'T) = - signalPath + signalPath |> keys |> Seq.rev |> Seq.fold (fun json key -> @@ -126,8 +108,8 @@ module SignalPath = ) (JsonValue.Create(signalValue) :> JsonNode) module Selector = + let inline value (selector:Selector) = selector let regex = Regex(@"[#.][-_]?[_a-zA-Z]+(?:\w|\\.)*|(?<=\s+|^)(?:\w+|\*)|\[[^\s""'=<>`]+?(?`]+))?\]|:[\w-]+(?:\(.*\))?", RegexOptions.Compiled) - let value (selector:Selector) = selector.ToString() let isValid (selectorString:string) = regex.IsMatch selectorString let tryCreate (selectorString:string) = if isValid selectorString @@ -138,34 +120,3 @@ module Selector = then Selector selectorString else failwith $"{selectorString} is not a valid selector" let create = sel - -module MergeFragmentsOptions = - let defaults = - { Selector = ValueNone - MergeMode = Consts.DefaultFragmentMergeMode - UseViewTransition = Consts.DefaultFragmentsUseViewTransitions - EventId = ValueNone - Retry = Consts.DefaultSseRetryDuration } - -module MergeSignalsOptions = - let defaults = - { OnlyIfMissing = Consts.DefaultMergeSignalsOnlyIfMissing - EventId = ValueNone - Retry = Consts.DefaultSseRetryDuration } - -module RemoveFragmentsOptions = - let defaults = - { UseViewTransition = Consts.DefaultFragmentsUseViewTransitions - EventId = ValueNone - Retry = Consts.DefaultSseRetryDuration } - -module ExecuteScriptOptions = - let defaults = - { AutoRemove = Consts.DefaultExecuteScriptAutoRemove - Attributes = [| Consts.DefaultExecuteScriptAttributes |] - EventId = ValueNone - Retry = Consts.DefaultSseRetryDuration } - -module EventOptions = - let defaults = { EventId = ValueNone; Retry = Consts.DefaultSseRetryDuration } - diff --git a/sdk/dotnet/fsharp/src/Utility.fs b/sdk/dotnet/fsharp/src/Utility.fs index e2efac09e..79b73c519 100644 --- a/sdk/dotnet/fsharp/src/Utility.fs +++ b/sdk/dotnet/fsharp/src/Utility.fs @@ -1,17 +1,48 @@ -module internal StarFederation.Datastar.FSharp.Utility - -open System -open System.Text - -module internal String = - let newLines = [| "\r\n"; "\n"; "\r" |] - let split (delimiters:string seq) (line:string) = line.Split(delimiters |> Seq.toArray, StringSplitOptions.None) - let isPopulated = String.IsNullOrWhiteSpace >> not - let toKebab (pascalString:string) = - (StringBuilder(), pascalString.ToCharArray()) - ||> Seq.fold (fun stringBuilder chr -> - if Char.IsUpper(chr) - then stringBuilder.Append("-").Append(Char.ToLower(chr)) - else stringBuilder.Append(chr) +namespace StarFederation.Datastar.FSharp + +module internal Utility = + open System + open System.Text + open Microsoft.Extensions.Primitives + + module internal String = + let dotSeparator = [| '.' |] + let newLines = [| "\r\n"; "\n"; "\r" |] + let newLineChars = [| '\r'; '\n' |] + + // New zero-allocation version using StringTokenizer + let inline splitToSegments (separatorChars:char[]) (text:string) = + StringTokenizer(text, separatorChars) + |> Seq.filter (fun segment -> segment.Length > 0) + + let inline splitLinesToSegments (text:string) = + splitToSegments newLineChars text + + let buildDataLine (prefix:string) (segment:StringSegment) = + String.Create(prefix.Length + segment.Length + 1, (prefix, segment), fun span (prefix, segment) -> + let mutable pos = 0 + prefix.AsSpan().CopyTo(span.Slice(pos)) + pos <- pos + prefix.Length + span.[pos] <- ' ' + pos <- pos + 1 + segment.AsSpan().CopyTo(span.Slice(pos)) ) - |> _.Replace("-", "", 0, 1).ToString() \ No newline at end of file + + let buildDataLinesFromSegments (prefix:string) (content:string) = + splitLinesToSegments content + |> Seq.map (buildDataLine prefix) + |> Seq.toArray + |> StringValues + + let isPopulated = (String.IsNullOrWhiteSpace >> not) + + let toKebab (pascalString:string) = + let sb = StringBuilder(pascalString.Length * 2) + let chars = pascalString.ToCharArray() + for i = 0 to chars.Length - 1 do + let chr = chars.[i] + if Char.IsUpper(chr) && i > 0 then + sb.Append('-').Append(Char.ToLower(chr)) |> ignore + else + sb.Append(Char.ToLower(chr)) |> ignore + sb.ToString() diff --git a/site/static/code_snippets/getting_started/multiple_events.csharpsnippet b/site/static/code_snippets/getting_started/multiple_events.csharpsnippet new file mode 100644 index 000000000..44b2af4e9 --- /dev/null +++ b/site/static/code_snippets/getting_started/multiple_events.csharpsnippet @@ -0,0 +1,4 @@ +sse.PatchElementsAsync(@"
...
"); +sse.PatchElementsAsync(@"
...
"); +sse.PatchSignalsAsync("{answer: '...'}"); +sse.PatchSignalsAsync("{prize: '...'}"); diff --git a/site/static/code_snippets/getting_started/setup.csharpsnippet b/site/static/code_snippets/getting_started/setup.csharpsnippet new file mode 100644 index 000000000..4c8fba1dc --- /dev/null +++ b/site/static/code_snippets/getting_started/setup.csharpsnippet @@ -0,0 +1,13 @@ +using StarFederation.Datastar.DependencyInjection; + +// Adds Datastar as a service +builder.Services.AddDatastar(); + +app.MapGet("/", async (IDatastarService datastarService) => +{ + // Patch HTML DOM with new element. + await datastarService.PatchElementsAsync(@"
What do you put in a toaster?
"); + + // Patch new signals into the existing signals. + await datastarService.PatchSignalsAsync("{response: '', answer: 'bread'}"); +}); diff --git a/site/static/code_snippets/going_deeper/multiple_events.csharpsnippet b/site/static/code_snippets/going_deeper/multiple_events.csharpsnippet new file mode 100644 index 000000000..b307b4cf2 --- /dev/null +++ b/site/static/code_snippets/going_deeper/multiple_events.csharpsnippet @@ -0,0 +1,11 @@ +using StarFederation.Datastar.DependencyInjection; + +// Adds Datastar as a service +builder.Services.AddDatastar(); + +app.MapGet("/", async (IDatastarService datastarService) => +{ + await datastarService.PatchElementsAsync(@"
What do you put in a toaster?
"); + await datastarService.PatchSignalsAsync("{foo: {bar: 1}}"); + await datastarService.ExecuteScriptAsync(@"console.log(""Success!"")"); +}); diff --git a/site/static/code_snippets/how_tos/load_more.csharpsnippet b/site/static/code_snippets/how_tos/load_more.csharpsnippet new file mode 100644 index 000000000..bef73fca5 --- /dev/null +++ b/site/static/code_snippets/how_tos/load_more.csharpsnippet @@ -0,0 +1,38 @@ +using System.Text.Json; +using StarFederation.Datastar; +using StarFederation.Datastar.DependencyInjection; + +public class Program +{ + public record OffsetSignals(int offset); + + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddDatastar(); + var app = builder.Build(); + + app.MapGet("/more", async (IDatastarService datastarService) => + { + var max = 5; + var limit = 1; + var signals = await datastarService.ReadSignalsAsync(); + var offset = signals.offset; + if (offset < max) + { + var newOffset = offset + limit; + await datastarService.PatchElementsAsync($"
Item {newOffset}
", new() + { + Selector = "#list", + PatchMode = PatchElementsMode.Append, + }); + if (newOffset < max) + await datastarService.PatchSignalsAsync(new OffsetSignals(newOffset)); + else + await datastarService.RemoveElementAsync("#load-more"); + } + }); + + app.Run(); + } +} \ No newline at end of file diff --git a/site/static/code_snippets/how_tos/polling_1.csharpsnippet b/site/static/code_snippets/how_tos/polling_1.csharpsnippet new file mode 100644 index 000000000..f33e21bc4 --- /dev/null +++ b/site/static/code_snippets/how_tos/polling_1.csharpsnippet @@ -0,0 +1,12 @@ +using StarFederation.Datastar.DependencyInjection; + +app.MapGet("/endpoint", async (IDatastarService datastarService) => +{ + var currentTime = DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss"); + var element = $""" +
+ {currentTime} +
+ """; + await datastarService.PatchElementsAsync(element); +}); diff --git a/site/static/code_snippets/how_tos/polling_2.csharpsnippet b/site/static/code_snippets/how_tos/polling_2.csharpsnippet new file mode 100644 index 000000000..e6291b33f --- /dev/null +++ b/site/static/code_snippets/how_tos/polling_2.csharpsnippet @@ -0,0 +1,14 @@ +using StarFederation.Datastar.DependencyInjection; + +app.MapGet("/endpoint", async (IDatastarService datastarService) => +{ + var currentTime = DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss"); + var currentSeconds = DateTime.Now.Second; + var duration = currentSeconds < 50 ? 5 : 1; + var element = $""" +
+ {currentTime} +
+ """; + await datastarService.PatchElementsAsync(element); +}); \ No newline at end of file diff --git a/site/static/code_snippets/how_tos/redirect_1.csharpsnippet b/site/static/code_snippets/how_tos/redirect_1.csharpsnippet new file mode 100644 index 000000000..feba9a17c --- /dev/null +++ b/site/static/code_snippets/how_tos/redirect_1.csharpsnippet @@ -0,0 +1,8 @@ +using StarFederation.Datastar.DependencyInjection; + +app.MapGet("/redirect", async (IDatastarService datastarService) => +{ + await datastarService.PatchElementsAsync("""
Redirecting in 3 seconds...
"""); + await Task.Delay(TimeSpan.FromSeconds(3)); + await datastarService.ExecuteScriptAsync("""window.location = "/guide";"""); +}); \ No newline at end of file diff --git a/site/static/code_snippets/how_tos/redirect_2.csharpsnippet b/site/static/code_snippets/how_tos/redirect_2.csharpsnippet new file mode 100644 index 000000000..11fb45c35 --- /dev/null +++ b/site/static/code_snippets/how_tos/redirect_2.csharpsnippet @@ -0,0 +1,8 @@ +using StarFederation.Datastar.DependencyInjection; + +app.MapGet("/redirect", async (IDatastarService datastarService) => +{ + await datastarService.PatchElementsAsync("""
Redirecting in 3 seconds...
"""); + await Task.Delay(TimeSpan.FromSeconds(3)); + await datastarService.ExecuteScriptAsync("""setTimeout(() => window.location = "/guide");"""); +}); \ No newline at end of file