88 lines
3 KiB
C#
88 lines
3 KiB
C#
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|