GioEventStream
Answer a route handler with a Server-Sent Events stream: send JSON events until you close it or the client goes away.
app/api/ticker/route.ts
import { GioEventStream } from '@gio.js/core';
export function GET() {
return new GioEventStream((stream) => {
const timer = setInterval(() => stream.send({ time: Date.now() }), 1000);
return () => clearInterval(timer); // runs when the client disconnects
});
}Reference
new GioEventStream(handler), returned from a route.ts method handler - any method, though a browser's EventSource always sends GET.
| Parameter | Type | Default | Description |
|---|---|---|---|
handler (required) | SseHandler | - | Called once the response has started. It may return a cleanup function, which runs when the client disconnects or the server shuts the stream down, or nothing. It may be async: the cleanup is then what its promise resolves to. |
ts
type SseCleanupFn = () => void;
type SseHandler = (stream: SseStream) => SseCleanupFn | void | Promise<SseCleanupFn | void>;SseStream
| Field | Type | Default | Description |
|---|---|---|---|
send(data, event?, id?) | void | - | Writes one event. data is always passed through JSON.stringify - a string arrives quoted - so it fits on one data: line. event sets the event name and id the event id; line breaks are stripped from both. |
close() | void | - | Ends the response. The cleanup function does not run after it. |
Behavior
- The response is
200withContent-Type: text/event-streamandCache-Control: no-cache, sent before the handler runs. It is never cached, and it stays open until you callclose(), the client disconnects, or the server shuts down. - Each
sendbecomesid: <id>\nevent: <event>\ndata: <json>\n\non the wire (theidandeventlines only when given). - When the handler throws, or its promise rejects, the error is logged (
sse handler threw) and the stream ends - its200is already sent. A cleanup that throws is logged too (sse cleanup threw). - A handler that returns (or resolves to) anything but a function,
undefinedornulllogs a warning and has no cleanup. - Under backpressure from a slow server connection, events may be dropped rather than buffered without bound (a warning is logged).
- On shutdown the server ends open event streams instead of waiting for them, so they never hold up a deploy.
isGioEventStream
ts
isGioEventStream(value: unknown): value is GioEventStreamisGioEventStream() tells whether a value is a GioEventStream - from any copy of @gio.js/core. Route modules load in their own module namespace, so instanceof can fail across that boundary; the check uses a brand (__gioSse) and the presence of a handler function. Use it in plugins or wrappers that pass handler results through.
ts
import { isGioEventStream } from '@gio.js/core';
function withTiming<T>(handler: () => Promise<T> | T) {
return async () => {
const started = Date.now();
const result = await handler();
if (!isGioEventStream(result)) console.log('handled in', Date.now() - started, 'ms');
return result;
};
}Examples
Named events with ids, closed by the server
app/api/clock/route.ts
import { GioEventStream } from '@gio.js/core';
export function GET() {
return new GioEventStream((stream) => {
let n = 0;
const timer = setInterval(() => {
n += 1;
stream.send({ n, time: new Date().toISOString() }, 'tick', String(n));
if (n === 3) {
clearInterval(timer); // close() does not call the cleanup
stream.close();
}
}, 200);
return () => clearInterval(timer);
});
}text
id: 1
event: tick
data: {"n":1,"time":"2026-10-07T15:19:19.008Z"}
id: 2
event: tick
data: {"n":2,"time":"2026-10-07T15:19:19.209Z"}
id: 3
event: tick
data: {"n":3,"time":"2026-10-07T15:19:19.409Z"}Read it in the browser
tsx
useEffect(() => {
const source = new EventSource('/api/clock');
source.addEventListener('tick', (event) => {
const { n } = JSON.parse(event.data); // data is always JSON
setCount(n);
});
return () => source.close();
}, []);Events sent without a name arrive at source.onmessage. An EventSource reconnects on its own after the stream ends; call source.close() when you do not want it to.
Test it
ts
import { callRoute } from '@gio.js/core/testing';
const res = await callRoute('/api/clock');
expect(await res.text()).toContain('event: tick'); // waits for close()Good to know
- Async handlers. An
asynchandler's cleanup is what its promise resolves to. A client that leaves before the promise settles still gets the cleanup run, as soon as it does - so set up what the cleanup undoes only after the lastawait, or undo it yourself when the work before it fails. Await only the setup, then return the cleanup: anasynchandler that keeps sending in a loop (for (;;) stream.send(await next())) never resolves, so its cleanup never runs and the loop outlives the client. Run long-lived work outside the awaited body - a timer or a subscription the cleanup stops. - Clean up before
close(). Callingclose()forgets the cleanup function, so stop timers and subscriptions yourself first. - Open streams hold connections. Each one counts toward
[server] max_connections. - Need full control of the wire format? Return a
Responsewith aReadableStreambody andContent-Type: text/event-streaminstead - it streams chunk by chunk too (see Streaming responses). - Static export skips route handlers, so event streams need the server.
Related
- Route Handlers: Server-Sent Events
- Streaming
- callRoute: event streams
- broadcast - for two-way messaging over WebSockets
Version history
| Version | Changes |
|---|---|
v0.1.0-beta.8 | An async handler's cleanup is what its promise resolves to (the promise used to be stored as the cleanup, which then threw); a handler may return nothing; any method may return a stream; SseHandler type. |
v0.1.0-beta.6 | import { GioEventStream, isGioEventStream } from '@gio.js/core' works: the package got a public entry point. |
v0.1.0-beta.5 | route.ts method handlers can return a GioEventStream; detection is brand-based. |
v0.1.0-beta.1 | Introduced. |