From 951d8db9ce49bda50fd98ba854919eccfc8ec767 Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Fri, 20 Jun 2025 16:36:58 -0400 Subject: [PATCH 1/8] sdk/dotnet - merge to patch, fragment to element. IDatastarService introduced --- sdk/dotnet/README.md | 22 +++--- .../src/DependencyInjection/Services.cs | 56 +++++++-------- .../DependencyInjection/ServicesProvider.cs | 11 +-- .../csharp/src/DependencyInjection/Types.cs | 71 ++++++++----------- .../src/ModelBinding/FromSignalAttribute.cs | 5 +- .../src/ModelBinding/MvcServiceProvider.cs | 2 +- .../src/ModelBinding/SignalsModelBinder.cs | 2 +- .../csharp/src/StarFederation.Datastar.csproj | 2 +- sdk/dotnet/csharp/src/Utility.cs | 1 + sdk/dotnet/fsharp/src/HttpHandlers.fs | 31 ++++---- .../fsharp/src/ServerSentEventGenerator.fs | 48 ++++++------- .../src/StarFederation.Datastar.FSharp.fsproj | 2 +- sdk/dotnet/fsharp/src/Types.fs | 46 ++++++------ sdk/dotnet/fsharp/src/Utility.fs | 2 +- 14 files changed, 135 insertions(+), 166 deletions(-) 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..c8cfd84ac 100644 --- a/sdk/dotnet/csharp/src/DependencyInjection/Services.cs +++ b/sdk/dotnet/csharp/src/DependencyInjection/Services.cs @@ -1,25 +1,22 @@ -using System.Security.Cryptography; using System.Text.Json; -using Microsoft.FSharp.Collections; 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 StartServerEventStream(params (string, string)[] additionalHeaders); + Task StartServerEventStream(params KeyValuePair[] additionalHeaders); + Task PatchElementsAsync(string fragments, PatchElementsOptions? options = null); + Task RemoveElementAsync(string selector, RemoveFragmentOptions? 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); Task ExecuteScriptAsync(string script, ExecuteScriptOptions? options = null); -} -public interface IDatastarSignalsReaderService -{ /// /// Get the serialized signals as a stream /// @@ -38,38 +35,37 @@ public interface IDatastarSignalsReaderService Task ReadSignalsAsync(JsonSerializerOptions? options = null); } -internal class ServerSentEventService(Core.ISendServerEvent handler) : IDatastarServerSentEventService +internal class DatastarService(Core.ISendServerEvent sendServerEventHandler, Core.IReadSignals signalsHandler) : IDatastarService { - public void AddHeaders(params KeyValuePair[] httpHeaders) => _additionalHeaders.AddRange(httpHeaders ?? []); + public Task StartServerEventStream(params (string, string)[] additionalHeaders) + => sendServerEventHandler.StartServerEventStream(additionalHeaders.Select(kv => kv.AsTuple()).ToArray()); - public Task StartServerEventStream() => handler.StartServerEventStream(_additionalHeaders.Select(kv => kv.AsTuple()).ToArray()); + public Task StartServerEventStream(params KeyValuePair[] additionalHeaders) + => sendServerEventHandler.StartServerEventStream(additionalHeaders.Select(kv => kv.AsTuple()).ToArray()); - public Task MergeFragmentsAsync(string fragments, MergeFragmentsOptions? options = null) => handler.SendServerEvent(Core.ServerSentEventGenerator.MergeFragments(fragments, options ?? new())); + public Task PatchElementsAsync(string fragments, PatchElementsOptions? options = null) + => sendServerEventHandler.SendServerEvent(Core.ServerSentEventGenerator.PatchElements(fragments, options ?? new())); - public Task RemoveFragmentsAsync(string selector, RemoveFragmentsOptions? options = null) => handler.SendServerEvent(Core.ServerSentEventGenerator.RemoveFragments(selector, options ?? new())); + public Task RemoveElementAsync(string selector, RemoveFragmentOptions? options = null) + => sendServerEventHandler.SendServerEvent(Core.ServerSentEventGenerator.RemoveElement(selector, options ?? new())); - public Task MergeSignalsAsync(string dataSignals, MergeSignalsOptions? options = null) => handler.SendServerEvent(Core.ServerSentEventGenerator.MergeSignals(dataSignals, options ?? new())); + public Task PatchSignalsAsync(TType signals, JsonSerializerOptions? jsonSerializerOptions = null, PatchSignalsOptions? patchSignalsOptions = null) + => sendServerEventHandler.SendServerEvent(Core.ServerSentEventGenerator.PatchSignals(signals as string ?? JsonSerializer.Serialize(signals, jsonSerializerOptions), patchSignalsOptions ?? new())); - public Task RemoveSignalsAsync(IEnumerable paths, EventOptions? options = null) => handler.SendServerEvent(Core.ServerSentEventGenerator.RemoveSignals(paths, options ?? new())); + public Task ExecuteScriptAsync(string script, ExecuteScriptOptions? options = null) + => sendServerEventHandler.SendServerEvent(Core.ServerSentEventGenerator.ExecuteScript(script, options ?? new())); - public Task ExecuteScriptAsync(string script, ExecuteScriptOptions? options = null) => handler.SendServerEvent(Core.ServerSentEventGenerator.ExecuteScript(script, options ?? new())); - - private List> _additionalHeaders = new(); -} - -internal class SignalsReaderService(Core.IReadSignals handler) : IDatastarSignalsReaderService -{ - public Stream GetSignalsStream() => handler.GetSignalsStream(); + public Stream GetSignalsStream() => signalsHandler.GetSignalsStream(); public async Task ReadSignalsAsync() { - string? signals = await handler.ReadSignalsAsync(); + string? signals = await signalsHandler.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 signalsHandler.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..384b86d03 100644 --- a/sdk/dotnet/csharp/src/DependencyInjection/ServicesProvider.cs +++ b/sdk/dotnet/csharp/src/DependencyInjection/ServicesProvider.cs @@ -10,17 +10,12 @@ public static IServiceCollection AddDatastar(this IServiceCollection serviceColl { serviceCollection .AddHttpContextAccessor() - .AddScoped(svcPvd => - { - IHttpContextAccessor? httpContextAccessor = svcPvd.GetService(); - Core.IReadSignals signalsHttpHandler = new Core.SignalsHttpHandler(httpContextAccessor!.HttpContext!.Request); - return new SignalsReaderService(signalsHttpHandler); - }) - .AddScoped(svcPvd => + .AddScoped(svcPvd => { IHttpContextAccessor? httpContextAccessor = svcPvd.GetService(); Core.ISendServerEvent sseHttpHandler = new Core.ServerSentEventHttpHandler(httpContextAccessor!.HttpContext!.Response); - return new ServerSentEventService(sseHttpHandler); + Core.IReadSignals signalsHttpHandler = new Core.SignalsHttpHandler(httpContextAccessor!.HttpContext!.Request); + return new DatastarService(sseHttpHandler, signalsHttpHandler); }); return serviceCollection; } diff --git a/sdk/dotnet/csharp/src/DependencyInjection/Types.cs b/sdk/dotnet/csharp/src/DependencyInjection/Types.cs index 5295fe29e..9156a6cf5 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 RemoveFragmentOptions { - 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(RemoveFragmentOptions options) => ToFSharp(options); + public static implicit operator FSharpValueOption(RemoveFragmentOptions options) => ToFSharp(options); - private static Core.RemoveFragmentsOptions ToFSharp(RemoveFragmentsOptions options) => new( + private static Core.RemoveElementOptions ToFSharp(RemoveFragmentOptions 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/HttpHandlers.fs b/sdk/dotnet/fsharp/src/HttpHandlers.fs index 5467fa14d..be534d2fb 100644 --- a/sdk/dotnet/fsharp/src/HttpHandlers.fs +++ b/sdk/dotnet/fsharp/src/HttpHandlers.fs @@ -9,6 +9,13 @@ open Microsoft.AspNetCore.Http open Microsoft.Extensions.Primitives open Microsoft.Net.Http.Headers +module JsonSerializerOptions = + /// JsonSerializerOptions but case insensitive + let SignalsDefault = + let options = JsonSerializerOptions() + options.PropertyNameCaseInsensitive <- true + options + /// Implementation of ISendServerEvent, for sending SSEs to the HttpResponse [] type ServerSentEventHttpHandler (httpResponse:HttpResponse) = @@ -28,32 +35,32 @@ type ServerSentEventHttpHandler (httpResponse:HttpResponse) = yield! additionalHeaders } |> Seq.iter (setHeader httpResponse) do! httpResponse.StartAsync(cancellationToken) - do! httpResponse.Body.FlushAsync(cancellationToken) + return! httpResponse.BodyWriter.FlushAsync(cancellationToken) } task :> Task - static member StartServerEventStream (httpResponse, additionalHeader) = ServerSentEventHttpHandler.StartServerEventStream(httpResponse, additionalHeader, CancellationToken.None) + static member StartServerEventStream (httpResponse, additionalHeader) = ServerSentEventHttpHandler.StartServerEventStream(httpResponse, additionalHeader, httpResponse.HttpContext.RequestAborted) 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) + let serializedSse = sse |> ServerSentEvent.serializeAsBytes |> Seq.toArray + return! httpResponse.BodyWriter.WriteAsync(serializedSse, cancellationToken) } task :> Task - static member SendServerEvent (sse, httpResponse) = ServerSentEventHttpHandler.SendServerEvent(httpResponse, sse, CancellationToken.None) + static member SendServerEvent (sse, httpResponse) = ServerSentEventHttpHandler.SendServerEvent(httpResponse, sse, httpResponse.HttpContext.RequestAborted) 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.StartServerEventStream(additionalHeaders) = (this:>ISendServerEvent).StartServerEventStream(additionalHeaders, httpResponse.HttpContext.RequestAborted) + member this.StartServerEventStream() = (this:>ISendServerEvent).StartServerEventStream(Array.empty, httpResponse.HttpContext.RequestAborted) 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) + member this.SendServerEvent(sse) = (this:>ISendServerEvent).SendServerEvent(sse, httpResponse.HttpContext.RequestAborted) /// Implementation of IReadSignals, for reading the Signals from the HttpRequest [] @@ -81,7 +88,7 @@ type SignalsHttpHandler (httpRequest:HttpRequest) = with _ -> return Signals.empty } - static member ReadSignalsAsync (httpRequest:HttpRequest) = SignalsHttpHandler.ReadSignalsAsync(httpRequest, CancellationToken.None) + static member ReadSignalsAsync (httpRequest:HttpRequest) = SignalsHttpHandler.ReadSignalsAsync(httpRequest, httpRequest.HttpContext.RequestAborted) static member ReadSignalsAsync<'T> (httpRequest:HttpRequest, jsonSerializerOptions:JsonSerializerOptions, cancellationToken:CancellationToken) = task { try @@ -98,9 +105,9 @@ type SignalsHttpHandler (httpRequest:HttpRequest) = 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) + static member ReadSignalsAsync<'T> (httpRequest:HttpRequest, cancellationToken:CancellationToken) = SignalsHttpHandler.ReadSignalsAsync<'T>(httpRequest, JsonSerializerOptions.SignalsDefault, cancellationToken) + static member ReadSignalsAsync<'T> (httpRequest:HttpRequest, jsonSerializerOptions:JsonSerializerOptions) = SignalsHttpHandler.ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions, httpRequest.HttpContext.RequestAborted) + static member ReadSignalsAsync<'T> (httpRequest:HttpRequest) = SignalsHttpHandler.ReadSignalsAsync<'T>(httpRequest, JsonSerializerOptions.SignalsDefault) interface IReadSignals with member this.GetSignalsStream() = SignalsHttpHandler.GetSignalsStream(httpRequest) diff --git a/sdk/dotnet/fsharp/src/ServerSentEventGenerator.fs b/sdk/dotnet/fsharp/src/ServerSentEventGenerator.fs index 14ea8e200..2b2ac8f75 100644 --- a/sdk/dotnet/fsharp/src/ServerSentEventGenerator.fs +++ b/sdk/dotnet/fsharp/src/ServerSentEventGenerator.fs @@ -1,57 +1,49 @@ namespace StarFederation.Datastar.FSharp +open System open StarFederation.Datastar.FSharp.Utility [] type ServerSentEventGenerator = - static member MergeFragments(fragments, options:MergeFragmentsOptions) = - { EventType = MergeFragments + static member PatchElements(elements, options:PatchElementsOptions) = + { EventType = PatchElements 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}")) + if (options.PatchMode <> Consts.DefaultElementPatchMode) then $"{Consts.DatastarDatalineMode} {options.PatchMode |> Consts.ElementPatchMode.toString}" + if (options.UseViewTransition <> Consts.DefaultElementsUseViewTransitions) then $"{Consts.DatastarDatalineUseViewTransition} %A{options.UseViewTransition}" + yield! (elements |> String.split String.newLines |> Seq.map (fun elementLine -> $"{Consts.DatastarDatalineElements} %s{elementLine}")) |] } - static member MergeFragments fragments = ServerSentEventGenerator.MergeFragments (fragments, MergeFragmentsOptions.defaults) + static member PatchElements elements = ServerSentEventGenerator.PatchElements (elements, PatchElementsOptions.defaults) - static member RemoveFragments(selector, options:RemoveFragmentsOptions) = - { EventType = RemoveFragments + static member RemoveElement(selector, options:RemoveElementOptions) = + { EventType = PatchElements Id = options.EventId Retry = options.Retry DataLines = [| $"{Consts.DatastarDatalineSelector} {selector |> Selector.value}" - if (options.UseViewTransition <> Consts.DefaultFragmentsUseViewTransitions) then $"{Consts.DatastarDatalineUseViewTransition} %A{options.UseViewTransition}" + $"{Consts.DatastarDatalineMode} {ElementPatchMode.Remove |> Consts.ElementPatchMode.toString}" + if (options.UseViewTransition <> Consts.DefaultElementsUseViewTransitions) then $"{Consts.DatastarDatalineUseViewTransition} %A{options.UseViewTransition}" |] } - static member RemoveFragments selector = ServerSentEventGenerator.RemoveFragments(selector, RemoveFragmentsOptions.defaults) + static member RemoveElement selector = ServerSentEventGenerator.RemoveElement(selector, RemoveElementOptions.defaults) - static member MergeSignals(signals, options:MergeSignalsOptions) = - { EventType = MergeSignals + static member PatchSignals(signals, options:PatchSignalsOptions) = + { EventType = PatchSignals Id = options.EventId Retry = options.Retry DataLines = [| - if (options.OnlyIfMissing <> Consts.DefaultMergeSignalsOnlyIfMissing) then $"{Consts.DatastarDatalineOnlyIfMissing} %A{options.OnlyIfMissing}" + if (options.OnlyIfMissing <> Consts.DefaultPatchSignalsOnlyIfMissing) 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 PatchSignals signals = ServerSentEventGenerator.PatchSignals(signals, PatchSignalsOptions.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 + static member ExecuteScript(script: string, options:ExecuteScriptOptions) = + let script = if script.StartsWith("" + { EventType = PatchElements 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}") + yield! (script |> String.split String.newLines |> Seq.map (fun scriptLine -> $"{Consts.DatastarDatalineElements} %s{scriptLine}")) |] } static member ExecuteScript script = ServerSentEventGenerator.ExecuteScript(script, ExecuteScriptOptions.defaults) diff --git a/sdk/dotnet/fsharp/src/StarFederation.Datastar.FSharp.fsproj b/sdk/dotnet/fsharp/src/StarFederation.Datastar.FSharp.fsproj index 7dea5792b..785bff527 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 diff --git a/sdk/dotnet/fsharp/src/Types.fs b/sdk/dotnet/fsharp/src/Types.fs index f8b5ea84b..9011bf225 100644 --- a/sdk/dotnet/fsharp/src/Types.fs +++ b/sdk/dotnet/fsharp/src/Types.fs @@ -3,6 +3,7 @@ namespace StarFederation.Datastar.FSharp open System open System.Collections.Generic open System.IO +open System.Text open System.Text.Json open System.Text.Json.Nodes open System.Text.RegularExpressions @@ -31,26 +32,21 @@ 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 = +type PatchSignalsOptions = { OnlyIfMissing: bool EventId: string voption Retry: TimeSpan } -type RemoveFragmentsOptions = +type RemoveElementOptions = { UseViewTransition: bool EventId: string voption Retry: TimeSpan } -type ExecuteScriptOptions = - { AutoRemove: bool - Attributes: string[] - EventId: string voption - Retry: TimeSpan } -type EventOptions = { EventId: string voption; Retry: TimeSpan } +type ExecuteScriptOptions = { EventId: string voption; Retry: TimeSpan } /// /// Read the signals from the request @@ -76,7 +72,7 @@ type ISendServerEvent = abstract SendServerEvent : ServerSentEvent * CancellationToken -> Task module ServerSentEvent = - let serialize sse = + let private lines sse = seq { $"event: {sse.EventType |> Consts.EventType.toString}" @@ -89,7 +85,11 @@ module ServerSentEvent = yield! sse.DataLines |> Array.map (fun dataLine -> $"data: {dataLine}") ""; ""; "" - } |> String.concat "\n" + } + let serializeAsBytes sse = + lines sse + |> Seq.map (fun line -> Seq.append (Encoding.UTF8.GetBytes line) "\n"B) + |> Seq.concat module Signals = let value (signals:Signals) : string = signals.ToString() @@ -139,33 +139,27 @@ module Selector = else failwith $"{selectorString} is not a valid selector" let create = sel -module MergeFragmentsOptions = +module PatchElementsOptions = let defaults = { Selector = ValueNone - MergeMode = Consts.DefaultFragmentMergeMode - UseViewTransition = Consts.DefaultFragmentsUseViewTransitions + PatchMode = Consts.DefaultElementPatchMode + UseViewTransition = Consts.DefaultElementsUseViewTransitions EventId = ValueNone Retry = Consts.DefaultSseRetryDuration } -module MergeSignalsOptions = +module RemoveElementOptions = let defaults = - { OnlyIfMissing = Consts.DefaultMergeSignalsOnlyIfMissing + { UseViewTransition = Consts.DefaultElementsUseViewTransitions EventId = ValueNone Retry = Consts.DefaultSseRetryDuration } -module RemoveFragmentsOptions = +module PatchSignalsOptions = let defaults = - { UseViewTransition = Consts.DefaultFragmentsUseViewTransitions + { OnlyIfMissing = Consts.DefaultPatchSignalsOnlyIfMissing EventId = ValueNone Retry = Consts.DefaultSseRetryDuration } module ExecuteScriptOptions = let defaults = - { AutoRemove = Consts.DefaultExecuteScriptAutoRemove - Attributes = [| Consts.DefaultExecuteScriptAttributes |] - EventId = ValueNone + { 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..2b7830d17 100644 --- a/sdk/dotnet/fsharp/src/Utility.fs +++ b/sdk/dotnet/fsharp/src/Utility.fs @@ -14,4 +14,4 @@ module internal String = then stringBuilder.Append("-").Append(Char.ToLower(chr)) else stringBuilder.Append(chr) ) - |> _.Replace("-", "", 0, 1).ToString() \ No newline at end of file + |> _.Replace("-", "", 0, 1).ToString() From 9fb474e1ef0eb746f6adc56f5a5df287a55a37d9 Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Fri, 20 Jun 2025 16:39:09 -0400 Subject: [PATCH 2/8] sdk/dotnet - snippets updated # Conflicts: # site/static/code_snippets/getting_started/multiple_events.csharpsnippet # site/static/code_snippets/getting_started/setup.csharpsnippet # site/static/code_snippets/going_deeper/multiple_events.csharpsnippet # site/static/code_snippets/how_tos/load_more.csharpsnippet # site/static/code_snippets/how_tos/polling_1.csharpsnippet # site/static/code_snippets/how_tos/polling_2.csharpsnippet # site/static/code_snippets/how_tos/redirect_1.csharpsnippet # site/static/code_snippets/how_tos/redirect_2.csharpsnippet --- examples/dotnet/csharp/HelloWorld/Program.cs | 10 ++--- .../multiple_events.csharpsnippet | 4 ++ .../getting_started/setup.csharpsnippet | 13 +++++++ .../multiple_events.csharpsnippet | 11 ++++++ .../how_tos/load_more.csharpsnippet | 38 +++++++++++++++++++ .../how_tos/polling_1.csharpsnippet | 12 ++++++ .../how_tos/polling_2.csharpsnippet | 14 +++++++ .../how_tos/redirect_1.csharpsnippet | 8 ++++ .../how_tos/redirect_2.csharpsnippet | 8 ++++ 9 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 site/static/code_snippets/getting_started/multiple_events.csharpsnippet create mode 100644 site/static/code_snippets/getting_started/setup.csharpsnippet create mode 100644 site/static/code_snippets/going_deeper/multiple_events.csharpsnippet create mode 100644 site/static/code_snippets/how_tos/load_more.csharpsnippet create mode 100644 site/static/code_snippets/how_tos/polling_1.csharpsnippet create mode 100644 site/static/code_snippets/how_tos/polling_2.csharpsnippet create mode 100644 site/static/code_snippets/how_tos/redirect_1.csharpsnippet create mode 100644 site/static/code_snippets/how_tos/redirect_2.csharpsnippet diff --git a/examples/dotnet/csharp/HelloWorld/Program.cs b/examples/dotnet/csharp/HelloWorld/Program.cs index d3a27b417..1ff55240b 100644 --- a/examples/dotnet/csharp/HelloWorld/Program.cs +++ b/examples/dotnet/csharp/HelloWorld/Program.cs @@ -22,18 +22,18 @@ 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 sse) => { - Signals mySignals = await signals.ReadSignalsAsync(); + Signals? mySignals = await sse.ReadSignalsAsync(); for (int index = 0; index < Message.Length; ++index) { - await sse.MergeFragmentsAsync($"""
{Message[..index]}
"""); + await sse.PatchElementsAsync($"""
{Message[..index]}
"""); if (!char.IsWhiteSpace(Message[index])) { - await Task.Delay(TimeSpan.FromMilliseconds(mySignals.Delay.GetValueOrDefault(0))); + await Task.Delay(TimeSpan.FromMilliseconds(mySignals!.Delay.GetValueOrDefault(0))); } } - await sse.MergeFragmentsAsync($"""
{Message}
"""); + await sse.PatchElementsAsync($"""
{Message}
"""); }); app.Run(); 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 From 64e9b389d69e2ab90c7ed9a363afafe9f2169117 Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Fri, 20 Jun 2025 16:40:33 -0400 Subject: [PATCH 3/8] sdk/dotnet - Examples updated --- examples/dotnet/csharp/HelloWorld/Program.cs | 13 ++++++++----- examples/dotnet/fsharp/HelloWorld/Program.fs | 12 +----------- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/examples/dotnet/csharp/HelloWorld/Program.cs b/examples/dotnet/csharp/HelloWorld/Program.cs index 1ff55240b..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 (IDatastarService sse) => + app.MapGet("/hello-world", async (IDatastarService datastarService) => { - Signals? mySignals = await sse.ReadSignalsAsync(); + Signals? mySignals = await datastarService.ReadSignalsAsync(); + Debug.Assert(mySignals != null, nameof(mySignals) + " != null"); + for (int index = 0; index < Message.Length; ++index) { - await sse.PatchElementsAsync($"""
{Message[..index]}
"""); + await datastarService.PatchElementsAsync($"""
{Message[..index]}
"""); if (!char.IsWhiteSpace(Message[index])) { - await Task.Delay(TimeSpan.FromMilliseconds(mySignals!.Delay.GetValueOrDefault(0))); + await Task.Delay(TimeSpan.FromMilliseconds(mySignals.Delay.GetValueOrDefault(0))); } } - await sse.PatchElementsAsync($"""
{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..a9c72f576 100644 --- a/examples/dotnet/fsharp/HelloWorld/Program.fs +++ b/examples/dotnet/fsharp/HelloWorld/Program.fs @@ -1,21 +1,11 @@ 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 = @@ -43,7 +33,7 @@ module Program = [0 .. (Message.Length - 1)] |> Seq.map (fun length -> Message[0..length]) |> Seq.map (fun message -> $"""
{message}
""") - |> Seq.map ServerSentEventGenerator.MergeFragments + |> Seq.map ServerSentEventGenerator.PatchElements |> Seq.map (fun sse -> async { do! sse |> sseHandler.SendServerEvent |> Async.AwaitTask do! Async.Sleep(TimeSpan.FromMilliseconds(delayMs)) }) From e5aaeed927b41cd83249b1e9636292047a3ed71a Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Fri, 20 Jun 2025 16:41:07 -0400 Subject: [PATCH 4/8] sdk/dotnet-nugets - renamed the workflows for creating nugets --- .github/workflows/sdk-csharp-nuget.yml | 2 +- .github/workflows/sdk-fsharp-nuget.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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: From 57cdfda1555d85da707a627055620f6f0edc781d Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Mon, 23 Jun 2025 17:34:33 -0400 Subject: [PATCH 5/8] sdk/dotnet - avoid repeated locking. avoid out of order sses --- sdk/dotnet/fsharp/src/HttpHandlers.fs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/sdk/dotnet/fsharp/src/HttpHandlers.fs b/sdk/dotnet/fsharp/src/HttpHandlers.fs index be534d2fb..70105a87e 100644 --- a/sdk/dotnet/fsharp/src/HttpHandlers.fs +++ b/sdk/dotnet/fsharp/src/HttpHandlers.fs @@ -4,6 +4,7 @@ open System.IO open System.Text open System.Text.Json open System.Threading +open System.Threading.Channels open System.Threading.Tasks open Microsoft.AspNetCore.Http open Microsoft.Extensions.Primitives @@ -21,6 +22,7 @@ module JsonSerializerOptions = type ServerSentEventHttpHandler (httpResponse:HttpResponse) = let mutable _startResponseTask : Task = null let _startResponseLock = obj() + let _sendEventChannel = Channel.CreateUnbounded() static member StartServerEventStream (httpResponse:HttpResponse, additionalHeaders:(string * string)[], cancellationToken:CancellationToken) = let task = backgroundTask { @@ -49,7 +51,6 @@ type ServerSentEventHttpHandler (httpResponse:HttpResponse) = static member SendServerEvent (sse, httpResponse) = ServerSentEventHttpHandler.SendServerEvent(httpResponse, sse, httpResponse.HttpContext.RequestAborted) interface ISendServerEvent with - member this.StartServerEventStream (additionalHeaders, cancellationToken) = lock _startResponseLock (fun () -> if _startResponseTask = null then _startResponseTask <- ServerSentEventHttpHandler.StartServerEventStream(httpResponse, additionalHeaders, cancellationToken)) _startResponseTask @@ -57,10 +58,15 @@ type ServerSentEventHttpHandler (httpResponse:HttpResponse) = member this.StartServerEventStream() = (this:>ISendServerEvent).StartServerEventStream(Array.empty, httpResponse.HttpContext.RequestAborted) member this.SendServerEvent(sse, cancellationToken) = task { - do! (this :> ISendServerEvent).StartServerEventStream(Array.empty, cancellationToken) + do! _sendEventChannel.Writer.WriteAsync(sse, cancellationToken) + do! + if _startResponseTask <> null + then _startResponseTask + else (this :> ISendServerEvent).StartServerEventStream(Array.empty, cancellationToken) + let! sse = _sendEventChannel.Reader.ReadAsync(cancellationToken) return! ServerSentEventHttpHandler.SendServerEvent(httpResponse, sse, cancellationToken) } - member this.SendServerEvent(sse) = (this:>ISendServerEvent).SendServerEvent(sse, httpResponse.HttpContext.RequestAborted) + member this.SendServerEvent(sse) = (this :> ISendServerEvent).SendServerEvent(sse, httpResponse.HttpContext.RequestAborted) /// Implementation of IReadSignals, for reading the Signals from the HttpRequest [] From 4fc654ad0b2db37fd336e4b6fe08676859074a96 Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Sat, 5 Jul 2025 07:04:54 -0400 Subject: [PATCH 6/8] sdk/dotnet - zero allocation updates --- build/consts_fsharp.qtpl | 2 +- examples/dotnet/fsharp/HelloWorld/Program.fs | 16 +- .../src/DependencyInjection/Services.cs | 45 +-- .../DependencyInjection/ServicesProvider.cs | 7 +- sdk/dotnet/fsharp/src/HttpHandlers.fs | 124 ------- sdk/dotnet/fsharp/src/ServerSentEvent.fs | 56 ++++ .../fsharp/src/ServerSentEventGenerator.fs | 309 +++++++++++++++--- .../src/StarFederation.Datastar.FSharp.fsproj | 4 +- sdk/dotnet/fsharp/src/Types.fs | 129 +++----- sdk/dotnet/fsharp/src/Utility.fs | 63 +++- 10 files changed, 449 insertions(+), 306 deletions(-) delete mode 100644 sdk/dotnet/fsharp/src/HttpHandlers.fs create mode 100644 sdk/dotnet/fsharp/src/ServerSentEvent.fs 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/fsharp/HelloWorld/Program.fs b/examples/dotnet/fsharp/HelloWorld/Program.fs index a9c72f576..9c95c1f9e 100644 --- a/examples/dotnet/fsharp/HelloWorld/Program.fs +++ b/examples/dotnet/fsharp/HelloWorld/Program.fs @@ -10,7 +10,7 @@ open StarFederation.Datastar.FSharp module Program = [] - type MySignals = { delay:float } + type MySignals = { delay:int } let [] Message = "Hello, world!" @@ -23,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.PatchElements - |> 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/csharp/src/DependencyInjection/Services.cs b/sdk/dotnet/csharp/src/DependencyInjection/Services.cs index c8cfd84ac..abb2ce345 100644 --- a/sdk/dotnet/csharp/src/DependencyInjection/Services.cs +++ b/sdk/dotnet/csharp/src/DependencyInjection/Services.cs @@ -1,5 +1,5 @@ - using System.Text.Json; +using Microsoft.Extensions.Primitives; using Microsoft.FSharp.Core; using Core = StarFederation.Datastar.FSharp; @@ -7,14 +7,21 @@ namespace StarFederation.Datastar.DependencyInjection; public interface IDatastarService { - Task StartServerEventStream(params (string, string)[] additionalHeaders); - Task StartServerEventStream(params KeyValuePair[] additionalHeaders); + Task StartServerEventStreamAsync(IEnumerable> additionalHeaders); + Task StartServerEventStreamAsync(IEnumerable> additionalHeaders); + Task PatchElementsAsync(string fragments, PatchElementsOptions? options = null); + Task RemoveElementAsync(string selector, RemoveFragmentOptions? 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); /// @@ -35,37 +42,37 @@ public interface IDatastarService Task ReadSignalsAsync(JsonSerializerOptions? options = null); } -internal class DatastarService(Core.ISendServerEvent sendServerEventHandler, Core.IReadSignals signalsHandler) : IDatastarService +internal class DatastarService(Core.ServerSentEventGenerator serverSentEventGenerator) : IDatastarService { - public Task StartServerEventStream(params (string, string)[] additionalHeaders) - => sendServerEventHandler.StartServerEventStream(additionalHeaders.Select(kv => kv.AsTuple()).ToArray()); + public Task StartServerEventStreamAsync(IEnumerable> additionalHeaders) => + serverSentEventGenerator.StartServerEventStreamAsync(additionalHeaders ?? []); - public Task StartServerEventStream(params KeyValuePair[] additionalHeaders) - => sendServerEventHandler.StartServerEventStream(additionalHeaders.Select(kv => kv.AsTuple()).ToArray()); + public Task StartServerEventStreamAsync(IEnumerable> additionalHeaders) => + serverSentEventGenerator.StartServerEventStreamAsync(additionalHeaders?.Select(kvp => new KeyValuePair(kvp.Key, new StringValues(kvp.Value))) ?? []); - public Task PatchElementsAsync(string fragments, PatchElementsOptions? options = null) - => sendServerEventHandler.SendServerEvent(Core.ServerSentEventGenerator.PatchElements(fragments, options ?? new())); + public Task PatchElementsAsync(string fragments, PatchElementsOptions? options = null) => + serverSentEventGenerator.PatchElementsAsync(fragments, options ?? new()); - public Task RemoveElementAsync(string selector, RemoveFragmentOptions? options = null) - => sendServerEventHandler.SendServerEvent(Core.ServerSentEventGenerator.RemoveElement(selector, options ?? new())); + public Task RemoveElementAsync(string selector, RemoveFragmentOptions? options = null) => + serverSentEventGenerator.RemoveElementAsync(selector, options ?? new()); - public Task PatchSignalsAsync(TType signals, JsonSerializerOptions? jsonSerializerOptions = null, PatchSignalsOptions? patchSignalsOptions = null) - => sendServerEventHandler.SendServerEvent(Core.ServerSentEventGenerator.PatchSignals(signals as string ?? JsonSerializer.Serialize(signals, jsonSerializerOptions), patchSignalsOptions ?? new())); + public Task PatchSignalsAsync(TType signals, JsonSerializerOptions? jsonSerializerOptions = null, PatchSignalsOptions? patchSignalsOptions = null) => + serverSentEventGenerator.PatchSignalsAsync(signals as string ?? JsonSerializer.Serialize(signals, jsonSerializerOptions), patchSignalsOptions ?? new()); - public Task ExecuteScriptAsync(string script, ExecuteScriptOptions? options = null) - => sendServerEventHandler.SendServerEvent(Core.ServerSentEventGenerator.ExecuteScript(script, options ?? new())); + public Task ExecuteScriptAsync(string script, ExecuteScriptOptions? options = null) => + serverSentEventGenerator.ExecuteScriptAsync(script, options ?? new()); - public Stream GetSignalsStream() => signalsHandler.GetSignalsStream(); + public Stream GetSignalsStream() => serverSentEventGenerator.GetSignalsStream(); public async Task ReadSignalsAsync() { - string? signals = await signalsHandler.ReadSignalsAsync(); + string? signals = await serverSentEventGenerator.ReadSignalsAsync(); return String.IsNullOrEmpty(signals) ? null : signals; } public async Task ReadSignalsAsync(JsonSerializerOptions? jsonSerializerOptions = null) { - FSharpValueOption read = await signalsHandler.ReadSignalsAsync(jsonSerializerOptions ?? Core.JsonSerializerOptions.SignalsDefault); + 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 384b86d03..626c72db0 100644 --- a/sdk/dotnet/csharp/src/DependencyInjection/ServicesProvider.cs +++ b/sdk/dotnet/csharp/src/DependencyInjection/ServicesProvider.cs @@ -13,10 +13,9 @@ public static IServiceCollection AddDatastar(this IServiceCollection serviceColl .AddScoped(svcPvd => { IHttpContextAccessor? httpContextAccessor = svcPvd.GetService(); - Core.ISendServerEvent sseHttpHandler = new Core.ServerSentEventHttpHandler(httpContextAccessor!.HttpContext!.Response); - Core.IReadSignals signalsHttpHandler = new Core.SignalsHttpHandler(httpContextAccessor!.HttpContext!.Request); - return new DatastarService(sseHttpHandler, signalsHttpHandler); + Core.ServerSentEventGenerator serverSentEventGenerator = new(httpContextAccessor); + return new DatastarService(serverSentEventGenerator); }); return serviceCollection; } -} \ 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 70105a87e..000000000 --- a/sdk/dotnet/fsharp/src/HttpHandlers.fs +++ /dev/null @@ -1,124 +0,0 @@ -namespace StarFederation.Datastar.FSharp - -open System.IO -open System.Text -open System.Text.Json -open System.Threading -open System.Threading.Channels -open System.Threading.Tasks -open Microsoft.AspNetCore.Http -open Microsoft.Extensions.Primitives -open Microsoft.Net.Http.Headers - -module JsonSerializerOptions = - /// JsonSerializerOptions but case insensitive - let SignalsDefault = - let options = JsonSerializerOptions() - options.PropertyNameCaseInsensitive <- true - options - -/// Implementation of ISendServerEvent, for sending SSEs to the HttpResponse -[] -type ServerSentEventHttpHandler (httpResponse:HttpResponse) = - let mutable _startResponseTask : Task = null - let _startResponseLock = obj() - let _sendEventChannel = Channel.CreateUnbounded() - - 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) - return! httpResponse.BodyWriter.FlushAsync(cancellationToken) - } - task :> Task - static member StartServerEventStream (httpResponse, additionalHeader) = ServerSentEventHttpHandler.StartServerEventStream(httpResponse, additionalHeader, httpResponse.HttpContext.RequestAborted) - - static member SendServerEvent (httpResponse:HttpResponse, sse, cancellationToken:CancellationToken) = - let task = task { - let serializedSse = sse |> ServerSentEvent.serializeAsBytes |> Seq.toArray - return! httpResponse.BodyWriter.WriteAsync(serializedSse, cancellationToken) - } - task :> Task - static member SendServerEvent (sse, httpResponse) = ServerSentEventHttpHandler.SendServerEvent(httpResponse, sse, httpResponse.HttpContext.RequestAborted) - - 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, httpResponse.HttpContext.RequestAborted) - member this.StartServerEventStream() = (this:>ISendServerEvent).StartServerEventStream(Array.empty, httpResponse.HttpContext.RequestAborted) - - member this.SendServerEvent(sse, cancellationToken) = task { - do! _sendEventChannel.Writer.WriteAsync(sse, cancellationToken) - do! - if _startResponseTask <> null - then _startResponseTask - else (this :> ISendServerEvent).StartServerEventStream(Array.empty, cancellationToken) - let! sse = _sendEventChannel.Reader.ReadAsync(cancellationToken) - return! ServerSentEventHttpHandler.SendServerEvent(httpResponse, sse, cancellationToken) - } - member this.SendServerEvent(sse) = (this :> ISendServerEvent).SendServerEvent(sse, httpResponse.HttpContext.RequestAborted) - -/// 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, httpRequest.HttpContext.RequestAborted) - - 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.SignalsDefault, cancellationToken) - static member ReadSignalsAsync<'T> (httpRequest:HttpRequest, jsonSerializerOptions:JsonSerializerOptions) = SignalsHttpHandler.ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions, httpRequest.HttpContext.RequestAborted) - static member ReadSignalsAsync<'T> (httpRequest:HttpRequest) = SignalsHttpHandler.ReadSignalsAsync<'T>(httpRequest, JsonSerializerOptions.SignalsDefault) - - 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 2b2ac8f75..5bc947b2e 100644 --- a/sdk/dotnet/fsharp/src/ServerSentEventGenerator.fs +++ b/sdk/dotnet/fsharp/src/ServerSentEventGenerator.fs @@ -1,49 +1,268 @@ namespace StarFederation.Datastar.FSharp -open System +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 PatchElements(elements, options:PatchElementsOptions) = - { EventType = PatchElements - Id = options.EventId - Retry = options.Retry - DataLines = [| - if (options.Selector |> ValueOption.isSome) then $"{Consts.DatastarDatalineSelector} {options.Selector |> ValueOption.get |> Selector.value}" - if (options.PatchMode <> Consts.DefaultElementPatchMode) then $"{Consts.DatastarDatalineMode} {options.PatchMode |> Consts.ElementPatchMode.toString}" - if (options.UseViewTransition <> Consts.DefaultElementsUseViewTransitions) then $"{Consts.DatastarDatalineUseViewTransition} %A{options.UseViewTransition}" - yield! (elements |> String.split String.newLines |> Seq.map (fun elementLine -> $"{Consts.DatastarDatalineElements} %s{elementLine}")) - |] } - static member PatchElements elements = ServerSentEventGenerator.PatchElements (elements, PatchElementsOptions.defaults) - - static member RemoveElement(selector, options:RemoveElementOptions) = - { EventType = PatchElements - Id = options.EventId - Retry = options.Retry - DataLines = [| - $"{Consts.DatastarDatalineSelector} {selector |> Selector.value}" - $"{Consts.DatastarDatalineMode} {ElementPatchMode.Remove |> Consts.ElementPatchMode.toString}" - if (options.UseViewTransition <> Consts.DefaultElementsUseViewTransitions) then $"{Consts.DatastarDatalineUseViewTransition} %A{options.UseViewTransition}" - |] } - static member RemoveElement selector = ServerSentEventGenerator.RemoveElement(selector, RemoveElementOptions.defaults) - - static member PatchSignals(signals, options:PatchSignalsOptions) = - { EventType = PatchSignals - Id = options.EventId - Retry = options.Retry - DataLines = [| - if (options.OnlyIfMissing <> Consts.DefaultPatchSignalsOnlyIfMissing) then $"{Consts.DatastarDatalineOnlyIfMissing} %A{options.OnlyIfMissing}" - yield! signals |> Signals.value |> String.split String.newLines |> Seq.map (fun dataLine -> $"{Consts.DatastarDatalineSignals} %s{dataLine}") - |] } - static member PatchSignals signals = ServerSentEventGenerator.PatchSignals(signals, PatchSignalsOptions.defaults) - - static member ExecuteScript(script: string, options:ExecuteScriptOptions) = - let script = if script.StartsWith("" - { EventType = PatchElements - Id = options.EventId - Retry = options.Retry - DataLines = [| - yield! (script |> String.split String.newLines |> Seq.map (fun scriptLine -> $"{Consts.DatastarDatalineElements} %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 785bff527..35fc12e10 100644 --- a/sdk/dotnet/fsharp/src/StarFederation.Datastar.FSharp.fsproj +++ b/sdk/dotnet/fsharp/src/StarFederation.Datastar.FSharp.fsproj @@ -49,11 +49,11 @@ README.md - + + - diff --git a/sdk/dotnet/fsharp/src/Types.fs b/sdk/dotnet/fsharp/src/Types.fs index 9011bf225..f75198ecc 100644 --- a/sdk/dotnet/fsharp/src/Types.fs +++ b/sdk/dotnet/fsharp/src/Types.fs @@ -2,21 +2,11 @@ namespace StarFederation.Datastar.FSharp open System open System.Collections.Generic -open System.IO -open System.Text 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 /// @@ -32,67 +22,59 @@ type SignalPath = string /// type Selector = string +[] type PatchElementsOptions = { Selector: Selector voption PatchMode: ElementPatchMode UseViewTransition: bool EventId: string voption Retry: TimeSpan } -type PatchSignalsOptions = - { OnlyIfMissing: bool - EventId: string voption - Retry: TimeSpan } + 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 = { 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 private lines sse = - seq { - $"event: {sse.EventType |> Consts.EventType.toString}" - - if sse.Id |> ValueOption.isSome - then $"id: {sse.Id |> ValueOption.get}" + with + static member Defaults = + { UseViewTransition = Consts.DefaultElementsUseViewTransitions + EventId = ValueNone + Retry = Consts.DefaultSseRetryDuration } - if (sse.Retry <> Consts.DefaultSseRetryDuration) - then $"retry: {sse.Retry.TotalMilliseconds}" +[] +type PatchSignalsOptions = + { OnlyIfMissing: bool + EventId: string voption + Retry: TimeSpan } + 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 } - ""; ""; "" - } - let serializeAsBytes sse = - lines sse - |> Seq.map (fun line -> Seq.append (Encoding.UTF8.GetBytes line) "\n"B) - |> Seq.concat +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,28 +120,3 @@ module Selector = then Selector selectorString else failwith $"{selectorString} is not a valid selector" let create = sel - -module PatchElementsOptions = - let defaults = - { Selector = ValueNone - PatchMode = Consts.DefaultElementPatchMode - UseViewTransition = Consts.DefaultElementsUseViewTransitions - EventId = ValueNone - Retry = Consts.DefaultSseRetryDuration } - -module RemoveElementOptions = - let defaults = - { UseViewTransition = Consts.DefaultElementsUseViewTransitions - EventId = ValueNone - Retry = Consts.DefaultSseRetryDuration } - -module PatchSignalsOptions = - let defaults = - { OnlyIfMissing = Consts.DefaultPatchSignalsOnlyIfMissing - EventId = ValueNone - Retry = Consts.DefaultSseRetryDuration } - -module ExecuteScriptOptions = - let defaults = - { EventId = ValueNone - Retry = Consts.DefaultSseRetryDuration } diff --git a/sdk/dotnet/fsharp/src/Utility.fs b/sdk/dotnet/fsharp/src/Utility.fs index 2b7830d17..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() + + 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() From 7a32019fbca3c9bb5a9a99fe2c3bd9a2657b15b2 Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Sat, 5 Jul 2025 07:18:23 -0400 Subject: [PATCH 7/8] sdk/dotnet - Consts updated --- sdk/dotnet/fsharp/src/Consts.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 92aa0aee34cdf2bb803dcfe4b732a4638513909e Mon Sep 17 00:00:00 2001 From: Greg Holden Date: Sat, 5 Jul 2025 21:35:56 -0400 Subject: [PATCH 8/8] sdk/dotnet - even more optimizations on c# side --- .../src/DependencyInjection/Services.cs | 22 +++++++++---------- .../csharp/src/DependencyInjection/Types.cs | 8 +++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/sdk/dotnet/csharp/src/DependencyInjection/Services.cs b/sdk/dotnet/csharp/src/DependencyInjection/Services.cs index abb2ce345..4d138945b 100644 --- a/sdk/dotnet/csharp/src/DependencyInjection/Services.cs +++ b/sdk/dotnet/csharp/src/DependencyInjection/Services.cs @@ -7,12 +7,12 @@ namespace StarFederation.Datastar.DependencyInjection; public interface IDatastarService { - Task StartServerEventStreamAsync(IEnumerable> additionalHeaders); - Task StartServerEventStreamAsync(IEnumerable> additionalHeaders); + Task StartServerEventStreamAsync(IEnumerable>? additionalHeaders = null); + Task StartServerEventStreamAsync(IEnumerable>? additionalHeaders = null); Task PatchElementsAsync(string fragments, PatchElementsOptions? options = null); - Task RemoveElementAsync(string selector, RemoveFragmentOptions? 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 @@ -20,7 +20,7 @@ public interface IDatastarService Task PatchSignalsAsync(TType signals, JsonSerializerOptions? jsonSerializerOptions = null, PatchSignalsOptions? patchSignalsOptions = null); /// - /// Execute a JS script on the client. Note: Do NOT include "<script>" encapsulation + /// Execute a JS script on the client. Note: Do NOT include "<script>" encapsulation /// Task ExecuteScriptAsync(string script, ExecuteScriptOptions? options = null); @@ -44,23 +44,23 @@ public interface IDatastarService internal class DatastarService(Core.ServerSentEventGenerator serverSentEventGenerator) : IDatastarService { - public Task StartServerEventStreamAsync(IEnumerable> additionalHeaders) => + public Task StartServerEventStreamAsync(IEnumerable>? additionalHeaders) => serverSentEventGenerator.StartServerEventStreamAsync(additionalHeaders ?? []); - public Task StartServerEventStreamAsync(IEnumerable> additionalHeaders) => + public Task StartServerEventStreamAsync(IEnumerable>? additionalHeaders) => serverSentEventGenerator.StartServerEventStreamAsync(additionalHeaders?.Select(kvp => new KeyValuePair(kvp.Key, new StringValues(kvp.Value))) ?? []); public Task PatchElementsAsync(string fragments, PatchElementsOptions? options = null) => - serverSentEventGenerator.PatchElementsAsync(fragments, options ?? new()); + serverSentEventGenerator.PatchElementsAsync(fragments, options ?? Core.PatchElementsOptions.Defaults); - public Task RemoveElementAsync(string selector, RemoveFragmentOptions? options = null) => - serverSentEventGenerator.RemoveElementAsync(selector, options ?? new()); + public Task RemoveElementAsync(string selector, RemoveElementOptions? options = null) => + serverSentEventGenerator.RemoveElementAsync(selector, options ?? Core.RemoveElementOptions.Defaults); public Task PatchSignalsAsync(TType signals, JsonSerializerOptions? jsonSerializerOptions = null, PatchSignalsOptions? patchSignalsOptions = null) => - serverSentEventGenerator.PatchSignalsAsync(signals as string ?? JsonSerializer.Serialize(signals, jsonSerializerOptions), patchSignalsOptions ?? new()); + serverSentEventGenerator.PatchSignalsAsync(signals as string ?? JsonSerializer.Serialize(signals, jsonSerializerOptions), patchSignalsOptions ?? Core.PatchSignalsOptions.Defaults); public Task ExecuteScriptAsync(string script, ExecuteScriptOptions? options = null) => - serverSentEventGenerator.ExecuteScriptAsync(script, options ?? new()); + serverSentEventGenerator.ExecuteScriptAsync(script, options ?? Core.ExecuteScriptOptions.Defaults); public Stream GetSignalsStream() => serverSentEventGenerator.GetSignalsStream(); diff --git a/sdk/dotnet/csharp/src/DependencyInjection/Types.cs b/sdk/dotnet/csharp/src/DependencyInjection/Types.cs index 9156a6cf5..4347e96f5 100644 --- a/sdk/dotnet/csharp/src/DependencyInjection/Types.cs +++ b/sdk/dotnet/csharp/src/DependencyInjection/Types.cs @@ -54,16 +54,16 @@ public class PatchSignalsOptions options.Retry); } -public class RemoveFragmentOptions +public class RemoveElementOptions { 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.RemoveElementOptions(RemoveFragmentOptions options) => ToFSharp(options); - public static implicit operator FSharpValueOption(RemoveFragmentOptions 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.RemoveElementOptions ToFSharp(RemoveFragmentOptions options) => new( + private static Core.RemoveElementOptions ToFSharp(RemoveElementOptions options) => new( options.UseViewTransition, options.EventId ?? FSharpValueOption.ValueNone, options.Retry);