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