Heximon Logo
Realtime

Server-Sent Events

Stream typed events server-to-client over plain HTTP with EventStream, EventChannel, heartbeats and Last-Event-ID resume, plus a durable-backed ServerSentEventsHandler for cross-isolate broadcast.

Server-Sent Events are server→client streaming over plain HTTP: one long-lived GET response on which the server keeps writing typed, named events as they happen. No WebSocket upgrade, no second protocol — a text/event-stream body that browsers consume with the built-in EventSource. Heximon gives you that as a value you simply return from a controller.

public stream(_action: Get<"/stream">): EventStream<NotificationEvents> {
  return EventStream.from(
    async (channel) => {
      await channel.send("notification", { id: "1", message: "hello" });
    },
    { events: NotificationEvents.events, context: this.context },
  );
}

That's the whole story for a single server. There's a second tier — a durable-backed channel for fanning one event out to clients spread across isolates — but reach for it only when you need cross-isolate broadcast. Start here.

Install the package

pnpm add @heximon/sse

For a single-server stream that's all you need: EventStream is an ordinary HTTP response, so the HTTP plugin you already have discovers the route. The durable tier later adds one compiler plugin; nothing here does.

Declare a typed event map

Name each event your stream can emit and give it a schema. The map is the contract: a send to an unlisted name won't type-check, and a payload that fails its schema is rejected before a single byte reaches the wire. Any Standard Schema validator works — Zod, Valibot, or a hand-rolled one.

src/notifications/notification.events.ts
import { ServerSentEvents } from "@heximon/sse";
import { notificationSchema } from "./notification.schema"; // any StandardSchemaV1

export class NotificationEvents extends ServerSentEvents({
  notification: notificationSchema,
}) {}

ServerSentEvents({...}) is the same config-factory base the durable tier uses below — declared with no path, it's just the event-schema map, now as a class: both a value (NotificationEvents.events, the map EventStream.from validates against) and a type (NotificationEvents, dropped straight into the EventStream generic with no typeof).

Return the stream from a controller

A handler returns EventStream.from(producer, options). The producer is an async function handed an EventChannel — call send as events occur, and Heximon frames each one onto the response.

src/notifications/notifications.controller.ts
import { Context } from "@heximon/runtime";
import type { Controller, Get } from "@heximon/http";
import { TimeSpan } from "@heximon/primitives";
import { EventStream } from "@heximon/sse";
import { NotificationEvents } from "./notification.events";

export class NotificationsController implements Controller<"/notifications"> {
  constructor(private readonly context: Context) {}

  public stream(_action: Get<"/stream">): EventStream<NotificationEvents> {
    return EventStream.from(
      async (channel) => {
        channel.heartbeat(new TimeSpan(50, "ms")); // a ":" comment line every 50ms, keeps proxies from idling out

        for (let index = 1; index <= 3; index += 1) {
          await channel.send("notification", { id: String(index), message: `hello ${index}` });
        }
        // Return void to close the stream; return a teardown function to keep it
        // open (event-driven) and clean up on disconnect.
      },
      { events: NotificationEvents.events, context: this.context },
    );
  }
}
src/notifications/notifications.module.ts
import { Module } from "@heximon/runtime";
import { NotificationsController } from "./notifications.controller";

export class NotificationsModule extends Module({
  http: { controllers: [NotificationsController] },
}) {}

Context is required, not ceremony. There's no ambient Context.current() — the channel's lifetime is tied to the context you pass, which is what keeps the producer running after the handler returns synchronously on Cloudflare. Inject it, pass it.

Send, beat, resume

The EventChannel your producer receives is the entire surface, and it's web-standard only — TextEncoder + ReadableStream — so the same code streams on Node and on Cloudflare.

CallEffect
channel.send(eventName, data)Named, typed, schema-validated event.
channel.send(data)Unnamed event (the default message), untyped.
channel.heartbeat(interval)A : ping comment every interval (a TimeSpan) — keeps idle connections alive.
channel.close()Ends the stream (idempotent).
channel.lastEventIdThe id the next event will carry (introspection/tests).

Validation is fail-closed: a payload that doesn't match its schema throws EventValidationErrorbefore the id counter advances or any frame is written, so a rejected event can never half-write the stream and desync a client mid-event.

When a dropped client reconnects, its browser replays the last id it saw in a Last-Event-ID header; Heximon reads it and exposes it as resumeFrom on the producer's second argument, so you pick up exactly where the connection broke.

Fan out across isolates with a durable channel

The single-server stream above lives in one process: the producer and every client share memory, so a send reaches them all. On the edge that breaks down — clients of the same topic land on different isolates, and one isolate's channel can't see another's connections.

To broadcast one event to all of them you need a single owner for the topic's live streams. That owner is a Cloudflare Durable Object: one DO per topic holds every client of that topic, regardless of which isolate they connected through.

Register the SSE and durable plugins

