├── src ├── fsharp │ ├── .gitattributes │ ├── Utility.fs │ ├── StarFederation.Datastar.FSharp.fsproj │ ├── ServerSentEvent.fs │ ├── Consts.fs │ ├── Types.fs │ └── ServerSentEventGenerator.fs ├── .gitattributes └── csharp │ ├── ModelBinding │ ├── MvcServiceProvider.cs │ ├── FromSignalAttribute.cs │ └── SignalsModelBinder.cs │ ├── DependencyInjection │ ├── ServicesProvider.cs │ ├── Types.cs │ └── Services.cs │ ├── StarFederation.Datastar.csproj │ ├── Consts.cs │ └── Utility.cs ├── assets └── datastar_icon.png ├── global.json ├── examples ├── csharp │ └── HelloWorld │ │ ├── appsettings.json │ │ ├── HelloWorld.csproj │ │ ├── Properties │ │ └── launchSettings.json │ │ ├── Program.cs │ │ └── wwwroot │ │ └── hello-world.html └── .gitignore ├── LICENSE.md ├── datastar-dotnet.sln └── README.md /src/fsharp/.gitattributes: -------------------------------------------------------------------------------- 1 | Consts.fs linguist-generated=true -------------------------------------------------------------------------------- /assets/datastar_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/starfederation/datastar-dotnet/HEAD/assets/datastar_icon.png -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "10.0.0", 4 | "rollForward": "latestFeature", 5 | "allowPrerelease": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/.gitattributes: -------------------------------------------------------------------------------- 1 | Constants.php linguist-generated=true 2 | enums/ElementPatchMode.php linguist-generated=true 3 | enums/EventType.php linguist-generated=true -------------------------------------------------------------------------------- /examples/csharp/HelloWorld/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /examples/csharp/HelloWorld/HelloWorld.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/csharp/ModelBinding/MvcServiceProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using StarFederation.Datastar.DependencyInjection; 3 | 4 | namespace StarFederation.Datastar.ModelBinding; 5 | 6 | public static class ServiceCollectionExtensionMethods 7 | { 8 | public static IServiceCollection AddDatastarMvc(this IServiceCollection serviceCollection) 9 | { 10 | // ReSharper disable once SuspiciousTypeConversion.Global 11 | if (!serviceCollection.Any(_ => _.ServiceType == typeof(IDatastarService))) throw new Exception($"{nameof(AddDatastarMvc)} requires that {nameof(DependencyInjection.ServiceCollectionExtensionMethods.AddDatastar)} is added first"); 12 | 13 | serviceCollection.AddControllers(options => options.ModelBinderProviders.Insert(0, new SignalsModelBinderProvider())); 14 | return serviceCollection; 15 | } 16 | } -------------------------------------------------------------------------------- /src/csharp/DependencyInjection/ServicesProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Core = StarFederation.Datastar.FSharp; 4 | 5 | namespace StarFederation.Datastar.DependencyInjection; 6 | 7 | public static class ServiceCollectionExtensionMethods 8 | { 9 | public static IServiceCollection AddDatastar(this IServiceCollection serviceCollection) 10 | { 11 | serviceCollection 12 | .AddHttpContextAccessor() 13 | .AddScoped(svcPvd => 14 | { 15 | IHttpContextAccessor? httpContextAccessor = svcPvd.GetService(); 16 | Core.ServerSentEventGenerator serverSentEventGenerator = new(httpContextAccessor); 17 | return new DatastarService(serverSentEventGenerator); 18 | }); 19 | return serviceCollection; 20 | } 21 | } -------------------------------------------------------------------------------- /examples/csharp/HelloWorld/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "launchUrl": "hello-world.html", 9 | "applicationUrl": "http://localhost:5222", 10 | "environmentVariables": { 11 | "ASPNETCORE_ENVIRONMENT": "Development" 12 | } 13 | }, 14 | "https": { 15 | "commandName": "Project", 16 | "dotnetRunMessages": true, 17 | "launchBrowser": true, 18 | "launchUrl": "hello-world.html", 19 | "applicationUrl": "https://localhost:7265;http://localhost:5222", 20 | "environmentVariables": { 21 | "ASPNETCORE_ENVIRONMENT": "Development" 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © Star Federation 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/fsharp/Utility.fs: -------------------------------------------------------------------------------- 1 | namespace StarFederation.Datastar.FSharp 2 | 3 | module internal Utility = 4 | open System 5 | open System.Text 6 | open Microsoft.Extensions.Primitives 7 | 8 | module internal String = 9 | let dotSeparator = [| '.' |] 10 | let newLines = [| "\r\n"; "\n"; "\r" |] 11 | let newLineChars = [| '\r'; '\n' |] 12 | 13 | // New zero-allocation version using StringTokenizer 14 | let inline splitLinesToSegments (text: string) = 15 | StringTokenizer(text, newLineChars) 16 | |> Seq.filter (fun segment -> segment.Length > 0) 17 | 18 | let isPopulated = (String.IsNullOrWhiteSpace >> not) 19 | 20 | let toKebab (pascalString: string) = 21 | let sb = StringBuilder(pascalString.Length * 2) 22 | let chars = pascalString.ToCharArray() 23 | 24 | for i = 0 to chars.Length - 1 do 25 | let chr = chars.[i] 26 | 27 | if Char.IsUpper(chr) && i > 0 then 28 | sb.Append('-').Append(Char.ToLower(chr)) |> ignore 29 | else 30 | sb.Append(Char.ToLower(chr)) |> ignore 31 | 32 | sb.ToString() 33 | -------------------------------------------------------------------------------- /datastar-dotnet.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "StarFederation.Datastar.FSharp", "src\fsharp\StarFederation.Datastar.FSharp.fsproj", "{D357821B-B5A9-4076-9DC3-20FC744C746F}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarFederation.Datastar", "src\csharp\StarFederation.Datastar.csproj", "{9C733B58-AA42-4A9A-8A03-887D0B69418A}" 6 | EndProject 7 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "environment", "environment", "{07A426A5-31E4-437E-ABB7-F69449A36DE6}" 8 | ProjectSection(SolutionItems) = preProject 9 | global.json = global.json 10 | EndProjectSection 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {D357821B-B5A9-4076-9DC3-20FC744C746F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {D357821B-B5A9-4076-9DC3-20FC744C746F}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {D357821B-B5A9-4076-9DC3-20FC744C746F}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {D357821B-B5A9-4076-9DC3-20FC744C746F}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {9C733B58-AA42-4A9A-8A03-887D0B69418A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {9C733B58-AA42-4A9A-8A03-887D0B69418A}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {9C733B58-AA42-4A9A-8A03-887D0B69418A}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {9C733B58-AA42-4A9A-8A03-887D0B69418A}.Release|Any CPU.Build.0 = Release|Any CPU 26 | EndGlobalSection 27 | EndGlobal 28 | -------------------------------------------------------------------------------- /src/csharp/ModelBinding/FromSignalAttribute.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Microsoft.AspNetCore.Mvc.ModelBinding; 3 | using Core = StarFederation.Datastar.FSharp; 4 | 5 | namespace StarFederation.Datastar.ModelBinding; 6 | 7 | public class DatastarSignalsBindingSource(string path, JsonSerializerOptions? jsonSerializerOptions) : BindingSource(BindingSourceName, BindingSourceName, true, true) 8 | { 9 | public const string BindingSourceName = "DatastarSignalsSource"; 10 | public string BindingPath { get; } = path; 11 | public JsonSerializerOptions JsonSerializerOptions { get; } = jsonSerializerOptions ?? Core.JsonSerializerOptions.SignalsDefault; 12 | } 13 | 14 | /// 15 | /// FromSignals will collect the values from the signals passed.
16 | /// - value type or string without path => use parameter name as key into Signals
17 | /// - value type or string with path => use dot-separated, path name as key into Signals
18 | /// - reference type without path => deserialize Signals into parameter Type
19 | /// - reference type with path => deserialize Signals at path into parameter Type
20 | /// Important: Requests that have the signals in the body (POST, PUT, etc) can only have one [FromSignals] in the parameter list; all following will receive default
21 | /// Important: When binding to a non-string, reference type; the parameter name is always ignored, the parameter name will not be used as a key into the Signals like value types and strings 22 | ///
23 | [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] 24 | public class FromSignalsAttribute : Attribute, IBindingSourceMetadata 25 | { 26 | public string Path { get; set; } = string.Empty; 27 | public JsonSerializerOptions JsonSerializerOptions { get; set; } = Core.JsonSerializerOptions.SignalsDefault; 28 | public BindingSource BindingSource => new DatastarSignalsBindingSource(Path, JsonSerializerOptions); 29 | } -------------------------------------------------------------------------------- /src/csharp/StarFederation.Datastar.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | StarFederation.Datastar 5 | 1.2.0 6 | StarFederation.Datastar 7 | enable 8 | StarFederation.Datastar 9 | net8.0;net9.0;net10.0 10 | enable 11 | 12 | 13 | SDK for Datastar ServerSentEvents and Signals 14 | Greg Holden and contributors 15 | en 16 | 17 | 18 | embedded 19 | Library 20 | 21 | 22 | StarFederation.Datastar 23 | 1.2.0 24 | datastar;datastar-sharp;fsharp;functional;asp.net core;asp.net;.net core;routing;web;csharp 25 | https://github.com/starfederation/datastar-dotnet 26 | datastar_icon.png 27 | README.md 28 | LICENSE.md 29 | true 30 | git 31 | https://github.com/starfederation/datastar-dotnet 32 | 33 | 34 | true 35 | true 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /examples/csharp/HelloWorld/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Text.Json.Serialization; 3 | using StarFederation.Datastar.DependencyInjection; 4 | 5 | namespace HelloWorld; 6 | 7 | public class Program 8 | { 9 | 10 | public static void Main(string[] args) 11 | { 12 | WebApplicationBuilder builder = WebApplication.CreateBuilder(args); 13 | builder.Services.AddDatastar(); 14 | 15 | WebApplication app = builder.Build(); 16 | app.UseStaticFiles(); 17 | 18 | app.MapGet("/stream-element-patches", async (IDatastarService datastarService) => 19 | { 20 | const string message = "Hello, Elements!"; 21 | Signals? mySignals = await datastarService.ReadSignalsAsync(); 22 | Debug.Assert(mySignals != null, nameof(mySignals) + " != null"); 23 | 24 | await datastarService.PatchSignalsAsync(new { show_patch_element_message = true }); 25 | 26 | for (var index = 1; index < message.Length; ++index) 27 | { 28 | await datastarService.PatchElementsAsync($"""
{message[..index]}
"""); 29 | await Task.Delay(TimeSpan.FromMilliseconds(mySignals.Delay.GetValueOrDefault(0))); 30 | } 31 | 32 | await datastarService.PatchElementsAsync($"""
{message}
"""); 33 | }); 34 | 35 | app.MapGet("/stream-signal-patches", async (IDatastarService datastarService) => 36 | { 37 | const string message = "Hello, Signals!"; 38 | Signals? mySignals = await datastarService.ReadSignalsAsync(); 39 | Debug.Assert(mySignals != null, nameof(mySignals) + " != null"); 40 | 41 | await datastarService.PatchSignalsAsync(new { show_patch_element_message = false }); 42 | 43 | for (var index = 1; index < message.Length; ++index) 44 | { 45 | await datastarService.PatchSignalsAsync(new { signals_message = message[..index] }); 46 | await Task.Delay(TimeSpan.FromMilliseconds(mySignals.Delay.GetValueOrDefault(0))); 47 | } 48 | 49 | await datastarService.PatchSignalsAsync(new { signals_message = message }); 50 | }); 51 | 52 | app.MapGet("/execute-script", (IDatastarService datastarService) => 53 | datastarService.ExecuteScriptAsync("alert('Hello! from the server 🚀')")); 54 | 55 | app.Run(); 56 | } 57 | 58 | public record Signals 59 | { 60 | [JsonPropertyName("delay")] 61 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 62 | public float? Delay { get; set; } = null; 63 | } 64 | } -------------------------------------------------------------------------------- /src/fsharp/StarFederation.Datastar.FSharp.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | StarFederation.Datastar.FSharp 5 | 1.2.0 6 | StarFederation.Datastar.FSharp 7 | disabled 8 | StarFederation.Datastar.FSharp 9 | net8.0;net9.0;net10.0 10 | 11 | 12 | SDK for Datastar ServerSentEvents and Signals 13 | Greg Holden, Ryan Riley, and contributors 14 | en 15 | 16 | 17 | embedded 18 | Library 19 | true 20 | false 21 | true 22 | 23 | 24 | StarFederation.Datastar.FSharp 25 | 1.2.0 26 | datastar;datastar-sharp;fsharp;functional;asp.net core;asp.net;.net core;routing;web 27 | https://github.com/starfederation/datastar-dotnet 28 | datastar_icon.png 29 | README.md 30 | LICENSE.md 31 | true 32 | git 33 | https://github.com/starfederation/datastar-dotnet 34 | 35 | 36 | true 37 | true 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/csharp/Consts.cs: -------------------------------------------------------------------------------- 1 | namespace StarFederation.Datastar; 2 | 3 | public enum ElementPatchMode 4 | { 5 | /// Morphs the element into the existing element. 6 | Outer, 7 | /// Replaces the inner HTML of the existing element. 8 | Inner, 9 | /// Removes the existing element. 10 | Remove, 11 | /// Replaces the existing element with the new element. 12 | Replace, 13 | /// Prepends the element inside to the existing element. 14 | Prepend, 15 | /// Appends the element inside the existing element. 16 | Append, 17 | /// Inserts the element before the existing element. 18 | Before, 19 | /// Inserts the element after the existing element. 20 | After 21 | } 22 | 23 | public enum PatchElementNamespace 24 | { 25 | Html, 26 | Svg, 27 | MathMl, 28 | } 29 | 30 | public enum EventType 31 | { 32 | /// An event for patching HTML elements into the DOM. 33 | PatchElements, 34 | /// An event for patching signals. 35 | PatchSignals 36 | } 37 | 38 | public static class Consts 39 | { 40 | public const string DatastarKey = "datastar"; 41 | 42 | /// Default: outer - Morphs the element into the existing element. 43 | public const ElementPatchMode DefaultElementPatchMode = ElementPatchMode.Outer; 44 | 45 | public const PatchElementNamespace DefaultPatchElementNamespace = PatchElementNamespace.Html; 46 | public const bool DefaultElementsUseViewTransitions = false; 47 | public const bool DefaultPatchSignalsOnlyIfMissing = false; 48 | 49 | public const string DatastarDatalineSelector = "selector"; 50 | public const string DatastarDatalineMode = "mode"; 51 | public const string DatastarDatalineElements = "elements"; 52 | public const string DatastarDatalineUseViewTransition = "useViewTransition"; 53 | public const string DatastarDatalineSignals = "signals"; 54 | public const string DatastarDatalineOnlyIfMissing = "onlyIfMissing"; 55 | 56 | /// Default: TimeSpan.FromMilliseconds 1000 57 | public static readonly TimeSpan DefaultSseRetryDuration = TimeSpan.FromMilliseconds(1000); 58 | 59 | public static string EnumToString(ElementPatchMode enumValue) => enumValue switch 60 | { 61 | ElementPatchMode.Outer => "outer", 62 | ElementPatchMode.Inner => "inner", 63 | ElementPatchMode.Remove => "remove", 64 | ElementPatchMode.Replace => "replace", 65 | ElementPatchMode.Prepend => "prepend", 66 | ElementPatchMode.Append => "append", 67 | ElementPatchMode.Before => "before", 68 | ElementPatchMode.After => "after", 69 | _ => throw new NotImplementedException($"ElementPatchMode.{enumValue}") 70 | }; 71 | 72 | public static string EnumToString(PatchElementNamespace enumValue) => enumValue switch 73 | { 74 | PatchElementNamespace.Html => "html", 75 | PatchElementNamespace.Svg => "svg", 76 | PatchElementNamespace.MathMl => "mathml", 77 | _ => throw new NotImplementedException($"PatchElementNamespace.{enumValue}") 78 | }; 79 | 80 | public static string EnumToString(EventType enumValue) => enumValue switch 81 | { 82 | EventType.PatchElements => "datastar-patch-elements", 83 | EventType.PatchSignals => "datastar-patch-signals", 84 | _ => throw new NotImplementedException($"EventType.{enumValue}") 85 | }; 86 | } -------------------------------------------------------------------------------- /src/fsharp/ServerSentEvent.fs: -------------------------------------------------------------------------------- 1 | namespace StarFederation.Datastar.FSharp 2 | 3 | open System 4 | open System.Buffers 5 | open System.Text 6 | open Microsoft.Extensions.Primitives 7 | 8 | module internal ServerSentEvent = 9 | let private eventPrefix = "event: "B 10 | let private idPrefix = "id: "B 11 | let private retryPrefix = "retry: "B 12 | let private dataPrefix = "data: "B 13 | 14 | let inline private writeUtf8String (str:string) (writer:IBufferWriter) = 15 | let span = writer.GetSpan(Encoding.UTF8.GetByteCount(str)) 16 | let bytesWritten = Encoding.UTF8.GetBytes(str.AsSpan(), span) 17 | writer.Advance(bytesWritten) 18 | writer 19 | 20 | let inline private writeUtf8Literal (bytes:byte[]) (writer:IBufferWriter) = 21 | let span = writer.GetSpan(bytes.Length) 22 | bytes.AsSpan().CopyTo(span) 23 | writer.Advance(bytes.Length) 24 | writer 25 | 26 | let inline private writeUtf8Segment (segment:StringSegment) (writer:IBufferWriter) = 27 | let span = writer.GetSpan(Encoding.UTF8.GetByteCount(segment)) 28 | let bytesWritten = Encoding.UTF8.GetBytes(segment.AsSpan(), span) 29 | writer.Advance(bytesWritten) 30 | writer 31 | 32 | let inline writeSpace (writer:IBufferWriter) = 33 | let span = writer.GetSpan(1) 34 | span[0] <- 32uy // ' ' 35 | writer.Advance(1) 36 | writer 37 | 38 | let inline writeNewline (writer:IBufferWriter) = 39 | let span = writer.GetSpan(1) 40 | span[0] <- 10uy // '\n' 41 | writer.Advance(1) 42 | 43 | let inline sendEventType eventType writer = 44 | writer 45 | |> writeUtf8Literal eventPrefix 46 | |> writeUtf8Literal eventType 47 | |> writeNewline 48 | 49 | let inline sendEventId eventId writer = 50 | writer |> writeUtf8Literal idPrefix |> writeUtf8String eventId |> writeNewline 51 | 52 | let inline sendRetry (retry:TimeSpan) writer = 53 | writer 54 | |> writeUtf8Literal retryPrefix 55 | |> writeUtf8String (retry.TotalMilliseconds.ToString()) 56 | |> writeNewline 57 | 58 | let inline sendDataBytesLine dataType bytes writer = 59 | writer 60 | |> writeUtf8Literal dataPrefix 61 | |> writeUtf8Literal dataType 62 | |> writeSpace 63 | |> writeUtf8Literal bytes 64 | |> writeNewline 65 | 66 | let inline sendDataStringSeqLine dataType strings writer = 67 | writer 68 | |> writeUtf8Literal dataPrefix 69 | |> writeUtf8Literal dataType 70 | |> writeSpace 71 | |> (fun writer -> 72 | strings 73 | |> Seq.iter (fun string -> writer |> writeUtf8String string |> writeSpace |> ignore) 74 | writer) 75 | |> writeNewline 76 | 77 | let inline sendDataStringLine dataType data writer = 78 | writer 79 | |> writeUtf8Literal dataPrefix 80 | |> writeUtf8Literal dataType 81 | |> writeSpace 82 | |> writeUtf8String data 83 | |> writeNewline 84 | 85 | let inline sendDataSegmentLine dataType segment writer = 86 | writer 87 | |> writeUtf8Literal dataPrefix 88 | |> writeUtf8Literal dataType 89 | |> writeSpace 90 | |> writeUtf8Segment segment 91 | |> writeNewline 92 | -------------------------------------------------------------------------------- /src/fsharp/Consts.fs: -------------------------------------------------------------------------------- 1 | namespace StarFederation.Datastar.FSharp 2 | 3 | open System 4 | 5 | type ElementPatchMode = 6 | /// Morphs the element into the existing element. 7 | | Outer 8 | /// Replaces the inner HTML of the existing element. 9 | | Inner 10 | /// Removes the existing element. 11 | | Remove 12 | /// Replaces the existing element with the new element. 13 | | Replace 14 | /// Prepends the element inside to the existing element. 15 | | Prepend 16 | /// Appends the element inside the existing element. 17 | | Append 18 | /// Inserts the element before the existing element. 19 | | Before 20 | /// Inserts the element after the existing element. 21 | | After 22 | 23 | type PatchElementNamespace = 24 | | Html 25 | | Svg 26 | | MathMl 27 | 28 | module Consts = 29 | [] 30 | let DatastarKey = "datastar" 31 | 32 | // Defaults 33 | let DefaultSseRetryDuration = TimeSpan.FromSeconds(1.0) 34 | let DefaultElementPatchMode = Outer 35 | let DefaultPatchElementNamespace = Html 36 | 37 | [] 38 | let DefaultElementsUseViewTransitions = false 39 | 40 | [] 41 | let DefaultPatchSignalsOnlyIfMissing = false 42 | 43 | [] 44 | let internal ScriptDataEffectRemove = @"data-effect=""el.remove()""" 45 | 46 | module internal Bytes = 47 | // Internal: Byte Constants 48 | let EventTypePatchElements = "datastar-patch-elements"B 49 | let EventTypePatchSignals = "datastar-patch-signals"B 50 | 51 | let DatalineSelector = "selector"B 52 | let DatalineMode = "mode"B 53 | let DatalineElements = "elements"B 54 | let DatalineUseViewTransition = "useViewTransition"B 55 | let DatalineNamespace = "namespace"B 56 | let DatalineSignals = "signals"B 57 | let DatalineOnlyIfMissing = "onlyIfMissing"B 58 | 59 | let bTrue = "true"B 60 | let bFalse = "false"B 61 | let bSpace = " "B 62 | let bQuote = @""""B 63 | 64 | let bOpenScriptAutoRemove = @""B 67 | let bBody = "body"B 68 | 69 | module PatchElementNamespace = 70 | let bHtml = "html"B 71 | let bSvg = "svg"B 72 | let bMathMl = "mathml"B 73 | 74 | let inline toBytes this = 75 | match this with 76 | | Html -> bHtml 77 | | Svg -> bSvg 78 | | MathMl -> bMathMl 79 | 80 | module ElementPatchMode = 81 | let bOuter = "outer"B 82 | let bInner = "inner"B 83 | let bRemove = "remove"B 84 | let bReplace = "replace"B 85 | let bPrepend = "prepend"B 86 | let bAppend = "append"B 87 | let bBefore = "before"B 88 | let bAfter = "after"B 89 | 90 | let inline toBytes this = 91 | match this with 92 | | ElementPatchMode.Outer -> bOuter 93 | | ElementPatchMode.Inner -> bInner 94 | | ElementPatchMode.Remove -> bRemove 95 | | ElementPatchMode.Replace -> bReplace 96 | | ElementPatchMode.Prepend -> bPrepend 97 | | ElementPatchMode.Append -> bAppend 98 | | ElementPatchMode.Before -> bBefore 99 | | ElementPatchMode.After -> bAfter 100 | -------------------------------------------------------------------------------- /src/csharp/Utility.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Microsoft.FSharp.Collections; 3 | 4 | // ReSharper disable PossibleMultipleEnumeration 5 | 6 | namespace StarFederation.Datastar; 7 | 8 | internal static class Utilities 9 | { 10 | public static JsonElement? Get(this JsonElement element, string name) => 11 | element.ValueKind != JsonValueKind.Null && element.ValueKind != JsonValueKind.Undefined && element.TryGetProperty(name, out JsonElement value) 12 | ? value 13 | : null; 14 | 15 | public static JsonElement? Get(this JsonElement element, int index) => 16 | element.ValueKind == JsonValueKind.Null || element.ValueKind == JsonValueKind.Undefined ? null : 17 | index < element.GetArrayLength() ? element[index] : null; 18 | 19 | /// 20 | /// Given a dot-separated path into a JSON structure, will return the JsonElement or null 21 | /// 22 | /// the head of the tree 23 | /// dot-separated path 24 | /// JsonElement if found; null if the path cannot be followed 25 | public static JsonElement? GetFromPath(this JsonElement jsonElement, string path) 26 | { 27 | JsonElement? GetFromPathCore(JsonElement jElement, IEnumerable pathElements) 28 | { 29 | if (!pathElements.Any()) 30 | { 31 | return null; 32 | } 33 | 34 | while (true) 35 | { 36 | if (pathElements.Count() == 1) 37 | { 38 | return jElement.Get(pathElements.First()); 39 | } 40 | if (jElement.Get(pathElements.First()) is not { } childElement) 41 | { 42 | return null; 43 | } 44 | jElement = childElement; 45 | pathElements = pathElements.Skip(1); 46 | } 47 | } 48 | return GetFromPathCore(jsonElement, path.Split('.')); 49 | } 50 | 51 | /// 52 | /// Given a dot-separated path into a JSON structure, will return the value or null 53 | /// 54 | /// the head of the tree 55 | /// dot-separated path 56 | /// the type to convert the value into 57 | /// options to the serializer 58 | /// value if found; null if the path cannot be followed 59 | public static object? GetValueFromPath(this JsonElement jsonElement, string path, Type jsonElementType, JsonSerializerOptions jsonSerializerOptions) 60 | { 61 | JsonElement? childJsonElement = GetFromPath(jsonElement, path); 62 | return childJsonElement?.Deserialize(jsonElementType, jsonSerializerOptions); 63 | } 64 | 65 | public static Tuple AsTuple(this KeyValuePair keyValuePair) => new(keyValuePair.Key, keyValuePair.Value); 66 | public static Tuple AsTuple(this (TKey, TValue) keyValuePair) => new(keyValuePair.Item1, keyValuePair.Item2); 67 | 68 | public static FSharpList ToFSharpList(this IEnumerable @this) 69 | => @this.Aggregate(FSharpList.Empty, (current, item) => new FSharpList(item, current)); 70 | } -------------------------------------------------------------------------------- /src/csharp/ModelBinding/SignalsModelBinder.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json; 3 | using Microsoft.AspNetCore.Mvc.ModelBinding; 4 | using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; 5 | using Microsoft.Extensions.Logging; 6 | using StarFederation.Datastar.DependencyInjection; 7 | 8 | namespace StarFederation.Datastar.ModelBinding; 9 | 10 | public class SignalsModelBinder(ILogger logger, IDatastarService signalsReader) : IModelBinder 11 | { 12 | public async Task BindModelAsync(ModelBindingContext bindingContext) 13 | { 14 | DatastarSignalsBindingSource signalBindingSource = (bindingContext.BindingSource as DatastarSignalsBindingSource)!; 15 | 16 | // Get signals into a JsonDocument 17 | JsonDocument doc; 18 | try 19 | { 20 | doc = await ReadSignalsToJsonDocument(bindingContext); 21 | } 22 | catch (JsonException ex) when (ex is { LineNumber: 0, BytePositionInLine: 0 }) 23 | { 24 | logger.LogWarning("Empty Signals. Is it possible you have multiple [FromSignals] for a not-GET request?"); 25 | bindingContext.Result = ModelBindingResult.Failed(); 26 | return; 27 | } 28 | catch 29 | { 30 | bindingContext.Result = ModelBindingResult.Failed(); 31 | return; 32 | } 33 | 34 | try 35 | { 36 | if (bindingContext.ModelType.IsValueType || bindingContext.ModelType == typeof(string)) 37 | { 38 | // SignalsPath: use the name of the field in the method or the one passed in the attribute 39 | string signalsPath = String.IsNullOrEmpty(signalBindingSource.BindingPath) ? bindingContext.FieldName : signalBindingSource.BindingPath; 40 | 41 | object? value = doc.RootElement.GetValueFromPath(signalsPath, bindingContext.ModelType, signalBindingSource.JsonSerializerOptions) 42 | ?? (bindingContext.ModelType.IsValueType ? Activator.CreateInstance(bindingContext.ModelType) : null); 43 | bindingContext.Result = ModelBindingResult.Success(value); 44 | } 45 | else 46 | { 47 | object? value; 48 | if (String.IsNullOrEmpty(signalBindingSource.BindingPath)) 49 | { 50 | value = doc.Deserialize(bindingContext.ModelType, signalBindingSource.JsonSerializerOptions); 51 | } 52 | else 53 | { 54 | value = doc.RootElement.GetValueFromPath(signalBindingSource.BindingPath, bindingContext.ModelType, signalBindingSource.JsonSerializerOptions); 55 | } 56 | 57 | bindingContext.Result = ModelBindingResult.Success(value); 58 | } 59 | } 60 | catch 61 | { 62 | bindingContext.Result = ModelBindingResult.Failed(); 63 | } 64 | } 65 | 66 | private async ValueTask ReadSignalsToJsonDocument(ModelBindingContext bindingContext) 67 | { 68 | return bindingContext.HttpContext.Request.Method == WebRequestMethods.Http.Get 69 | ? JsonDocument.Parse(await signalsReader.ReadSignalsAsync() ?? string.Empty) 70 | : await JsonDocument.ParseAsync(signalsReader.GetSignalsStream()); 71 | } 72 | } 73 | 74 | public class SignalsModelBinderProvider : IModelBinderProvider 75 | { 76 | public IModelBinder? GetBinder(ModelBinderProviderContext context) 77 | => context?.BindingInfo?.BindingSource?.DisplayName == DatastarSignalsBindingSource.BindingSourceName 78 | ? new BinderTypeModelBinder(typeof(SignalsModelBinder)) 79 | : null; 80 | 81 | } -------------------------------------------------------------------------------- /examples/csharp/HelloWorld/wwwroot/hello-world.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Datastar SDK Demo 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |
Hello, World!
13 |
14 |
15 |
16 | 17 |
18 | 19 | 20 |
21 |
22 |

23 | Element Patches Stream 24 |

25 |
26 |

27 | Elements are patched by the server. 28 |

29 |
30 | 33 | 34 |
35 | 38 |
39 | 40 | 41 |
42 |
43 |

44 | Signal Patches Stream 45 |

46 |
47 |

48 | Signals are patched by the server. 49 |

50 |
51 | 52 | 53 |
54 | 57 |
58 | 59 | 60 |
61 |
62 |

63 | Execute Script 64 |

65 |
66 |

67 | A JS "alert" script will be streamed from the server
and immediately executed. 68 |

69 | 72 |
73 |
74 | 75 | -------------------------------------------------------------------------------- /src/fsharp/Types.fs: -------------------------------------------------------------------------------- 1 | namespace StarFederation.Datastar.FSharp 2 | 3 | open System 4 | open System.Collections.Generic 5 | open System.Text.Json 6 | open System.Text.Json.Nodes 7 | open System.Text.RegularExpressions 8 | open StarFederation.Datastar.FSharp.Utility 9 | 10 | /// Signals read to and from Datastar on the front end 11 | type Signals = string 12 | 13 | /// A dotted path into Signals to access a key/value pair 14 | type SignalPath = string 15 | 16 | /// An HTML selector name 17 | type Selector = string 18 | 19 | [] 20 | type PatchElementsOptions = 21 | { Selector: Selector voption 22 | PatchMode: ElementPatchMode 23 | UseViewTransition: bool 24 | Namespace: PatchElementNamespace 25 | EventId: string voption 26 | Retry: TimeSpan } 27 | static member Defaults = 28 | { Selector = ValueNone 29 | PatchMode = Consts.DefaultElementPatchMode 30 | UseViewTransition = Consts.DefaultElementsUseViewTransitions 31 | Namespace = Consts.DefaultPatchElementNamespace 32 | EventId = ValueNone 33 | Retry = Consts.DefaultSseRetryDuration } 34 | 35 | [] 36 | type RemoveElementOptions = 37 | { UseViewTransition: bool 38 | EventId: string voption 39 | Retry: TimeSpan } 40 | static member Defaults = 41 | { UseViewTransition = Consts.DefaultElementsUseViewTransitions 42 | EventId = ValueNone 43 | Retry = Consts.DefaultSseRetryDuration } 44 | 45 | [] 46 | type PatchSignalsOptions = 47 | { OnlyIfMissing: bool 48 | EventId: string voption 49 | Retry: TimeSpan } 50 | static member Defaults = 51 | { OnlyIfMissing = Consts.DefaultPatchSignalsOnlyIfMissing 52 | EventId = ValueNone 53 | Retry = Consts.DefaultSseRetryDuration } 54 | 55 | [] 56 | type ExecuteScriptOptions = 57 | { EventId: string voption 58 | Retry: TimeSpan 59 | AutoRemove: bool 60 | /// added to the <script> tag 61 | Attributes: KeyValuePair list } 62 | static member Defaults = 63 | { EventId = ValueNone 64 | Retry = Consts.DefaultSseRetryDuration 65 | AutoRemove = true 66 | Attributes = [] } 67 | 68 | module JsonSerializerOptions = 69 | let SignalsDefault = 70 | let options = JsonSerializerOptions() 71 | options.PropertyNameCaseInsensitive <- true 72 | options 73 | 74 | module Signals = 75 | let create (signalsString:string) = Signals signalsString 76 | let tryCreate (signalsString:string) = 77 | try 78 | let _ = JsonObject.Parse(signalsString) 79 | ValueSome(Signals signalsString) 80 | with _ -> 81 | ValueNone 82 | let empty = Signals "{ }" 83 | 84 | module SignalPath = 85 | let isValidKey (signalPathKey:string) = 86 | signalPathKey 87 | |> String.isPopulated && signalPathKey.ToCharArray() |> Seq.forall (fun chr -> Char.IsLetter chr || Char.IsNumber chr || chr = '_') 88 | 89 | let isValid (signalPathString:string) = 90 | signalPathString.Split('.') |> Array.forall isValidKey 91 | 92 | let tryCreate (signalPathString:string) = 93 | if isValid signalPathString 94 | then ValueSome(SignalPath signalPathString) 95 | else ValueNone 96 | 97 | let create (signalPathString:string) = 98 | if isValid signalPathString 99 | then SignalPath signalPathString 100 | else failwith $"{signalPathString} is not a valid signal path" 101 | 102 | let kebabValue signals = signals |> String.toKebab 103 | let keys (signalPath:SignalPath) = signalPath.Split('.') 104 | 105 | module Selector = 106 | let regex = Regex(@"[#.][-_]?[_a-zA-Z]+(?:\w|\\.)*|(?<=\s+|^)(?:\w+|\*)|\[[^\s""'=<>`]+?(?`]+))?\]|:[\w-]+(?:\(.*\))?", RegexOptions.Compiled) 107 | 108 | let isValid (selectorString:string) = regex.IsMatch selectorString 109 | 110 | let tryCreate (selectorString:string) = 111 | if isValid selectorString 112 | then ValueSome(Selector selectorString) 113 | else ValueNone 114 | 115 | let create (selectorString:string) = 116 | if isValid selectorString 117 | then Selector selectorString 118 | else failwith $"{selectorString} is not a valid selector" 119 | -------------------------------------------------------------------------------- /src/csharp/DependencyInjection/Types.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.FSharp.Collections; 2 | using Microsoft.FSharp.Core; 3 | using Core = StarFederation.Datastar.FSharp; 4 | 5 | namespace StarFederation.Datastar.DependencyInjection; 6 | 7 | public class PatchElementsOptions 8 | { 9 | public string? Selector { get; init; } = null; 10 | public ElementPatchMode PatchMode { get; init; } = Consts.DefaultElementPatchMode; 11 | public bool UseViewTransition { get; init; } = Consts.DefaultElementsUseViewTransitions; 12 | public PatchElementNamespace Namespace { get; init; } = PatchElementNamespace.Html; 13 | public string? EventId { get; init; } = null; 14 | public TimeSpan Retry { get; init; } = Consts.DefaultSseRetryDuration; 15 | 16 | public static implicit operator FSharpValueOption(PatchElementsOptions options) => ToFSharp(options); 17 | public static implicit operator Core.PatchElementsOptions(PatchElementsOptions options) => ToFSharp(options); 18 | 19 | private static Core.PatchElementsOptions ToFSharp(PatchElementsOptions options) 20 | { 21 | return new Core.PatchElementsOptions( 22 | options.Selector ?? FSharpValueOption.ValueNone, 23 | From(options.PatchMode), 24 | options.UseViewTransition, 25 | FromNs(options.Namespace), 26 | options.EventId ?? FSharpValueOption.ValueNone, 27 | options.Retry 28 | ); 29 | 30 | static Core.ElementPatchMode From(ElementPatchMode patchElementsMode) => patchElementsMode switch 31 | { 32 | ElementPatchMode.Inner => Core.ElementPatchMode.Inner, 33 | ElementPatchMode.Outer => Core.ElementPatchMode.Outer, 34 | ElementPatchMode.Prepend => Core.ElementPatchMode.Prepend, 35 | ElementPatchMode.Append => Core.ElementPatchMode.Append, 36 | ElementPatchMode.Before => Core.ElementPatchMode.Before, 37 | ElementPatchMode.After => Core.ElementPatchMode.After, 38 | ElementPatchMode.Remove => Core.ElementPatchMode.Remove, 39 | ElementPatchMode.Replace => Core.ElementPatchMode.Replace, 40 | _ => throw new ArgumentOutOfRangeException(nameof(patchElementsMode), patchElementsMode, null) 41 | }; 42 | 43 | static Core.PatchElementNamespace FromNs(PatchElementNamespace patchElementNamespace) => patchElementNamespace switch 44 | { 45 | PatchElementNamespace.Html => Core.PatchElementNamespace.Html, 46 | PatchElementNamespace.Svg => Core.PatchElementNamespace.Svg, 47 | PatchElementNamespace.MathMl => Core.PatchElementNamespace.MathMl, 48 | _ => throw new ArgumentOutOfRangeException(nameof(patchElementNamespace), patchElementNamespace, null) 49 | }; 50 | } 51 | } 52 | 53 | public class PatchSignalsOptions 54 | { 55 | public bool OnlyIfMissing { get; init; } = Consts.DefaultPatchSignalsOnlyIfMissing; 56 | public string? EventId { get; init; } = null; 57 | public TimeSpan Retry { get; init; } = Consts.DefaultSseRetryDuration; 58 | 59 | public static implicit operator Core.PatchSignalsOptions(PatchSignalsOptions options) => ToFSharp(options); 60 | public static implicit operator FSharpValueOption(PatchSignalsOptions options) => ToFSharp(options); 61 | 62 | private static Core.PatchSignalsOptions ToFSharp(PatchSignalsOptions options) => new( 63 | options.OnlyIfMissing, 64 | options.EventId ?? FSharpValueOption.ValueNone, 65 | options.Retry 66 | ); 67 | } 68 | 69 | public class RemoveElementOptions 70 | { 71 | public bool UseViewTransition { get; init; } = Consts.DefaultElementsUseViewTransitions; 72 | public string? EventId { get; init; } = null; 73 | public TimeSpan Retry { get; init; } = Consts.DefaultSseRetryDuration; 74 | 75 | public static implicit operator Core.RemoveElementOptions(RemoveElementOptions options) => ToFSharp(options); 76 | public static implicit operator FSharpValueOption(RemoveElementOptions options) => ToFSharp(options); 77 | 78 | private static Core.RemoveElementOptions ToFSharp(RemoveElementOptions options) 79 | { 80 | return new Core.RemoveElementOptions( 81 | options.UseViewTransition, 82 | options.EventId ?? FSharpValueOption.ValueNone, 83 | options.Retry); 84 | } 85 | } 86 | 87 | public class ExecuteScriptOptions 88 | { 89 | public string? EventId { get; init; } = null; 90 | public TimeSpan Retry { get; init; } = Consts.DefaultSseRetryDuration; 91 | public bool AutoRemove { get; init; } = Consts.DefaultElementsUseViewTransitions; 92 | public KeyValuePair[] Attributes { get; init; } = []; 93 | 94 | public static implicit operator Core.ExecuteScriptOptions(ExecuteScriptOptions options) => ToFSharp(options); 95 | public static implicit operator FSharpValueOption(ExecuteScriptOptions options) => ToFSharp(options); 96 | 97 | private static Core.ExecuteScriptOptions ToFSharp(ExecuteScriptOptions options) => new( 98 | options.EventId ?? FSharpValueOption.ValueNone, 99 | options.Retry, 100 | options.AutoRemove, 101 | options.Attributes.ToFSharpList() 102 | ); 103 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Datastar + dotnet 2 | 3 | [![NuGet Version](https://img.shields.io/nuget/v/Starfederation.Datastar.svg)](https://www.nuget.org/packages/Starfederation.Datastar) 4 | 5 | Real-time Hypermedia first Library and Framework for dotnet 6 | 7 | The dotnet Datastar library is written in two parts: 8 | - The F# library (StarFederation.Datastar.FSharp) implements the [Architecture Decision Record: Datastar SDK](https://github.com/starfederation/datastar/blob/develop/sdk/ADR.md) to provide the core, Datastar functionality. 9 | - The C# library (StarFederation.Datastar) uses the F# library for its core functionality as well as providing Dependency Injection, Model Binding, and C#-friendly types. 10 | 11 | # HTML Frontend 12 | 13 | ```html 14 |
15 | 16 |
17 |
18 | 19 | 20 |
21 | ``` 22 | 23 | # C# Backend 24 | 25 | ```csharp 26 | using StarFederation.Datastar; 27 | using StarFederation.Datastar.DependencyInjection; 28 | using System.Text.Json; 29 | using System.Text.Json.Serialization; 30 | 31 | // add as an ASP Service 32 | // allows injection of IDatastarService, to respond to a request with a Datastar friendly ServerSentEvent 33 | // and to read the signals sent by the client 34 | builder.Services.AddDatastar(); 35 | 36 | // displayDate - patching an element 37 | app.MapGet("/displayDate", async (IDatastarService datastarService) => 38 | { 39 | string today = DateTime.Now.ToString("%y-%M-%d %h:%m:%s"); 40 | await datastarService.PatchElementsAsync($"""
{today}
"""); 41 | }); 42 | 43 | // removeDate - removing an element 44 | app.MapGet("/removeDate", async (IDatastarService datastarService) => { await datastarService.RemoveElementAsync("#date"); }); 45 | 46 | public record MySignals { 47 | [JsonPropertyName("input")] 48 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 49 | public string? Input { get; init; } = null; 50 | 51 | [JsonPropertyName("output")] 52 | [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] 53 | public string? Output { get; init; } = null; 54 | 55 | public string Serialize() => ... 56 | } 57 | 58 | // changeOutput - reads the signals, update the Output, and merge back 59 | app.MapPost("/changeOutput", async (IDatastarService datastarService) => ... 60 | { 61 | MySignals signals = await datastarService.ReadSignalsAsync(); 62 | MySignals newSignals = new() { Output = $"Your Input: {signals.Input}" }; 63 | await datastarService.PatchSignalsAsync(newSignals.Serialize()); 64 | }); 65 | ``` 66 | 67 | # F# Backend 68 | 69 | ```fsharp 70 | namespace HelloWorld 71 | open System 72 | open System.Text.Json 73 | open System.Threading.Tasks 74 | open Microsoft.AspNetCore.Builder 75 | open Microsoft.AspNetCore.Http 76 | open Microsoft.Extensions.DependencyInjection 77 | open Microsoft.Extensions.Hosting 78 | open StarFederation.Datastar.FSharp 79 | 80 | module Program = 81 | let message = "Hello, world!" 82 | 83 | [] 84 | type MySignals = { input:string; output:string } 85 | 86 | [] 87 | let main args = 88 | 89 | let builder = WebApplication.CreateBuilder(args) 90 | builder.Services.AddHttpContextAccessor(); 91 | let app = builder.Build() 92 | app.UseStaticFiles() 93 | 94 | app.MapGet("/displayDate", Func(fun ctx -> task { 95 | do! ServerSentEventGenerator.StartServerEventStreamAsync(ctx.HttpContext.Response) 96 | let today = DateTime.Now.ToString("%y-%M-%d %h:%m:%s") 97 | do! ServerSentEventGenerator.PatchElementsAsync(ctx.HttpContext.Response, $"""
{today}
""") 98 | })) 99 | 100 | app.MapGet("/removeDate", Func(fun ctx -> task { 101 | do! ServerSentEventGenerator.StartServerEventStreamAsync(ctx.HttpContext.Response) 102 | do! ServerSentEventGenerator.RemoveElementAsync (ctx.HttpContext.Response, "#date") 103 | })) 104 | 105 | app.MapPost("/changeOutput", Func(fun ctx -> task { 106 | do! ServerSentEventGenerator.StartServerEventStreamAsync(ctx.HttpContext.Response) 107 | let! signals = ServerSentEventGenerator.ReadSignalsAsync(ctx.HttpContext.Request) 108 | let signals' = signals |> ValueOption.defaultValue { input = ""; output = "" } 109 | do! ServerSentEventGenerator.PatchSignalsAsync(ctx.HttpContext.Response, JsonSerializer.Serialize( { signals' with output = $"Your input: {signals'.input}" } )) 110 | })) 111 | 112 | app.Run() 113 | 114 | 0 115 | ``` 116 | 117 | # Model Binding 118 | 119 | ```csharp 120 | public class MySignals { 121 | public string myString { get; set; } = ""; 122 | public int myInt { get; set; } = 0; 123 | public InnerSignals myInner { get; set; } = new(); 124 | 125 | public class InnerSignals { 126 | public string myInnerString { get; set; } = ""; 127 | public int myInnerInt { get; set; } = 0; 128 | } 129 | } 130 | 131 | public IActionResult Test_GetSignals([FromSignals] MySignals signals) => ... 132 | 133 | public IActionResult Test_GetValues([FromSignals] string myString, [FromSignals] int myInt) => ... 134 | 135 | public IActionResult Test_GetInnerPathed([FromSignals(Path = "myInner")] MySignals.InnerSignals myInnerOther) => ... 136 | 137 | public IActionResult Test_GetInnerValues([FromSignals(Path = "myInner.myInnerString")] string myInnerStringOther, [FromSignals(Path = "myInner.myInnerInt")] int myInnerIntOther) => ... 138 | ``` -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | [Ss]amples/[Ss]andbox/ 13 | *.sqlite 14 | 15 | # User-specific files (MonoDevelop/Xamarin Studio) 16 | *.userprefs 17 | 18 | # Mono auto generated files 19 | mono_crash.* 20 | 21 | # Build results 22 | [Dd]ebug/ 23 | [Dd]ebugPublic/ 24 | [Rr]elease/ 25 | [Rr]eleases/ 26 | x64/ 27 | x86/ 28 | [Aa][Rr][Mm]/ 29 | [Aa][Rr][Mm]64/ 30 | bld/ 31 | [Bb]in/ 32 | [Oo]bj/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # StyleCop 67 | StyleCopReport.xml 68 | 69 | # Files built by Visual Studio 70 | *_i.c 71 | *_p.c 72 | *_h.h 73 | *.ilk 74 | *.meta 75 | *.obj 76 | *.iobj 77 | *.pch 78 | *.pdb 79 | *.ipdb 80 | *.pgc 81 | *.pgd 82 | *.rsp 83 | *.sbr 84 | *.tlb 85 | *.tli 86 | *.tlh 87 | *.tmp 88 | *.tmp_proj 89 | *_wpftmp.csproj 90 | *.log 91 | *.vspscc 92 | *.vssscc 93 | .builds 94 | *.pidb 95 | *.svclog 96 | *.scc 97 | 98 | # Chutzpah Test files 99 | _Chutzpah* 100 | 101 | # Visual C++ cache files 102 | ipch/ 103 | *.aps 104 | *.ncb 105 | *.opendb 106 | *.opensdf 107 | *.sdf 108 | *.cachefile 109 | *.VC.db 110 | *.VC.VC.opendb 111 | 112 | # Visual Studio profiler 113 | *.psess 114 | *.vsp 115 | *.vspx 116 | *.sap 117 | 118 | # Visual Studio Trace Files 119 | *.e2e 120 | 121 | # TFS 2012 Local Workspace 122 | $tf/ 123 | 124 | # Guidance Automation Toolkit 125 | *.gpState 126 | 127 | # ReSharper is a .NET coding add-in 128 | _ReSharper*/ 129 | *.[Rr]e[Ss]harper 130 | *.DotSettings.user 131 | 132 | # TeamCity is a build add-in 133 | _TeamCity* 134 | 135 | # DotCover is a Code Coverage Tool 136 | *.dotCover 137 | 138 | # AxoCover is a Code Coverage Tool 139 | .axoCover/* 140 | !.axoCover/settings.json 141 | 142 | # Visual Studio code coverage results 143 | *.coverage 144 | *.coveragexml 145 | 146 | # NCrunch 147 | _NCrunch_* 148 | .*crunch*.local.xml 149 | nCrunchTemp_* 150 | 151 | # MightyMoose 152 | *.mm.* 153 | AutoTest.Net/ 154 | 155 | # Web workbench (sass) 156 | .sass-cache/ 157 | 158 | # Installshield output folder 159 | [Ee]xpress/ 160 | 161 | # DocProject is a documentation generator add-in 162 | DocProject/buildhelp/ 163 | DocProject/Help/*.HxT 164 | DocProject/Help/*.HxC 165 | DocProject/Help/*.hhc 166 | DocProject/Help/*.hhk 167 | DocProject/Help/*.hhp 168 | DocProject/Help/Html2 169 | DocProject/Help/html 170 | 171 | # Click-Once directory 172 | publish/ 173 | 174 | # Publish Web Output 175 | *.[Pp]ublish.xml 176 | *.azurePubxml 177 | # Note: Comment the next line if you want to checkin your web deploy settings, 178 | # but database connection strings (with potential passwords) will be unencrypted 179 | *.pubxml 180 | *.publishproj 181 | 182 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 183 | # checkin your Azure Web App publish settings, but sensitive information contained 184 | # in these scripts will be unencrypted 185 | PublishScripts/ 186 | 187 | # NuGet Packages 188 | *.nupkg 189 | # NuGet Symbol Packages 190 | *.snupkg 191 | # The packages folder can be ignored because of Package Restore 192 | **/[Pp]ackages/* 193 | # except build/, which is used as an MSBuild target. 194 | !**/[Pp]ackages/build/ 195 | # Uncomment if necessary however generally it will be regenerated when needed 196 | #!**/[Pp]ackages/repositories.config 197 | # NuGet v3's project.json files produces more ignorable files 198 | *.nuget.props 199 | *.nuget.targets 200 | 201 | # Microsoft Azure Build Output 202 | csx/ 203 | *.build.csdef 204 | 205 | # Microsoft Azure Emulator 206 | ecf/ 207 | rcf/ 208 | 209 | # Windows Store app package directories and files 210 | AppPackages/ 211 | BundleArtifacts/ 212 | Package.StoreAssociation.xml 213 | _pkginfo.txt 214 | *.appx 215 | *.appxbundle 216 | *.appxupload 217 | 218 | # Visual Studio cache files 219 | # files ending in .cache can be ignored 220 | *.[Cc]ache 221 | # but keep track of directories ending in .cache 222 | !?*.[Cc]ache/ 223 | 224 | # Others 225 | ClientBin/ 226 | ~$* 227 | *~ 228 | *.dbmdl 229 | *.dbproj.schemaview 230 | *.jfm 231 | *.pfx 232 | *.publishsettings 233 | orleans.codegen.cs 234 | 235 | # Including strong name files can present a security risk 236 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 237 | #*.snk 238 | 239 | # Since there are multiple workflows, uncomment next line to ignore bower_components 240 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 241 | #bower_components/ 242 | 243 | # RIA/Silverlight projects 244 | Generated_Code/ 245 | 246 | # Backup & report files from converting an old project file 247 | # to a newer Visual Studio version. Backup files are not needed, 248 | # because we have git ;-) 249 | _UpgradeReport_Files/ 250 | Backup*/ 251 | UpgradeLog*.XML 252 | UpgradeLog*.htm 253 | ServiceFabricBackup/ 254 | *.rptproj.bak 255 | 256 | # SQL Server files 257 | *.mdf 258 | *.ldf 259 | *.ndf 260 | 261 | # Business Intelligence projects 262 | *.rdl.data 263 | *.bim.layout 264 | *.bim_*.settings 265 | *.rptproj.rsuser 266 | *- [Bb]ackup.rdl 267 | *- [Bb]ackup ([0-9]).rdl 268 | *- [Bb]ackup ([0-9][0-9]).rdl 269 | 270 | # Microsoft Fakes 271 | FakesAssemblies/ 272 | 273 | # GhostDoc plugin setting file 274 | *.GhostDoc.xml 275 | 276 | # Node.js Tools for Visual Studio 277 | .ntvs_analysis.dat 278 | node_modules/ 279 | 280 | # Visual Studio 6 build log 281 | *.plg 282 | 283 | # Visual Studio 6 workspace options file 284 | *.opt 285 | 286 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 287 | *.vbw 288 | 289 | # Visual Studio LightSwitch build output 290 | **/*.HTMLClient/GeneratedArtifacts 291 | **/*.DesktopClient/GeneratedArtifacts 292 | **/*.DesktopClient/ModelManifest.xml 293 | **/*.Server/GeneratedArtifacts 294 | **/*.Server/ModelManifest.xml 295 | _Pvt_Extensions 296 | 297 | # Paket dependency manager 298 | .paket/paket.exe 299 | paket-files/ 300 | 301 | # FAKE - F# Make 302 | .fake/ 303 | 304 | # CodeRush personal settings 305 | .cr/personal 306 | 307 | # Python Tools for Visual Studio (PTVS) 308 | __pycache__/ 309 | *.pyc 310 | 311 | # Cake - Uncomment if you are using it 312 | # tools/** 313 | # !tools/packages.config 314 | 315 | # Tabs Studio 316 | *.tss 317 | 318 | # Telerik's JustMock configuration file 319 | *.jmconfig 320 | 321 | # BizTalk build output 322 | *.btp.cs 323 | *.btm.cs 324 | *.odx.cs 325 | *.xsd.cs 326 | 327 | # OpenCover UI analysis results 328 | OpenCover/ 329 | 330 | # Azure Stream Analytics local run output 331 | ASALocalRun/ 332 | 333 | # MSBuild Binary and Structured Log 334 | *.binlog 335 | 336 | # NVidia Nsight GPU debugger configuration file 337 | *.nvuser 338 | 339 | # MFractors (Xamarin productivity tool) working folder 340 | .mfractor/ 341 | 342 | # Local History for Visual Studio 343 | .localhistory/ 344 | 345 | # BeatPulse healthcheck temp database 346 | healthchecksdb 347 | 348 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 349 | MigrationBackup/ 350 | 351 | # Ionide (cross platform F# VS Code tools) working folder 352 | .ionide/ 353 | .DS_Store 354 | 355 | # Rider (JetBrain's cross-platform .NET IDE) working folder 356 | .idea/ 357 | 358 | # Datastar Bundles copied 359 | **/wwwroot/bundles/ 360 | -------------------------------------------------------------------------------- /src/csharp/DependencyInjection/Services.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Microsoft.Extensions.Primitives; 3 | using Core = StarFederation.Datastar.FSharp; 4 | 5 | // ReSharper disable InvalidXmlDocComment 6 | 7 | namespace StarFederation.Datastar.DependencyInjection; 8 | 9 | public interface IDatastarService 10 | { 11 | /// Send a 200 text/event-stream Response. Implicit CancellationToken = HttpContext.RequestAborted. 12 | Task StartServerEventStreamAsync(); 13 | /// Send a 200 text/event-stream Response. 14 | Task StartServerEventStreamAsync(CancellationToken cancellationToken); 15 | /// Send a 200 text/event-stream Response. Implicit CancellationToken = HttpContext.RequestAborted. 16 | Task StartServerEventStreamAsync(IEnumerable> additionalHeaders); 17 | /// Send a 200 text/event-stream Response. Implicit CancellationToken = HttpContext.RequestAborted. 18 | Task StartServerEventStreamAsync(IEnumerable> additionalHeaders); 19 | /// Send a 200 text/event-stream Response. 20 | Task StartServerEventStreamAsync(IEnumerable> additionalHeaders, CancellationToken cancellationToken); 21 | /// Send a 200 text/event-stream Response. 22 | Task StartServerEventStreamAsync(IEnumerable> additionalHeaders, CancellationToken cancellationToken); 23 | 24 | /// Send an HTML element to the client for DOM manipulation. Implicit CancellationToken = HttpContext.RequestAborted. 25 | /// Complete, well-formed HTML elements 26 | Task PatchElementsAsync(string elements); 27 | /// Send an HTML element to the client for DOM manipulation. Implicit CancellationToken = HttpContext.RequestAborted. 28 | /// Complete, well-formed HTML elements 29 | Task PatchElementsAsync(string elements, PatchElementsOptions options); 30 | /// Send an HTML element to the client for DOM manipulation. 31 | /// Complete, well-formed HTML elements 32 | Task PatchElementsAsync(string elements, CancellationToken cancellationToken); 33 | /// Send an HTML element to the client for DOM manipulation. 34 | /// Complete, well-formed HTML elements 35 | Task PatchElementsAsync(string elements, PatchElementsOptions options, CancellationToken cancellationToken); 36 | 37 | /// Remove an element from the DOM. Implicit CancellationToken = HttpContext.RequestAborted. 38 | Task RemoveElementAsync(string selector); 39 | /// Remove an element from the DOM. Implicit CancellationToken = HttpContext.RequestAborted. 40 | Task RemoveElementAsync(string selector, RemoveElementOptions options); 41 | /// Remove an element from the DOM. 42 | Task RemoveElementAsync(string selector, CancellationToken cancellationToken); 43 | /// Remove an element from the DOM. 44 | Task RemoveElementAsync(string selector, RemoveElementOptions options, CancellationToken cancellationToken); 45 | 46 | /// Update signals on the browser. Implicit CancellationToken = HttpContext.RequestAborted. 47 | Task PatchSignalsAsync(TType signals); 48 | /// Update signals on the browser. Uses JsonSerializer.Serialize() to convert TType to JSON 49 | Task PatchSignalsAsync(TType signals, CancellationToken cancellationToken); 50 | /// Update signals on the browser. Uses JsonSerializer.Serialize() to convert TType to JSON. Implicit CancellationToken = HttpContext.RequestAborted. 51 | Task PatchSignalsAsync(TType signals, JsonSerializerOptions jsonSerializerOptions); 52 | /// Update signals on the browser. Uses JsonSerializer.Serialize() to convert TType to JSON. 53 | Task PatchSignalsAsync(TType signals, JsonSerializerOptions jsonSerializerOptions, CancellationToken cancellationToken); 54 | /// Update signals on the browser. Uses JsonSerializer.Serialize() to convert TType to JSON. Implicit CancellationToken = HttpContext.RequestAborted. 55 | Task PatchSignalsAsync(TType signals, PatchSignalsOptions patchSignalsOptions); 56 | /// Update signals on the browser. Uses JsonSerializer.Serialize() to convert TType to JSON. 57 | Task PatchSignalsAsync(TType signals, PatchSignalsOptions patchSignalsOptions, CancellationToken cancellationToken); 58 | /// Update signals on the browser. Uses JsonSerializer.Serialize() to convert TType to JSON. Implicit CancellationToken = HttpContext.RequestAborted. 59 | Task PatchSignalsAsync(TType signals, JsonSerializerOptions jsonSerializerOptions, PatchSignalsOptions patchSignalsOptions); 60 | /// Update signals on the browser. Uses JsonSerializer.Serialize() to convert TType to JSON. 61 | Task PatchSignalsAsync(TType signals, JsonSerializerOptions jsonSerializerOptions, PatchSignalsOptions patchSignalsOptions, CancellationToken cancellationToken); 62 | 63 | /// Execute a Javascript snippet on the browser. Implicit CancellationToken = HttpContext.RequestAborted. 64 | /// JS snippet; do not include <script> in string 65 | Task ExecuteScriptAsync(string script); 66 | /// Execute a Javascript snippet on the browser. 67 | /// JS snippet; do not include <script> in string 68 | Task ExecuteScriptAsync(string script, CancellationToken cancellationToken); 69 | /// Execute a Javascript snippet on the browser. Implicit CancellationToken = HttpContext.RequestAborted. 70 | /// JS snippet; do not include <script> in string 71 | Task ExecuteScriptAsync(string script, ExecuteScriptOptions options); 72 | /// Execute a Javascript snippet on the browser. 73 | /// JS snippet; do not include <script> in string 74 | Task ExecuteScriptAsync(string script, ExecuteScriptOptions options, CancellationToken cancellationToken); 75 | 76 | /// Gets a stream to the signals in JSON format from the Request. For GET requests, a MemoryStream backing will be created. 77 | Stream GetSignalsStream(); 78 | 79 | /// Gets the signals in JSON format from the Request. Implicit CancellationToken = HttpContext.RequestAborted. 80 | /// JSON string or null, if none 81 | Task ReadSignalsAsync(); 82 | /// Gets the signals in JSON format from the Request. 83 | /// JSON string or null, if none 84 | Task ReadSignalsAsync(CancellationToken cancellationToken); 85 | 86 | /// Gets the signals from the Request. Uses JsonSerializer.Deserialize to parse JSON signals, with PropertyNameCaseInsensitive = true. Implicit CancellationToken = HttpContext.RequestAborted. 87 | /// TType or null, if none 88 | Task ReadSignalsAsync(); 89 | /// Gets the signals from the Request. Uses JsonSerializer.Deserialize to parse JSON signals. Implicit CancellationToken = HttpContext.RequestAborted. 90 | /// TType or null, if none 91 | Task ReadSignalsAsync(JsonSerializerOptions options); 92 | /// Gets the signals from the Request. Uses JsonSerializer.Deserialize to parse JSON signals, with PropertyNameCaseInsensitive = true. 93 | /// TType or null, if none 94 | Task ReadSignalsAsync(CancellationToken cancellationToken); 95 | /// Gets the signals from the Request. Uses JsonSerializer.Deserialize to parse JSON signals. 96 | /// TType or null, if none 97 | Task ReadSignalsAsync(JsonSerializerOptions options, CancellationToken cancellationToken); 98 | } 99 | 100 | internal class DatastarService(Core.ServerSentEventGenerator serverSentEventGenerator) : IDatastarService 101 | { 102 | public Task StartServerEventStreamAsync() 103 | => serverSentEventGenerator.StartServerEventStreamAsync(); 104 | 105 | public Task StartServerEventStreamAsync(CancellationToken cancellationToken) 106 | => serverSentEventGenerator.StartServerEventStreamAsync(cancellationToken); 107 | 108 | public Task StartServerEventStreamAsync(IEnumerable> additionalHeaders) 109 | => serverSentEventGenerator.StartServerEventStreamAsync(additionalHeaders); 110 | 111 | public Task StartServerEventStreamAsync(IEnumerable> additionalHeaders) 112 | => serverSentEventGenerator.StartServerEventStreamAsync(additionalHeaders.Select(kv => new KeyValuePair(kv.Key, new(kv.Value)))); 113 | 114 | public Task StartServerEventStreamAsync(IEnumerable> additionalHeaders, CancellationToken cancellationToken) 115 | => serverSentEventGenerator.StartServerEventStreamAsync(additionalHeaders, cancellationToken); 116 | 117 | public Task StartServerEventStreamAsync(IEnumerable> additionalHeaders, CancellationToken cancellationToken) 118 | => serverSentEventGenerator.StartServerEventStreamAsync(additionalHeaders.Select(kv => new KeyValuePair(kv.Key, new(kv.Value))), cancellationToken); 119 | 120 | public Task PatchElementsAsync(string elements) 121 | => serverSentEventGenerator.PatchElementsAsync(elements); 122 | public Task PatchElementsAsync(string elements, PatchElementsOptions options) 123 | => serverSentEventGenerator.PatchElementsAsync(elements, options); 124 | public Task PatchElementsAsync(string elements, CancellationToken cancellationToken) 125 | => serverSentEventGenerator.PatchElementsAsync(elements, cancellationToken); 126 | public Task PatchElementsAsync(string elements, PatchElementsOptions options, CancellationToken cancellationToken) 127 | => serverSentEventGenerator.PatchElementsAsync(elements, options, cancellationToken); 128 | 129 | public Task RemoveElementAsync(string selector) 130 | => serverSentEventGenerator.RemoveElementAsync(selector); 131 | public Task RemoveElementAsync(string selector, RemoveElementOptions options) 132 | => serverSentEventGenerator.RemoveElementAsync(selector, options); 133 | public Task RemoveElementAsync(string selector, CancellationToken cancellationToken) 134 | => serverSentEventGenerator.RemoveElementAsync(selector, cancellationToken); 135 | public Task RemoveElementAsync(string selector, RemoveElementOptions options, CancellationToken cancellationToken) 136 | => serverSentEventGenerator.RemoveElementAsync(selector, options, cancellationToken); 137 | 138 | public Task PatchSignalsAsync(TType signals) 139 | => serverSentEventGenerator.PatchSignalsAsync(JsonSerializer.Serialize(signals, Core.JsonSerializerOptions.SignalsDefault)); 140 | public Task PatchSignalsAsync(TType signals, CancellationToken cancellationToken) 141 | => serverSentEventGenerator.PatchSignalsAsync(JsonSerializer.Serialize(signals, Core.JsonSerializerOptions.SignalsDefault), cancellationToken); 142 | public Task PatchSignalsAsync(TType signals, JsonSerializerOptions jsonSerializerOptions) 143 | => serverSentEventGenerator.PatchSignalsAsync(JsonSerializer.Serialize(signals, jsonSerializerOptions)); 144 | public Task PatchSignalsAsync(TType signals, JsonSerializerOptions jsonSerializerOptions, CancellationToken cancellationToken) 145 | => serverSentEventGenerator.PatchSignalsAsync(JsonSerializer.Serialize(signals, jsonSerializerOptions), cancellationToken); 146 | public Task PatchSignalsAsync(TType signals, PatchSignalsOptions patchSignalsOptions) 147 | => serverSentEventGenerator.PatchSignalsAsync(JsonSerializer.Serialize(signals, Core.JsonSerializerOptions.SignalsDefault), patchSignalsOptions); 148 | public Task PatchSignalsAsync(TType signals, PatchSignalsOptions patchSignalsOptions, CancellationToken cancellationToken) 149 | => serverSentEventGenerator.PatchSignalsAsync(JsonSerializer.Serialize(signals, Core.JsonSerializerOptions.SignalsDefault), patchSignalsOptions, cancellationToken); 150 | public Task PatchSignalsAsync(TType signals, JsonSerializerOptions jsonSerializerOptions, PatchSignalsOptions patchSignalsOptions) 151 | => serverSentEventGenerator.PatchSignalsAsync(JsonSerializer.Serialize(signals, jsonSerializerOptions), patchSignalsOptions); 152 | public Task PatchSignalsAsync(TType signals, JsonSerializerOptions jsonSerializerOptions, PatchSignalsOptions patchSignalsOptions, CancellationToken cancellationToken) 153 | => serverSentEventGenerator.PatchSignalsAsync(JsonSerializer.Serialize(signals, jsonSerializerOptions), patchSignalsOptions, cancellationToken); 154 | 155 | public Task ExecuteScriptAsync(string script) 156 | => serverSentEventGenerator.ExecuteScriptAsync(script); 157 | public Task ExecuteScriptAsync(string script, CancellationToken cancellationToken) 158 | => serverSentEventGenerator.ExecuteScriptAsync(script, cancellationToken); 159 | public Task ExecuteScriptAsync(string script, ExecuteScriptOptions options) 160 | => serverSentEventGenerator.ExecuteScriptAsync(script, options); 161 | public Task ExecuteScriptAsync(string script, ExecuteScriptOptions options, CancellationToken cancellationToken) 162 | => serverSentEventGenerator.ExecuteScriptAsync(script, options, cancellationToken); 163 | 164 | public Stream GetSignalsStream() 165 | => serverSentEventGenerator.GetSignalsStream(); 166 | 167 | public async Task ReadSignalsAsync() 168 | => await serverSentEventGenerator.ReadSignalsAsync() is { Length: > 0 } signals ? signals : null; 169 | public async Task ReadSignalsAsync(CancellationToken cancellationToken) 170 | => await serverSentEventGenerator.ReadSignalsAsync(cancellationToken) is { Length: > 0 } signals ? signals : null; 171 | 172 | public async Task ReadSignalsAsync() 173 | => await serverSentEventGenerator.ReadSignalsAsync() is { Length: > 0 } signals ? JsonSerializer.Deserialize(signals, Core.JsonSerializerOptions.SignalsDefault) : default; 174 | public async Task ReadSignalsAsync(JsonSerializerOptions options) 175 | => await serverSentEventGenerator.ReadSignalsAsync() is { Length: > 0 } signals ? JsonSerializer.Deserialize(signals, options) : default; 176 | public async Task ReadSignalsAsync(CancellationToken cancellationToken) 177 | => await serverSentEventGenerator.ReadSignalsAsync(cancellationToken) is { Length: > 0 } signals ? JsonSerializer.Deserialize(signals, Core.JsonSerializerOptions.SignalsDefault) : default; 178 | public async Task ReadSignalsAsync(JsonSerializerOptions options, CancellationToken cancellationToken) 179 | => await serverSentEventGenerator.ReadSignalsAsync(cancellationToken) is { Length: > 0 } signals ? JsonSerializer.Deserialize(signals, options) : default; 180 | } -------------------------------------------------------------------------------- /src/fsharp/ServerSentEventGenerator.fs: -------------------------------------------------------------------------------- 1 | namespace StarFederation.Datastar.FSharp 2 | 3 | open System.Collections.Concurrent 4 | open System.Collections.Generic 5 | open System.IO 6 | open System.Text 7 | open System.Text.Json 8 | open System.Threading 9 | open System.Threading.Tasks 10 | open System.Web 11 | open Microsoft.AspNetCore.Http 12 | open Microsoft.Extensions.Primitives 13 | open StarFederation.Datastar.FSharp.Utility 14 | 15 | [] 16 | type ServerSentEventGenerator(httpContextAccessor:IHttpContextAccessor) = 17 | let defaultCancellationToken = httpContextAccessor.HttpContext.RequestAborted 18 | let httpRequest = httpContextAccessor.HttpContext.Request 19 | let httpResponse = httpContextAccessor.HttpContext.Response 20 | let mutable _startResponseTask : Task = null 21 | let _startResponseLock = obj() 22 | let _eventQueue = ConcurrentQueue Task>() 23 | 24 | static member StartServerEventStreamAsync(httpResponse:HttpResponse, additionalHeaders:KeyValuePair seq, cancellationToken:CancellationToken) = 25 | let task = backgroundTask { 26 | httpResponse.Headers.ContentType <- "text/event-stream" 27 | httpResponse.Headers.CacheControl <- "no-cache" 28 | 29 | if (httpResponse.HttpContext.Request.Protocol = HttpProtocol.Http11) then 30 | httpResponse.Headers.Connection <- "keep-alive" 31 | 32 | for KeyValue(name, content) in additionalHeaders do 33 | match httpResponse.Headers.TryGetValue(name) with 34 | | false, _ -> httpResponse.Headers.Add(name, content) 35 | | true, _ -> () 36 | 37 | do! httpResponse.StartAsync(cancellationToken) 38 | return! httpResponse.BodyWriter.FlushAsync(cancellationToken) 39 | } 40 | task :> Task 41 | 42 | static member PatchElementsAsync(httpResponse:HttpResponse, elements:string, options:PatchElementsOptions, cancellationToken:CancellationToken) = 43 | let writer = httpResponse.BodyWriter 44 | writer |> ServerSentEvent.sendEventType Bytes.EventTypePatchElements 45 | options.EventId |> ValueOption.iter (fun eventId -> writer |> ServerSentEvent.sendEventId eventId) 46 | if options.Retry <> Consts.DefaultSseRetryDuration then writer |> ServerSentEvent.sendRetry options.Retry 47 | 48 | options.Selector |> ValueOption.iter (fun selector -> writer |> ServerSentEvent.sendDataStringLine Bytes.DatalineSelector selector) 49 | 50 | if options.PatchMode <> Consts.DefaultElementPatchMode then 51 | writer |> ServerSentEvent.sendDataBytesLine Bytes.DatalineMode (options.PatchMode |> Bytes.ElementPatchMode.toBytes) 52 | 53 | if options.UseViewTransition <> Consts.DefaultElementsUseViewTransitions then 54 | writer |> ServerSentEvent.sendDataBytesLine Bytes.DatalineUseViewTransition (if options.UseViewTransition then Bytes.bTrue else Bytes.bFalse) 55 | 56 | if options.Namespace <> Consts.DefaultPatchElementNamespace then 57 | writer |> ServerSentEvent.sendDataBytesLine Bytes.DatalineNamespace (options.Namespace |> Bytes.PatchElementNamespace.toBytes) 58 | 59 | for segment in String.splitLinesToSegments elements do 60 | writer |> ServerSentEvent.sendDataSegmentLine Bytes.DatalineElements segment 61 | 62 | writer |> ServerSentEvent.writeNewline 63 | 64 | writer.FlushAsync(cancellationToken).AsTask() :> Task 65 | 66 | static member RemoveElementAsync(httpResponse:HttpResponse, selector:Selector, options:RemoveElementOptions, cancellationToken:CancellationToken) = 67 | let writer = httpResponse.BodyWriter 68 | writer |> ServerSentEvent.sendEventType Bytes.EventTypePatchElements 69 | options.EventId |> ValueOption.iter (fun eventId -> writer |> ServerSentEvent.sendEventId eventId) 70 | if options.Retry <> Consts.DefaultSseRetryDuration then writer |> ServerSentEvent.sendRetry options.Retry 71 | 72 | writer |> ServerSentEvent.sendDataBytesLine Bytes.DatalineMode (ElementPatchMode.Remove |> Bytes.ElementPatchMode.toBytes) 73 | 74 | writer |> ServerSentEvent.sendDataStringLine Bytes.DatalineSelector selector 75 | 76 | if options.UseViewTransition <> Consts.DefaultElementsUseViewTransitions then 77 | writer |> ServerSentEvent.sendDataBytesLine Bytes.DatalineUseViewTransition (if options.UseViewTransition then Bytes.bTrue else Bytes.bFalse) 78 | 79 | writer |> ServerSentEvent.writeNewline 80 | 81 | writer.FlushAsync(cancellationToken).AsTask() :> Task 82 | 83 | static member PatchSignalsAsync(httpResponse:HttpResponse, signals:Signals, options:PatchSignalsOptions, cancellationToken:CancellationToken) = 84 | let writer = httpResponse.BodyWriter 85 | writer |> ServerSentEvent.sendEventType Bytes.EventTypePatchSignals 86 | options.EventId |> ValueOption.iter (fun eventId -> writer |> ServerSentEvent.sendEventId eventId) 87 | if options.Retry <> Consts.DefaultSseRetryDuration then writer |> ServerSentEvent.sendRetry options.Retry 88 | 89 | if options.OnlyIfMissing <> Consts.DefaultPatchSignalsOnlyIfMissing then 90 | writer |> ServerSentEvent.sendDataBytesLine Bytes.DatalineOnlyIfMissing (if options.OnlyIfMissing then Bytes.bTrue else Bytes.bFalse) 91 | 92 | for segment in String.splitLinesToSegments signals do 93 | writer |> ServerSentEvent.sendDataSegmentLine Bytes.DatalineSignals segment 94 | 95 | writer |> ServerSentEvent.writeNewline 96 | 97 | writer.FlushAsync(cancellationToken).AsTask() :> Task 98 | 99 | static member ExecuteScriptAsync(httpResponse:HttpResponse, script:string, options:ExecuteScriptOptions, cancellationToken:CancellationToken) = 100 | let writer = httpResponse.BodyWriter 101 | writer |> ServerSentEvent.sendEventType Bytes.EventTypePatchElements 102 | options.EventId |> ValueOption.iter (fun eventId -> writer |> ServerSentEvent.sendEventId eventId) 103 | if options.Retry <> Consts.DefaultSseRetryDuration then writer |> ServerSentEvent.sendRetry options.Retry 104 | 105 | writer |> ServerSentEvent.sendDataBytesLine Bytes.DatalineSelector Bytes.bBody 106 | 107 | writer |> ServerSentEvent.sendDataBytesLine Bytes.DatalineMode Bytes.ElementPatchMode.bAppend 108 | 109 | // 129 | writer |> ServerSentEvent.sendDataBytesLine Bytes.DatalineElements Bytes.bCloseScript 130 | 131 | writer |> ServerSentEvent.writeNewline 132 | 133 | writer.FlushAsync(cancellationToken).AsTask() :> Task 134 | 135 | static member GetSignalsStream(httpRequest:HttpRequest) = 136 | match httpRequest.Method with 137 | | System.Net.WebRequestMethods.Http.Get -> 138 | match httpRequest.Query.TryGetValue(Consts.DatastarKey) with 139 | | true, stringValues when stringValues.Count > 0 -> (new MemoryStream(Encoding.UTF8.GetBytes(stringValues[0])) :> Stream) 140 | | _ -> Stream.Null 141 | | _ -> httpRequest.Body 142 | 143 | static member ReadSignalsAsync(httpRequest:HttpRequest, cancellationToken:CancellationToken) = 144 | backgroundTask { 145 | match httpRequest.Method with 146 | | System.Net.WebRequestMethods.Http.Get -> 147 | match httpRequest.Query.TryGetValue(Consts.DatastarKey) with 148 | | true, stringValues when stringValues.Count > 0 -> return stringValues[0] 149 | | _ -> return Signals.empty 150 | | _ -> 151 | try 152 | use readResult = new StreamReader(httpRequest.Body) 153 | return! readResult.ReadToEndAsync(cancellationToken) 154 | with _ -> 155 | return Signals.empty 156 | } 157 | 158 | static member ReadSignalsAsync<'T>(httpRequest:HttpRequest, jsonSerializerOptions:JsonSerializerOptions, cancellationToken:CancellationToken) = 159 | task { 160 | try 161 | match httpRequest.Method with 162 | | System.Net.WebRequestMethods.Http.Get -> 163 | match httpRequest.Query.TryGetValue(Consts.DatastarKey) with 164 | | true, stringValues when stringValues.Count > 0 -> 165 | return ValueSome (JsonSerializer.Deserialize<'T>(stringValues[0], jsonSerializerOptions)) 166 | | _ -> 167 | return ValueNone 168 | | _ -> 169 | let! t = JsonSerializer.DeserializeAsync<'T>(httpRequest.Body, jsonSerializerOptions, cancellationToken) 170 | return (ValueSome t) 171 | with _ -> return ValueNone 172 | } 173 | 174 | member this.StartServerEventStreamAsync(additionalHeaders, cancellationToken) = 175 | lock _startResponseLock (fun () -> 176 | if _startResponseTask = null then 177 | _startResponseTask <- ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, additionalHeaders, cancellationToken) 178 | ) 179 | _startResponseTask 180 | 181 | member private this.SendEventAsync(sendEventTask:unit -> Task, cancellationToken:CancellationToken) = 182 | backgroundTask { 183 | _eventQueue.Enqueue(sendEventTask) 184 | do! 185 | if _startResponseTask <> null 186 | then _startResponseTask 187 | else this.StartServerEventStreamAsync(Seq.empty, cancellationToken) 188 | let (_, sendEventTask') = _eventQueue.TryDequeue() 189 | return! sendEventTask' () 190 | } 191 | 192 | member this.PatchElementsAsync(elements, options, cancellationToken) = 193 | let sendTask = fun () -> ServerSentEventGenerator.PatchElementsAsync(httpResponse, elements, options, cancellationToken) 194 | this.SendEventAsync(sendTask, cancellationToken) :> Task 195 | 196 | member this.RemoveElementAsync(selector, options, cancellationToken) = 197 | let sendTask = fun () -> ServerSentEventGenerator.RemoveElementAsync(httpResponse, selector, options, cancellationToken) 198 | this.SendEventAsync(sendTask, cancellationToken) :> Task 199 | 200 | member this.PatchSignalsAsync(signals, options, cancellationToken) = 201 | let sendTask = fun () -> ServerSentEventGenerator.PatchSignalsAsync(httpResponse, signals, options, cancellationToken) 202 | this.SendEventAsync(sendTask, cancellationToken) :> Task 203 | 204 | member this.ExecuteScriptAsync(script, options, cancellationToken) = 205 | let sendTask = fun () -> ServerSentEventGenerator.ExecuteScriptAsync(httpResponse, script, options, cancellationToken) 206 | this.SendEventAsync(sendTask, cancellationToken) :> Task 207 | 208 | member this.GetSignalsStream() = 209 | ServerSentEventGenerator.GetSignalsStream(httpRequest) 210 | 211 | member this.ReadSignalsAsync(cancellationToken) : Task = 212 | ServerSentEventGenerator.ReadSignalsAsync(httpRequest, cancellationToken) 213 | 214 | member this.ReadSignalsAsync<'T>(jsonSerializerOptions, cancellationToken) = 215 | ServerSentEventGenerator.ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions, cancellationToken) 216 | 217 | // 218 | // SHORT HAND METHODS 219 | // 220 | static member StartServerEventStreamAsync(httpResponse, additionalHeaders) = 221 | ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, additionalHeaders, httpResponse.HttpContext.RequestAborted) 222 | static member StartServerEventStreamAsync(httpResponse) = 223 | ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, Seq.empty, httpResponse.HttpContext.RequestAborted) 224 | static member StartServerEventStreamAsync(httpResponse, cancellationToken) = 225 | ServerSentEventGenerator.StartServerEventStreamAsync(httpResponse, Seq.empty, cancellationToken) 226 | static member PatchElementsAsync(httpResponse, elements, options) = 227 | ServerSentEventGenerator.PatchElementsAsync(httpResponse, elements, options, httpResponse.HttpContext.RequestAborted) 228 | static member PatchElementsAsync(httpResponse, elements) = 229 | ServerSentEventGenerator.PatchElementsAsync(httpResponse, elements, PatchElementsOptions.Defaults, httpResponse.HttpContext.RequestAborted) 230 | static member RemoveElementAsync(httpResponse, selector, options) = 231 | ServerSentEventGenerator.RemoveElementAsync(httpResponse, selector, options, httpResponse.HttpContext.RequestAborted) 232 | static member RemoveElementAsync(httpResponse, selector) = 233 | ServerSentEventGenerator.RemoveElementAsync(httpResponse, selector, RemoveElementOptions.Defaults, httpResponse.HttpContext.RequestAborted) 234 | static member PatchSignalsAsync(httpResponse, signals, options) = 235 | ServerSentEventGenerator.PatchSignalsAsync(httpResponse, signals, options, httpResponse.HttpContext.RequestAborted) 236 | static member PatchSignalsAsync(httpResponse, signals) = 237 | ServerSentEventGenerator.PatchSignalsAsync(httpResponse, signals, PatchSignalsOptions.Defaults, httpResponse.HttpContext.RequestAborted) 238 | static member ExecuteScriptAsync(httpResponse, script, options) = 239 | ServerSentEventGenerator.ExecuteScriptAsync(httpResponse, script, options, httpResponse.HttpContext.RequestAborted) 240 | static member ExecuteScriptAsync(httpResponse, script) = 241 | ServerSentEventGenerator.ExecuteScriptAsync(httpResponse, script, ExecuteScriptOptions.Defaults, httpResponse.HttpContext.RequestAborted) 242 | static member ReadSignalsAsync(httpRequest) = 243 | ServerSentEventGenerator.ReadSignalsAsync(httpRequest, cancellationToken = httpRequest.HttpContext.RequestAborted) 244 | static member ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions) = 245 | ServerSentEventGenerator.ReadSignalsAsync<'T>(httpRequest, jsonSerializerOptions, httpRequest.HttpContext.RequestAborted) 246 | static member ReadSignalsAsync<'T>(httpRequest) = 247 | ServerSentEventGenerator.ReadSignalsAsync<'T>(httpRequest, JsonSerializerOptions.SignalsDefault, httpRequest.HttpContext.RequestAborted) 248 | 249 | member this.StartServerEventStreamAsync(additionalHeaders) = 250 | this.StartServerEventStreamAsync(additionalHeaders, defaultCancellationToken) 251 | member this.StartServerEventStreamAsync(cancellationToken: CancellationToken) = 252 | this.StartServerEventStreamAsync(Seq.empty, cancellationToken) 253 | member this.StartServerEventStreamAsync() = 254 | this.StartServerEventStreamAsync(Seq.empty, defaultCancellationToken) 255 | member this.PatchElementsAsync(elements, options) = 256 | this.PatchElementsAsync(elements, options, defaultCancellationToken) 257 | member this.PatchElementsAsync(elements, cancellationToken) = 258 | this.PatchElementsAsync(elements, PatchElementsOptions.Defaults, cancellationToken) 259 | member this.PatchElementsAsync(elements) = 260 | this.PatchElementsAsync(elements, PatchElementsOptions.Defaults, defaultCancellationToken) 261 | member this.RemoveElementAsync(selector, options) = 262 | this.RemoveElementAsync(selector, options, defaultCancellationToken) 263 | member this.RemoveElementAsync(selector, cancellationToken) = 264 | this.RemoveElementAsync(selector, RemoveElementOptions.Defaults, cancellationToken) 265 | member this.RemoveElementAsync(selector) = 266 | this.RemoveElementAsync(selector, RemoveElementOptions.Defaults, defaultCancellationToken) 267 | member this.PatchSignalsAsync(signals, options) = 268 | this.PatchSignalsAsync(signals, options, defaultCancellationToken) 269 | member this.PatchSignalsAsync(signals, cancellationToken) = 270 | this.PatchSignalsAsync(signals, PatchSignalsOptions.Defaults, cancellationToken) 271 | member this.PatchSignalsAsync(signals) = 272 | this.PatchSignalsAsync(signals, PatchSignalsOptions.Defaults, defaultCancellationToken) 273 | member this.ExecuteScriptAsync(script, options) = 274 | this.ExecuteScriptAsync(script, options, defaultCancellationToken) 275 | member this.ExecuteScriptAsync(script, cancellationToken) = 276 | this.ExecuteScriptAsync(script, ExecuteScriptOptions.Defaults, cancellationToken) 277 | member this.ExecuteScriptAsync(script) = 278 | this.ExecuteScriptAsync(script, ExecuteScriptOptions.Defaults, defaultCancellationToken) 279 | member this.ReadSignalsAsync() : Task = 280 | this.ReadSignalsAsync(httpRequest.HttpContext.RequestAborted) 281 | member this.ReadSignalsAsync<'T>(jsonSerializerOptions) = 282 | this.ReadSignalsAsync<'T>(jsonSerializerOptions, httpRequest.HttpContext.RequestAborted) 283 | member this.ReadSignalsAsync<'T>(cancellationToken) = 284 | this.ReadSignalsAsync<'T>(JsonSerializerOptions.SignalsDefault, cancellationToken) 285 | member this.ReadSignalsAsync<'T>() = 286 | this.ReadSignalsAsync<'T>(JsonSerializerOptions.SignalsDefault, httpRequest.HttpContext.RequestAborted) 287 | --------------------------------------------------------------------------------