using System.Text.Json; using Microsoft.AspNetCore.Mvc; using w4c_workflows.Services; namespace w4c_workflows.Controllers; /// /// Tenant-scoped server-sent-events stream: GET /api/workflows/events pushes /// data: {"kind":"workflow","id":"","at":""} frames whenever a workflow file is /// written server-side. The client re-fetches the touched file; no payload is pushed. /// The full literal route avoids colliding with api/workflows/{id} and routes to this service /// through the SPA proxy (/api/workflows prefix). /// [ApiController] [Route("api/workflows/events")] public class RealtimeController : ControllerBase { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(15); private readonly RealtimeEventHub _hub; private readonly ILogger _logger; public RealtimeController(RealtimeEventHub hub, ILogger logger) { _hub = hub; _logger = logger; } private string TenantId => (string?)HttpContext.Items["TenantId"] ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); [HttpGet] public async Task Stream(CancellationToken ct) { var tid = TenantId; Response.Headers["Content-Type"] = "text/event-stream"; Response.Headers["Cache-Control"] = "no-cache"; Response.Headers["Connection"] = "keep-alive"; Response.Headers["X-Accel-Buffering"] = "no"; await Response.Body.FlushAsync(ct); var (id, reader) = _hub.AddClient(tid); try { while (!ct.IsCancellationRequested) { using var readCts = CancellationTokenSource.CreateLinkedTokenSource(ct); readCts.CancelAfter(HeartbeatInterval); bool hasItem; try { hasItem = await reader.WaitToReadAsync(readCts.Token); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { await Response.WriteAsync(": ping\n\n", ct); await Response.Body.FlushAsync(ct); continue; } if (!hasItem) break; while (reader.TryRead(out var evt)) { var json = JsonSerializer.Serialize(evt, JsonOptions); await Response.WriteAsync($"data: {json}\n\n", ct); } await Response.Body.FlushAsync(ct); } } catch (OperationCanceledException) { // Client disconnected — normal terminal state. } catch (Exception ex) { _logger.LogWarning(ex, "Realtime SSE stream ended for tenant {Tenant}", tid); } finally { _hub.RemoveClient(tid, id); } } }