Classes/RealtimeSink.ps1
|
# * SignalR .On handlers fire on arbitrary threads where a PowerShell ScriptBlock has # no runspace. So the bridge is a COMPILED delegate that only enqueues into a # thread-safe BlockingCollection; the PowerShell Receive-* cmdlets drain it. This is # the single trick that makes SignalR usable from PowerShell. if (-not ('MyWebApi.RealtimeSink' -as [type])) { # * The real-time layer is OPTIONAL: a bare REST-only import (no scripts/restore-lib.sh # run, lib/ empty or absent) must still succeed. Compiling this type unconditionally # would make Add-Type throw FileNotFoundException the moment the SignalR client DLLs # are missing, which would fail the WHOLE module import -- not just realtime cmdlets. # So: check the DLLs exist on disk first, and silently skip compilation if not. Callers # that actually need realtime (Open-MyWebApiRealtimeConnection) check for the type's # presence themselves and throw a friendly error instead of a cryptic "type not found". $libDir = Join-Path $PSScriptRoot '..' 'lib' $signalRRefs = @( (Join-Path $libDir 'Microsoft.AspNetCore.SignalR.Client.Core.dll'), (Join-Path $libDir 'Microsoft.AspNetCore.SignalR.Client.dll') ) $signalRRefsPresent = $true foreach ($ref in $signalRRefs) { if (-not (Test-Path $ref)) { $signalRRefsPresent = $false; break } } if ($signalRRefsPresent) { # * Add-Type's default reference set (a fixed curated list, not "everything currently # loaded") does not include System.Collections.Concurrent / System.Text.Json, so the C# # below fails with CS0234/CS0246 unless we pass them explicitly. (Task / TimeSpan / object # etc. resolve fine without extra refs -- on .NET Core those live inside # System.Private.CoreLib, which Add-Type always references implicitly.) # Pass these two by SIMPLE NAME, not by full file path: they are already loaded in the # pwsh process (forced above via [void][Type]), so Add-Type resolves the name against the # already-loaded assembly. Passing the resolved -Location path instead was tried and # broke compilation with CS0012 "Object is not referenced" on every basic type -- passing # an already-implicitly-referenced framework assembly's file path a second time makes # Roslyn stop recognizing the implicit corlib reference. Simple names avoid that entirely. [void][System.Collections.Concurrent.BlockingCollection[object]] [void][System.Text.Json.JsonElement] $frameworkRefs = @( 'System.Collections.Concurrent', 'System.Text.Json' ) # * try/catch + nowarn:1701/1702 -- the bundled SignalR client is built for net8; on a pwsh # whose runtime System.* identity differs (e.g. .NET 10) Roslyn reports CS1701/CS1702 # "assuming assembly reference matches" and Add-Type surfaces it as a compile failure. # Suppress those version-mismatch diagnostics, and if compilation still fails, degrade # gracefully (REST keeps working; Open-MyWebApiRealtimeConnection throws a clear error). try { Add-Type -CompilerOptions '-nowarn:1701,1702' -ReferencedAssemblies ($frameworkRefs + $signalRRefs) -TypeDefinition @' using System; using System.Collections.Concurrent; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; namespace MyWebApi { public sealed class RealtimeSink : IDisposable { private readonly BlockingCollection<object> _queue = new BlockingCollection<object>(); private readonly CancellationTokenSource _cts = new CancellationTokenSource(); public HubConnection Connection { get; } public RealtimeSink(HubConnection connection) { Connection = connection; } // Register a server->client callback (OnConnectionStatus, OnTick): enqueue the raw JSON. public void On(string method) { Connection.On<JsonElement>(method, arg => { try { _queue.Add(new PayloadEnvelope { Method = method, Json = arg.GetRawText() }); } catch { /* queue completed/disposed during shutdown - drop late callback */ } }); } // Consume a server-streaming hub method on a background task, funneling items into the queue. public void StartStream(string method) { _ = Task.Run(async () => { try { await foreach (var item in Connection.StreamAsync<JsonElement>(method, _cts.Token)) { try { _queue.Add(new PayloadEnvelope { Method = method, Json = item.GetRawText() }); } catch { break; } } } catch (OperationCanceledException) { /* normal on disconnect */ } catch { /* stream ended / connection closed */ } }); } public bool TryTake(out object item, int timeoutMs) => _queue.TryTake(out item, timeoutMs); public Task StartAsync() => Connection.StartAsync(); public async Task StopAsync() { _cts.Cancel(); try { await Connection.StopAsync(); } catch { } try { await Connection.DisposeAsync(); } catch { } _queue.CompleteAdding(); } public void Dispose() { try { _cts.Cancel(); } catch { } _cts.Dispose(); _queue.Dispose(); } } public sealed class PayloadEnvelope { public string Method { get; set; } = ""; public string Json { get; set; } = ""; } } '@ } catch { Write-Verbose "MyWebApi: real-time layer unavailable on this runtime ($($_.Exception.Message))" } } } |