The single-server tier needed no compiler plugin. The durable tier does — register ServerSentEventsPlugin alongside DurablePlugin and the HttpPlugin the connecting GET rides on, in the heximon.config.ts config file.

heximon.config.ts
import { defineHeximonConfig } from "@heximon/build";
import { DurablePlugin } from "@heximon/durable/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
import { ServerSentEventsPlugin } from "@heximon/sse/compiler";

export default defineHeximonConfig({
  plugins: [new HttpPlugin(), new DurablePlugin(), new ServerSentEventsPlugin()],
});

The two plugins resolve each other by inspecting each other's output, not by importing, so your SSE source imports no durable code. You do need @heximon/durable installed, because the channel names a DurableObject as its host.

Declare the broadcast events as a class

The durable tier's event map is a class, declared like a Contract: class FeedEvents extends ServerSentEvents({ path, events }). That call is one of the few that sit in an extends clause, and what it returns is both a value and a type — the same trick a Contract uses.

As a value, FeedEvents.events is the schema map the compiler imports and the broadcaster registers; as a type, FeedEvents drives the SSE generics directly, with no typeof. The subclass body is free for domain methods over the typed publisher.

The events class can also declare the stream's path — the canonical form. When it does, the events class is the single owner of the feed's address: the handler below binds events: FeedEvents and declares no path of its own, and connection.pathParams types itself off the declared :segments.

src/feed/feed.events.ts
import { ServerSentEvents } from "@heximon/sse";
import { announceSchema } from "./announce.schema"; // any StandardSchemaV1 for { message: string }

export class FeedEvents extends ServerSentEvents({
  path: "/feed/:topicId",
  events: {
    announce: announceSchema,
  },
}) {}

path is optional. Omit it — ServerSentEvents({ name: schema }), the flat form — when a handler should declare its own path instead, e.g. one events class mounted at several handler-declared paths:

src/feed/feed.events.ts
import { ServerSentEvents } from "@heximon/sse";
import { announceSchema } from "./announce.schema"; // any StandardSchemaV1 for { message: string }

export class FeedEvents extends ServerSentEvents({
  announce: announceSchema,
}) {}
One source of truth for the address. Declaring path on the handler's ServerSentEventsHandler<{ path }> config AND on its bound events class's ServerSentEvents({ path, events }) is a build error — even when both declare the same string. Pick one owner: the events class (drop the handler's path) or the handler (drop the events class's path).

Author the durable channel

A ServerSentEventsHandler<Config> reads its stream path, host DO, and events class from a single type argument — the same way a Controller<"/users"> reads its prefix — declared via implements (or extends, which works the same). SSE is unidirectional, so a connection has nothing to receive: the only method is stream(connection), which runs once per client. Cross-client fan-out comes from publish, not from here.

When the bound events class already declares a path (the form above), the handler declares none — the address comes from the events class, and both the key selector and connection.pathParams type themselves off it:

src/feed/feed.channel.ts
import type {
  EventStreamConnection,
  ServerSentEventsHandler,
  ServerSentEventsKeySelector,
} from "@heximon/sse";
import { FeedEvents } from "./feed.events"; // declares path: "/feed/:topicId"
import { FeedRoom } from "./feed.room";

export class FeedChannel implements ServerSentEventsHandler<{
  durable: FeedRoom; // the DurableObject holding the live streams
  events: FeedEvents; // owns the stream's address — the handler declares no path
}> {
  // Key the hosting DO per topic — `request` is typed from the events class's declared path.
  public readonly key: ServerSentEventsKeySelector<{
    durable: FeedRoom;
    events: FeedEvents;
  }> = (request) => request.pathParams.topicId;

  public async stream(connection: EventStreamConnection<FeedEvents>): Promise<void> {
    // connection.pathParams is typed off FeedEvents's declared ":topicId" segment.
    const topicId = connection.pathParams.topicId;
    await connection.channel.send({ type: "welcome", id: connection.id, topicId });
  }
}

