w4c-workflows-api/Controllers/RealtimeController.cs

88 lines
3 KiB
C#
Raw Normal View History

2026-09-07 19:05:15 +00:00
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using w4c_workflows.Services;
namespace w4c_workflows.Controllers;
/// <summary>
/// Tenant-scoped server-sent-events stream: <c>GET /api/workflows/events</c> pushes
/// <c>data: {"kind":"workflow","id":"<path>","at":"<iso>"}</c> 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 <c>api/workflows/{id}</c> and routes to this service
/// through the SPA proxy (<c>/api/workflows</c> prefix).
/// </summary>
[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<RealtimeController> _logger;
public RealtimeController(RealtimeEventHub hub, ILogger<RealtimeController> 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);
}
}
}