Server-Sent Events
To stream events to the client over a long-lived connection — token-by-token LLM output, progress updates, live notifications — mark the route with @Sse and return an async iterable (typically an async generator). covenix sets the text/event-stream headers, frames each yielded value as an SSE event, validates it against the schema, and cleans up on disconnect.
import { z } from 'zod';
import { Route, Get, Param, Sse, SseEvent } from 'covenix';
const Token = z.object({ text: z.string() });
@Route('chat')
class ChatController {
@Get('{id}/stream')
@Sse(Token, { keepAlive: 15000 })
async *stream(@Param('id') id: string): AsyncGenerator<z.infer<typeof Token>> {
try {
for await (const text of llm.stream(id)) {
yield { text }; // → data: {"text":"…"}
}
yield new SseEvent({ text: '' }, { event: 'done' }); // → event: done
} finally {
// Runs when the client disconnects — abort the upstream call here.
}
}
}How it works
A @Sse route doesn't JSON-respond. covenix:
- sets
Content-Type: text/event-stream(plusCache-Control: no-cacheandX-Accel-Buffering: noto defeat proxy buffering) and keeps the socket open, - pulls from the returned async iterable and writes one SSE frame per event,
- validates and serializes each event against the
@Sseschema — the parsed value is sent, so undeclared fields are stripped just like a normal response, - on client disconnect, calls the iterator's
return()so an async generator'sfinallyruns (the place to abort an upstream call), - ends the stream when the iterable completes.
Framing: plain values vs SseEvent
Yield a plain value and covenix frames it as a data: line (dispatched to the browser's onmessage). Yield an SseEvent to set the other SSE fields:
yield { text: 'hi' }; // data: {"text":"hi"}
yield new SseEvent(payload, {
event: 'done', // → addEventListener('done', …) on the client
id: '42', // → echoed as Last-Event-ID on reconnect (resumable streams)
retry: 3000, // → client reconnect delay (ms)
});The data is what's validated against the schema; the framing fields are metadata. A @Sse() with no schema frames raw values (strings as-is, others as JSON).
Keep-alive
Set { keepAlive: ms } to send a comment frame (: \n\n) on that interval, keeping idle connections from being closed by proxies/load balancers:
@Sse(Token, { keepAlive: 15000 })Disconnect handling
Because the handler is an async iterable, cleanup is just a try/finally (or a generator's natural teardown). When the client goes away, covenix calls the iterator's return(), which resumes the generator at its suspension point with a return — running finally.
WARNING
return() runs the generator's finally once the in-flight await settles. If your source can block indefinitely (e.g. a network read with no timeout), pass it an AbortSignal you trigger on disconnect so it unblocks promptly, rather than relying on finally alone.
Validation mid-stream
Each event is validated, but once the first frame is sent the response status is already committed — so an event that fails its schema can't become a 500. covenix terminates the stream and surfaces the error through next(err) (logged by your error handler; it can't change the status). Treat a mid-stream validation failure as the server bug it is.
OpenAPI
An @Sse route is documented as a text/event-stream response whose media schema is the event schema, so the per-event shape still shows up for tooling:
"responses": {
"200": {
"content": {
"text/event-stream": { "schema": { "$ref": "#/components/schemas/Token" } }
}
}
}OpenAPI has no native model for SSE channels/streaming semantics, so this documents the event payload shape only.