The key selector field decides which Durable Object a connection forwards into: returning topicId means every client of /feed/lobby shares one DO, while /feed/private lands on a different DO entirely. That keyed split is what makes broadcast scoped to a topic. (On an extends ServerSentEventsHandler<…> handler, the same selector is passed via super((request) => …) — there the arrow's request needs no annotation.)

The alternative — a flat (path-less) events class bound to a handler that declares its own path — is fully supported, and is the right shape when one events class is mounted at several different handler paths:

src/feed/feed.channel.ts
import type {
  EventStreamConnection,
  ServerSentEventsHandler,
  ServerSentEventsKeySelector,
} from "@heximon/sse";
import { FeedEvents } from "./feed.events"; // flat form — no declared path
import { FeedRoom } from "./feed.room";

export class FeedChannel implements ServerSentEventsHandler<{
  path: "/feed/:topicId";
  durable: FeedRoom;
  events: FeedEvents;
}> {
  public readonly key: ServerSentEventsKeySelector<{
    path: "/feed/:topicId";
    durable: FeedRoom;
    events: FeedEvents;
  }> = (request) => request.pathParams.topicId;

  public async stream(connection: EventStreamConnection<FeedEvents>): Promise<void> {
    await connection.channel.send({ type: "welcome", id: connection.id });
  }
}

Hold the streams in a Durable Object and broadcast

The host DO injects ServerSentEventsBroadcaster and exposes a publish RPC that frames the event once and writes it to every stream it holds for the topic. Annotating publish with the events class (ServerSentEvent<FeedEvents> — no typeof, since the class is a type) makes a wrong event name or a mismatched payload a compile error.

src/feed/feed.room.ts
import type { DurableObject } from "@heximon/durable";
import { ServerSentEventsBroadcaster, type ServerSentEvent } from "@heximon/sse";
import { FeedEvents } from "./feed.events";

export class FeedRoom implements DurableObject {
  private static readonly topic = "/feed/:topicId"; // the stream's static dispatch path

  // The broadcaster is injected by class identity off this DO's app.
  constructor(private readonly broadcaster: ServerSentEventsBroadcaster) {}

  public async publish(event: ServerSentEvent<FeedEvents>): Promise<number> {
    return this.broadcaster.publish(FeedRoom.topic, event); // fans out to every held stream
  }
}

Unlike a WebSocket room, the host DO does not declare hibernate: true. SSE has no hibernation API — the streams are live in DO memory, so the DO stays resident exactly as long as a client is connected. That's the cost of a push channel browsers speak natively, and it's the right trade-off when connections are bounded per topic.

Trigger a broadcast

A producer injects a DurableFactory<FeedRoom>, resolves the DO by topic name with getByName, and calls its publish RPC — a plain DI dependency like any other.

src/feed/feed.publisher.ts
import type { Controller, Post } from "@heximon/http";
import { FeedFactory } from "./feed.factory"; // extends DurableFactory<FeedRoom>

export class FeedPublisher implements Controller<"/feed"> {
  constructor(private readonly feeds: FeedFactory) {}

  public async announce(action: Post<"/:topicId/announce">): Promise<{ delivered: number }> {
    const topicId = action.request.pathParams.topicId;
    const delivered = await this.feeds
      .getByName(topicId)
      .publish({ event: "announce", data: { message: `hello ${topicId}` } });
    return { delivered };
  }
}

Wire the durable module

Each concept goes under the namespace its plugin owns: the DO and its factory under durable, the channel under sse, the producer controller under http. None of them belong in providers.

src/feed/feed.module.ts
import { Module } from "@heximon/runtime";
import { FeedChannel } from "./feed.channel";
import { FeedFactory } from "./feed.factory";
import { FeedPublisher } from "./feed.publisher";
import { FeedRoom } from "./feed.room";

export class FeedModule extends Module({
  durable: { objects: [FeedRoom], factories: [FeedFactory] },
  sse: { handlers: [FeedChannel] },
  http: { controllers: [FeedPublisher] },
}) {}
Don't list the durable factory in providers.FeedFactory extends DurableFactory<FeedRoom> is already constructed by the framework from the Durable Object binding — listing it again shadows that real provider, and the factory you inject won't be wired to the DO namespace.Declare it only under durable: { factories }, and resolve instances with getByName(topicId) (a human topic name), never get(string) — Cloudflare's get requires a 64-character hex id, so a topic name passed to it fails at runtime.

Where it runs

The whole stack touches only TextEncoder, ReadableStream, and JSON, never a node: API, so the identical code runs on Node and on Cloudflare. On Node there's no platform DO to forward into, so the durable path degrades to an in-process per-topic registry: the same fan-out code, no round-trip. One stream path may have exactly one handler — a duplicate path is a build error — and every durable: target must be a real, discovered DurableObject.

Connect from the browser

A ServerSentEvents({...}) class is more than a server-side declaration — the same class types a browser client. The @heximon/client/sse subpath gives you an SseClient that connects to any text/event-stream endpoint and validates every received frame against that schema, with no node: imports and no runtime dependency on @heximon/runtime or @heximon/sse.

See Typed clients for the full guide — subscribing, streaming, resuming with Last-Event-ID, and testing with a mock transport.

See also

  • WebSockets — switch to a full-duplex channel when clients need to send as well as receive; the durable host pattern is the same.
  • Durable Objects — the DurableObject and DurableFactory the broadcast tier holds its live streams in.
  • Typed clients — the browser-safe SseClient for a ServerSentEvents class, and its WebSocket counterpart.
  • Nitro deployment — host the single-server stream, or ship the durable channel to Cloudflare Workers.
  • The flagship Cloudflare app — a durable-backed remaining-seats feed channel with cross-isolate broadcast fan-out, plus a typed SseClient test that consumes the live server frames (subscribe, stream, resume via lastEventId, and the error-handling paths).
Copyright © 2026