Request-driven server streaming
Stream one request's lazy response over SSE or gRPC with IStreamHandler — and choose it deliberately over client events, ordered streams, or connections.
IStreamHandler<TRequest, TItem> serves a cold, request-driven response: one caller starts one
lazy sequence. Use it when the response itself is a sequence — a row-by-row export, token output, or a
tail/watch query. It is not a catch-all realtime API.
Choose the delivery contract
The word “stream” describes several intentionally different contracts. Start with the recovery behavior the consumer needs, not the wire protocol:
| Need | Choose | Producer and lifetime | What the consumer can rely on | It does not provide |
|---|---|---|---|---|
| One request returns a deferred sequence | IStreamHandler | Cold; each request owns a new enumeration | Upfront accept/reject, then lazy completion, cancellation, and faults | Shared producer, ordering across requests, replay, or resume |
| Every item from one live producer matters | Actor-owned StreamHub | Hot; one sequencer per key owns the hub | Ordered items, completion, bounded replay, and reconnect resume | Durable history beyond the retained hub and its activation epoch |
| A browser should refresh after a change | Client events | Hot fan-out from any publisher/node | At-most-once hint; an authorized re-query converges | Replay, gap-free sequence delivery, or a delivery receipt |
| The conversation itself is interactive or stateful | Client connections | Long-lived, codec-owned link | Per-connection conversation and explicit request/reply when the protocol supports it | A pub/sub, ordered-stream, or durable-message guarantee |
Use this selection flow:
- Is the output the deferred response to one request? Use
IStreamHandler. - Otherwise, does a missed item make the consumer wrong, and can one live producer own the sequence?
Use an actor-owned
StreamHub. - Otherwise, can a recipient re-query authoritative state after a loss? Use client events.
- Separately, if either direction needs an interactive/stateful conversation, add a connection as the carrier. A connection does not replace the delivery contract above.
For machine consumers that must process every independently durable business fact, use an integration event and its outbox instead of holding a client connection.
The handler contract
A stream handler is a distinct shape, rather than a widened unary handler:
using System.Runtime.CompilerServices;
public sealed class ExportRows(AppDbContext db)
: IStreamHandler<ExportRows.Query, ExportRows.Row> {
public sealed record Query : IQuery {
public required Guid ReportId { get; init; }
}
public sealed record Row(Guid Id, string Value);
public ValueTask<Result<IAsyncEnumerable<Row>>> HandleAsync(Query query, CancellationToken ct) {
if (query.ReportId == Guid.Empty) {
return ValueTask.FromResult<Result<IAsyncEnumerable<Row>>>(
AppError.Validation("A report id is required."));
}
return ValueTask.FromResult(Result<IAsyncEnumerable<Row>>.Success(ReadRows(query, ct)));
}
private async IAsyncEnumerable<Row> ReadRows(
Query query,
[EnumeratorCancellation] CancellationToken ct) {
await foreach (var row in db.ExportRows(query.ReportId).WithCancellation(ct)) {
yield return new Row(row.Id, row.Value);
}
}
}HandleAsync accepts or rejects the request before a transport commits its response. On success,
the IAsyncEnumerable<TItem> remains lazy: completion, cancellation, and faults belong to its
enumeration. The generator discovers the handler under its module and gives it a separate, module-gated
stream pipeline.
A stream handler is not exposed by [Handler] over JSON-RPC/MCP and it is not a generated
[HttpEndpoint] route. JSON-RPC and MCP remain single-response. HTTP/SSE and gRPC expose this
contract explicitly.
HTTP/SSE: map the request directly
Put the explicit route in the owning module's MapEndpoints hook. The direct MapGet call is deliberate:
ASP.NET Core's Request Delegate Generator sees the route and query parameters, while Elarion owns typed
stream invocation, canonical JSON, and SSE framing.
[AppModule("Reports")]
public static partial class ReportsModule {
public static void MapEndpoints(IEndpointRouteBuilder endpoints) {
endpoints.MapGet("reports/{reportId}/export", static (Guid reportId) =>
ElarionHttpResults.ToStreamResult<ExportRows.Query, ExportRows.Row>(new() {
ReportId = reportId,
}));
}
}The host still maps all module-owned routes through MapElarionEndpoints, and it calls
AddElarionHttpJson() so minimal APIs use the same canonical, source-generated JSON resolver chain as
the rest of Elarion. Register the item type in the module or host JSON context as usual.
ToStreamResult starts the decorated handler only when ASP.NET executes the result:
- A validation, authorization, feature-gate, or not-found failure becomes the normal Elarion problem response before SSE headers are committed.
- Once accepted, normal completion ends the stream; a disconnect cancels it; a lazy fault ends the
connection. There is no second
Resultenvelope after items begin. - The accepted invocation retains its fresh DI/dispatch scope until enumeration completes, faults,
cancels, or is disposed. A stream can therefore retain its
DbContextand reader for its full duration; use a finite, intentional resource lifetime rather than an accidental infinite response. - Normal SSE items have no shared sequence, replay ring, or
Last-Event-IDresume behavior. If those are required, the response is a hot producer-ownedStreamHub, not this contract.
See HTTP endpoints for ordinary generated unary routes.
The complete working cold-export example is EdgeTelemetry,
which contrasts its unbuffered /history/stream response with a buffered JSON query.
Pipeline and transport boundaries
Stream handlers retain the request gates that decide whether a stream may begin:
- Applies: authorization, feature gates, validation, observability, and context enrichment.
- Does not apply automatically: unary transactions, caching and invalidation, idempotency, resilience, and audit. Do not infer a retry or transaction around a long lazy enumeration from attributes intended for a unary request.
For a custom server-streaming transport, call
StreamHandlerInvoker.InvokeAsync<TRequest, TItem>. On success, await using its returned invocation
while writing items; it owns the fresh scope until enumeration ends. Dispose an accepted invocation that
you never enumerate, and never return its bare sequence after releasing the scope. The
custom transports guide shows the adapter shape.
HTTP/SSE and gRPC server streaming support this
cold request shape. gRPC uses the same invocation lifetime, but maps a startup AppError to gRPC status
before it writes an item. Client and duplex streaming are deliberately out of scope.
When to use the neighbouring realtime APIs instead
- Client events: use for committed facts, live progress, or latest-wins state when a missed message only leaves a browser stale and re-querying repairs it. They remain the default browser-push tier.
StreamHub: use when each item is data, completion matters, and a single actor can sequence a live producer per key. The LiveQuotes sample deliberately uses client events for its dashboard and a resumableStreamHubfor every accepted tick.- Connections: use only when the link itself is stateful or client input is latency-interactive. They can carry client-event subscriptions, but they do not turn an at-most-once hint into an ordered replayable stream.
HTTP endpoints
Mark a handler with [HttpEndpoint] and Elarion generates the minimal-API MapGet/MapPost mapping — unwrapping the Query/Command and mapping AppError to RFC 7807 status codes.
OpenAPI
Bring the [HttpEndpoint] REST transport to schema/contract parity with JSON-RPC — an OpenAPI document, module tags, clean operation ids, ProblemDetails, and the Idempotency-Key contract, from Microsoft.AspNetCore.OpenApi.