feat(browser): stream bounded live session updates (#26)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using FinalFactory.Rendezvous.Contracts;
|
||||
using FinalFactory.Rendezvous.Server.Abuse;
|
||||
using FinalFactory.Rendezvous.Server.Browser;
|
||||
@@ -69,6 +70,12 @@ internal static class ContractEndpoints
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("BrowseSessions");
|
||||
sessions.MapGet("/stream", StreamSessions)
|
||||
.Produces<SessionStreamEvent>(StatusCodes.Status200OK, contentType: "text/event-stream")
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ApiError>(StatusCodes.Status429TooManyRequests)
|
||||
.Produces<ApiError>(StatusCodes.Status503ServiceUnavailable)
|
||||
.WithName("StreamSessions");
|
||||
sessions.MapGet("/{listingId}", GetSession)
|
||||
.Produces<GetSessionResponse>()
|
||||
.Produces<ApiError>(StatusCodes.Status400BadRequest)
|
||||
@@ -349,6 +356,123 @@ internal static class ContractEndpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> StreamSessions(
|
||||
[FromQuery] int contractVersion,
|
||||
[FromQuery] string gameId,
|
||||
[FromQuery] string environmentId,
|
||||
[FromQuery] uint protocolVersion,
|
||||
[FromQuery] string? regionId,
|
||||
[FromQuery] bool? excludeFull,
|
||||
[FromQuery] string? streamCursor,
|
||||
[FromHeader(Name = "Last-Event-ID")] string? lastEventId,
|
||||
[FromServices] SessionStreamService streams,
|
||||
[FromServices] AbuseProtectionService abuseProtection,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!GameId.TryParse(gameId, out GameId parsedGameId)
|
||||
|| !EnvironmentId.TryParse(environmentId, out EnvironmentId parsedEnvironmentId)
|
||||
|| regionId is not null && !RegionId.TryParse(regionId, out _))
|
||||
{
|
||||
return Error(RendezvousErrorCode.InvalidRequest);
|
||||
}
|
||||
if (!TryAcquireIdentity(
|
||||
abuseProtection,
|
||||
httpContext,
|
||||
"StreamSessions",
|
||||
Tenant(parsedGameId, parsedEnvironmentId),
|
||||
null,
|
||||
null,
|
||||
out AbuseProtectionService.AbuseLease? abuseLease))
|
||||
{
|
||||
return RateLimited(httpContext);
|
||||
}
|
||||
|
||||
using (abuseLease)
|
||||
{
|
||||
BrowserServiceResult<SessionStreamSubscription> subscribed = streams.Subscribe(new()
|
||||
{
|
||||
ContractVersion = contractVersion,
|
||||
GameId = parsedGameId,
|
||||
EnvironmentId = parsedEnvironmentId,
|
||||
ProtocolVersion = protocolVersion,
|
||||
RegionId = regionId is null ? null : new RegionId(regionId),
|
||||
ExcludeFull = excludeFull ?? false,
|
||||
}, string.IsNullOrEmpty(lastEventId) ? streamCursor : lastEventId);
|
||||
if (!subscribed.Succeeded || subscribed.Value is null)
|
||||
{
|
||||
return Error(subscribed.Error);
|
||||
}
|
||||
|
||||
using SessionStreamSubscription subscription = subscribed.Value;
|
||||
using CancellationTokenSource duration = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken);
|
||||
duration.CancelAfter(streams.MaximumConnectionDuration);
|
||||
HttpResponse response = httpContext.Response;
|
||||
response.StatusCode = StatusCodes.Status200OK;
|
||||
response.ContentType = "text/event-stream";
|
||||
response.Headers.CacheControl = "no-cache, no-store";
|
||||
response.Headers["X-Accel-Buffering"] = "no";
|
||||
await response.StartAsync(duration.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
while (!duration.IsCancellationRequested)
|
||||
{
|
||||
SessionStreamReadResult read = streams.Read(subscription);
|
||||
if (read.RequiresReset)
|
||||
{
|
||||
await WriteSseAsync(response, streams.ResetEvent(subscription), duration.Token)
|
||||
.ConfigureAwait(false);
|
||||
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
if (read.Events.Count > 0)
|
||||
{
|
||||
foreach (SessionStreamEvent item in read.Events)
|
||||
{
|
||||
await WriteSseAsync(response, item, duration.Token).ConfigureAwait(false);
|
||||
}
|
||||
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
bool changed = await streams.WaitForChangeAsync(subscription, duration.Token)
|
||||
.ConfigureAwait(false);
|
||||
if (!changed)
|
||||
{
|
||||
await WriteSseAsync(
|
||||
response,
|
||||
streams.KeepaliveEvent(subscription),
|
||||
duration.Token).ConfigureAwait(false);
|
||||
await response.Body.FlushAsync(duration.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (duration.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
return Results.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteSseAsync(
|
||||
HttpResponse response,
|
||||
SessionStreamEvent item,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string eventName = item.Kind switch
|
||||
{
|
||||
SessionStreamEventKind.SessionUpsert => "session_upsert",
|
||||
SessionStreamEventKind.SessionRemove => "session_remove",
|
||||
SessionStreamEventKind.Reset => "reset",
|
||||
_ => "keepalive",
|
||||
};
|
||||
string data = JsonSerializer.Serialize(item, ContractJson.Options);
|
||||
await response.WriteAsync(
|
||||
$"id: {item.Cursor}\nevent: {eventName}\ndata: {data}\n\n",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static IResult GetSession(
|
||||
SessionListingId listingId,
|
||||
[FromQuery] int contractVersion,
|
||||
|
||||
Reference in New Issue
Block a user