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))"
    }
    }
}