GioJSdocs
On this page

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.

ParameterTypeDefaultDescription
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

FieldTypeDefaultDescription
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 200 with Content-Type: text/event-stream and Cache-Control: no-cache, sent before the handler runs. It is never cached, and it stays open until you call close(), the client disconnects, or the server shuts down.
  • Each send becomes id: <id>\nevent: <event>\ndata: <json>\n\n on the wire (the id and event lines only when given).
  • When the handler throws, or its promise rejects, the error is logged (sse handler threw) and the stream ends - its 200 is already sent. A cleanup that throws is logged too (sse cleanup threw).
  • A handler that returns (or resolves to) anything but a function, undefined or null logs 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 GioEventStream

isGioEventStream() 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 async handler'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 last await, or undo it yourself when the work before it fails. Await only the setup, then return the cleanup: an async handler 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(). Calling close() 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 Response with a ReadableStream body and Content-Type: text/event-stream instead - it streams chunk by chunk too (see Streaming responses).
  • Static export skips route handlers, so event streams need the server.

Version history

VersionChanges
v0.1.0-beta.8An 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.6import { GioEventStream, isGioEventStream } from '@gio.js/core' works: the package got a public entry point.
v0.1.0-beta.5route.ts method handlers can return a GioEventStream; detection is brand-based.
v0.1.0-beta.1Introduced.