154 lines
6.1 KiB
C#
154 lines
6.1 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Nexus.Api.Models;
|
|
using Nexus.Api.Services;
|
|
|
|
namespace Nexus.Api.Controllers;
|
|
|
|
/// <summary>
|
|
/// Authenticated browser-safe Server-Sent Events projection over the bounded
|
|
/// Gateway event buffer. The browser never receives Gateway credentials.
|
|
/// </summary>
|
|
[Authorize]
|
|
[ApiController]
|
|
[Route("api/v1/openclaw/events")]
|
|
public sealed class OpenClawEventsController(IOpenClawEventProjectionService events) : ControllerBase
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(750);
|
|
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(15);
|
|
|
|
[HttpGet]
|
|
public async Task Stream(
|
|
[FromQuery] string? lastEventId = null,
|
|
[FromQuery] bool follow = true,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
Response.StatusCode = StatusCodes.Status200OK;
|
|
Response.ContentType = "text/event-stream";
|
|
Response.Headers.CacheControl = "no-cache, no-store";
|
|
Response.Headers.Connection = "keep-alive";
|
|
Response.Headers["X-Accel-Buffering"] = "no";
|
|
|
|
var headerCursor = Request.Headers["Last-Event-ID"].FirstOrDefault();
|
|
var cursor = string.IsNullOrWhiteSpace(headerCursor) ? lastEventId : headerCursor;
|
|
var lastHeartbeatAt = DateTimeOffset.UtcNow;
|
|
|
|
var connectionEvent = events.CreateConnectionEvent(cursor);
|
|
var connectionSignature = GetConnectionSignature(connectionEvent);
|
|
await Response.WriteAsync("retry: 2000\n\n", cancellationToken);
|
|
await WriteEventAsync(connectionEvent, cancellationToken);
|
|
await Response.Body.FlushAsync(cancellationToken);
|
|
|
|
try
|
|
{
|
|
do
|
|
{
|
|
connectionEvent = events.CreateConnectionEvent(cursor);
|
|
var currentConnectionSignature = GetConnectionSignature(connectionEvent);
|
|
if (!string.Equals(
|
|
connectionSignature,
|
|
currentConnectionSignature,
|
|
StringComparison.Ordinal))
|
|
{
|
|
await WriteEventAsync(connectionEvent, cancellationToken);
|
|
connectionSignature = currentConnectionSignature;
|
|
}
|
|
|
|
var batch = events.Project(cursor);
|
|
if (batch.ReplayBoundaryMissed)
|
|
{
|
|
await WriteEventAsync(CreateGapEvent(cursor, batch), cancellationToken);
|
|
}
|
|
|
|
foreach (var item in batch.Events)
|
|
{
|
|
await WriteEventAsync(item, cancellationToken);
|
|
}
|
|
|
|
cursor = batch.Cursor ?? cursor;
|
|
var now = DateTimeOffset.UtcNow;
|
|
if (!follow || now - lastHeartbeatAt >= HeartbeatInterval)
|
|
{
|
|
await WriteEventAsync(events.CreateHeartbeatEvent(cursor), cancellationToken);
|
|
lastHeartbeatAt = now;
|
|
}
|
|
|
|
await Response.Body.FlushAsync(cancellationToken);
|
|
if (!follow)
|
|
break;
|
|
|
|
await Task.Delay(PollInterval, cancellationToken);
|
|
} while (!cancellationToken.IsCancellationRequested);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
// Expected when EventSource disconnects or the request is aborted.
|
|
}
|
|
}
|
|
|
|
private async Task WriteEventAsync(
|
|
OpenClawStreamEventDto item,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var id = StripSseControlCharacters(item.Id);
|
|
var eventType = StripSseControlCharacters(item.Type);
|
|
var data = JsonSerializer.Serialize(item, JsonOptions);
|
|
await Response.WriteAsync(
|
|
$"id: {id}\nevent: {eventType}\ndata: {data}\n\n",
|
|
cancellationToken);
|
|
}
|
|
|
|
private static OpenClawStreamEventDto CreateGapEvent(
|
|
string? requestedCursor,
|
|
OpenClawEventBatch batch)
|
|
{
|
|
var occurredAt = DateTimeOffset.UtcNow;
|
|
var gapCursor = batch.Events.Count == 0
|
|
? batch.Cursor ?? "origin"
|
|
: string.IsNullOrWhiteSpace(requestedCursor) ? "origin" : requestedCursor;
|
|
return new OpenClawStreamEventDto(
|
|
gapCursor,
|
|
"openclaw.gap",
|
|
"gap",
|
|
"gateway",
|
|
null,
|
|
null,
|
|
null,
|
|
true,
|
|
false,
|
|
null,
|
|
null,
|
|
occurredAt,
|
|
new JsonObject
|
|
{
|
|
["reason"] = "last-event-id-outside-buffer",
|
|
["requestedLastEventId"] = requestedCursor,
|
|
["oldestAvailableId"] = batch.OldestAvailableId,
|
|
["latestAvailableId"] = batch.LatestAvailableId,
|
|
["requiresAuthoritativeRefresh"] = true
|
|
});
|
|
}
|
|
|
|
private static string StripSseControlCharacters(string value)
|
|
=> value.Replace("\r", string.Empty, StringComparison.Ordinal)
|
|
.Replace("\n", string.Empty, StringComparison.Ordinal);
|
|
|
|
private static string GetConnectionSignature(OpenClawStreamEventDto item)
|
|
=> string.Join(
|
|
"\u001f",
|
|
item.Payload?["state"]?.ToJsonString() ?? string.Empty,
|
|
item.Payload?["connected"]?.ToJsonString() ?? string.Empty,
|
|
item.Payload?["gatewayVersion"]?.ToJsonString() ?? string.Empty,
|
|
item.Payload?["protocolVersion"]?.ToJsonString() ?? string.Empty,
|
|
item.Payload?["deviceId"]?.ToJsonString() ?? string.Empty,
|
|
item.Payload?["deviceTokenConfigured"]?.ToJsonString() ?? string.Empty,
|
|
item.Payload?["pairingRequired"]?.ToJsonString() ?? string.Empty,
|
|
item.Payload?["pairingRequestId"]?.ToJsonString() ?? string.Empty,
|
|
item.Payload?["lastConnectedAt"]?.ToJsonString() ?? string.Empty,
|
|
item.Payload?["reconnectAttempts"]?.ToJsonString() ?? string.Empty,
|
|
item.Payload?["message"]?.ToJsonString() ?? string.Empty);
|
|
}
|