Appearance
@apco/realtime
Server-Sent Events (SSE) realtime for APCO apps. There is no platform broker — the realtime hub runs inside your SSR app's process, so it works anywhere the app runs. Requires Node.js ≥ 22 (ESM only); React ≥ 18 is an optional peer dependency (only for the hook). See the realtime guide for a walkthrough.
bash
npm install @apco/realtime| Entry point | Exports |
|---|---|
@apco/realtime | createRealtime, DEFAULT_MAX_CLIENTS, HARD_MAX_CLIENTS, types Realtime, RealtimeOptions, RealtimeEvent, PublishResult, RealtimeAdapter |
@apco/realtime/client | subscribe, types SubscribeOptions, Subscription, RealtimeClientEvent |
@apco/realtime/events | defineRealtimeEvents, eventsFor, type RealtimeEventDefinitions |
@apco/realtime/react | useRealtime, types UseRealtimeOptions, RealtimeClientEvent, Subscription |
@apco/realtime/postgres | createPostgresAdapter, types PostgresAdapterOptions, PostgresAdapterError, SqlLike |
Server (@apco/realtime)
createRealtime()
ts
import { createRealtime } from "@apco/realtime";
const rt = createRealtime({ channels: ["todos"], maxClients: 100 });
// Publish from any route:
rt.publish("todos", { event: "todo:created", data: row });
// Mount the SSE endpoint (Fetch-API server, e.g. Hono):
app.get("/api/events", (c) => rt.handler(c.req.raw));Signatures:
ts
interface RealtimeOptions {
/** Allowed channel names. Omit to allow any channel. */
channels?: string[];
/** Heartbeat interval in ms. Default: 30_000. */
heartbeatMs?: number;
/** Per-channel throttle. Pass false to disable. */
throttle?: false | {
maxTokens?: number;
refillIntervalMs?: number;
refillTokens?: number;
};
/** Called when a publish is dropped by channel filtering or throttling. */
onDrop?: (drop: { channel: string; reason: "channel_not_allowed" | "throttled"; event?: string }) => void;
/**
* Max concurrent SSE clients. Default: 25 (DEFAULT_MAX_CLIENTS).
* Clamped to the hard ceiling of 1000 (HARD_MAX_CLIENTS).
*/
maxClients?: number;
/** Cross-instance transport. Omit to stay strictly in-process (default). */
adapter?: RealtimeAdapter;
/** Called when the adapter rejects a publish or fails to subscribe. */
onAdapterError?: (err: unknown, context: { op: "publish" | "subscribe" }) => void;
/**
* SSE resume buffer. Default `{ bufferSize: 100 }` (clamped to [0, 10_000]).
* Every delivered event is stamped with a per-instance `id` and retained in a
* bounded ring buffer; a client reconnecting with a matching Last-Event-ID
* replays what it missed. Pass `false` (or `bufferSize: 0`) to disable replay
* AND drop the `id:` line (byte-identical legacy wire format).
*/
replay?: { bufferSize?: number } | false;
/**
* Per-channel authorization hook, run at subscribe time for each requested
* channel. Return false to filter a channel out; configuring it makes
* `handler()` async. See "Channel authorization" below.
*/
authorize?: (channel: string, request: Request | IncomingMessage) => boolean | Promise<boolean>;
}
interface RealtimeEvent {
channel: string; // logical channel
event?: string; // SSE event: type; defaults to "message"
data: unknown; // JSON-serializable payload
id?: string; // SSE id: — stamped per-instance when replay is on (see below)
}
interface Realtime {
publish(channel: string, event: Omit<RealtimeEvent, "channel">): PublishResult;
handler(request: Request): Response | Promise<Response>; // Fetch-API SSE stream (async only when `authorize` is set)
node(req: IncomingMessage, res: ServerResponse): void; // Node http adapter
clientCount(): number; // currently connected clients
close(): Promise<void>; // unsubscribe adapter (no-op without one)
}
type PublishResult =
| { delivered: true }
| { delivered: false; reason: "channel_not_allowed" | "throttled" };
function createRealtime(opts?: RealtimeOptions): Realtime;
const DEFAULT_MAX_CLIENTS = 25;
const HARD_MAX_CLIENTS = 1000;publish(channel, event)
Publishes are still safe to fire-and-forget, but now return a delivery result for observability. Two drop rules to know about:
- Channel allow-list — when
channelswas passed tocreateRealtime, publishes to any other channel return{ delivered: false, reason: "channel_not_allowed" }. - Per-channel throttle — publishes are rate-limited per channel by a token bucket (burst 10, refilling ~10 events/second by default). Events beyond that return
{ delivered: false, reason: "throttled" }, not queued. Coalesce hot paths (e.g. publish one "changed" event and let clients refetch) rather than publishing per row.
Use onDrop for central logging or counters:
ts
const rt = createRealtime({
channels: ["todos"],
onDrop: (drop) => console.warn("dropped realtime event", drop.reason),
});Event definitions
For bundled apps, define channel/event names once and reuse them on server and client:
ts
import { defineRealtimeEvents, eventsFor } from "@apco/realtime/events";
export const realtimeEvents = defineRealtimeEvents({
todos: ["todo:created", "todo:updated", "todo:deleted"],
});
rt.publish("todos", { event: "todo:created", data: row });ts
subscribe({
url: "/api/events",
channels: ["todos"],
events: eventsFor(realtimeEvents, "todos"),
onEvent: () => refetch(),
});handler(request) — Fetch API
Returns a Response streaming text/event-stream (headers: Cache-Control: no-cache, Connection: keep-alive). Honors an optional ?channels=a,b query param — only events on those channels are delivered to that client. When clientCount() is already at maxClients, responds 503 "Too many clients". A comment heartbeat (: heartbeat) is written every heartbeatMs to keep proxies from idling the connection out.
Without authorize the handler is synchronous (returns a Response) and byte-for-byte compatible with earlier versions when replay is disabled. Configuring authorize makes it return Promise<Response> (it awaits the per-channel checks before building the stream) — await it or return the promise directly; Fetch-API servers like Hono accept both.
node(req, res) — Node http adapter
Same semantics as handler for plain node:http / Express-style servers:
ts
import { createServer } from "node:http";
const server = createServer((req, res) => {
if (req.url?.startsWith("/api/events")) return rt.node(req, res);
// ...
});Wire format and sanitization
Each event is written as a named SSE event whose data: is a JSON envelope. When the replay buffer is enabled (the default), an id: line carrying the per-instance resume id precedes it:
id: 3f9a1c2b4d5e:42
event: todo:created
data: {"channel":"todos","data":{"id":1,"text":"ship docs"}}- The
id:line is the per-instance resume id (<instanceId>:<seq>) used by SSE resume. It is present only whilereplayis enabled — setreplay: false(orbufferSize: 0) to drop it entirely and restore the exact legacy wire bytes (event:+data:only). Like event names, a caller-suppliedidis sanitized (CR/LF stripped) before it hits the stream. - The
event:name and the channel are sanitized (CR/LF stripped) before hitting the stream, so user-influenced names cannot inject forged SSE fields; an event name that sanitizes to empty falls back tomessage. datais JSON-stringified, which escapes any remaining control characters — payloads can never break SSE framing.
SSE resume
By default every delivered event — local publishes and adapter-inbound events — is stamped with a per-instance id <instanceId>:<seq> (a random hex instanceId per createRealtime call plus a monotonic counter) and kept in a bounded ring buffer (replay.bufferSize, default 100, clamped to [0, 10_000]). When a client reconnects with a Last-Event-ID header or a ?lastEventId= query param (the param wins — the custom client uses it), the server:
- parses the resume id and checks the
instanceIdmatches this instance; - if so, synchronously replays every buffered event with a greater
seqthat passes the connection's channel filter (andauthorize) before live events flow; - otherwise replays nothing.
This is best-effort, per instance. A foreign or garbled id, a cross-instance reconnect (a different replica, or after a restart), or a gap larger than the buffer all fall through to no replay — so clients should still refetch on connect for authoritative state (see the scaling guide). The id: line and buffer are the only additions; the Postgres adapter envelope is unchanged — ids are stamped locally and never cross the adapter.
Setting replay: false (or bufferSize: 0) disables the buffer and the id: line together.
Channel authorization
Pass authorize(channel, request) to gate subscriptions per channel. It runs at subscribe time (in both handler() and node()) for every requested channel and returns boolean | Promise<boolean>:
ts
const rt = createRealtime({
channels: ["todos"],
authorize: (channel, request) => {
const cookie = request.headers.get?.("cookie") ?? ""; // Fetch Request
return hasValidSession(cookie); // your check
},
});Semantics:
- Unauthorized channels are silently filtered out of the subscription. If the request named channels and none survive, the subscription is rejected with 403.
- A subscribe-all request (no
?channels=) is rejected with 400 whenauthorizeis set — a wildcard can't be authorized per channel, so clients must name their channels. - A throwing hook fails closed → 403.
- Configuring
authorizemakeshandler()asynchronous (see above);node()waits for the checks before writing any headers. Publish, throttle, allowlist, and adapter behavior are unchanged.
requestis a FetchRequestinhandler()and anode:httpIncomingMessageinnode()— read headers accordingly (request.headers.get("cookie")vsrequest.headers.cookie).
Capacity planning
Your plan's max_sse_connections limit (maxSseConnections in GET /account/usage) is the number you should pass as maxClients — the library defaults to a conservative 25 and hard-caps at 1000 regardless of what you pass.
Cross-instance adapter (@apco/realtime)
By default the realtime hub is in-process — two instances of the same app (production + apco dev --sync, or multiple replicas) can't see each other's events. Pass an adapter to createRealtime to bridge them. See the scaling guide for the full walkthrough and payload guidance.
ts
interface RealtimeAdapter {
/** Broadcast a locally published event to other instances. */
publish(evt: RealtimeEvent): void | Promise<void>;
/** Receive events from other instances; resolves to an unsubscribe fn. */
subscribe(onEvent: (evt: RealtimeEvent) => void): Promise<() => Promise<void>>;
/** Optional cleanup. */
close?(): Promise<void>;
}Behavior when an adapter is configured:
- A local
publish()that passes the channel allowlist and throttle is delivered locally and forwarded toadapter.publish(). Adapter failures are routed toonAdapterErrorand never thrown back into the caller —publish()stays synchronous and still returns itsPublishResult. - Events arriving from the adapter are delivered straight to local subscribers. They skip the throttle (already throttled at their origin) and are never re-forwarded to the adapter (no loops), but the channel allowlist still applies (dropped events call
onDropwithreason: "channel_not_allowed"). close()unsubscribes from the adapter and callsadapter.close?.(). It's a no-op when no adapter was configured, idempotent (safe to call twice or concurrently), and resolves even if the adapter's teardown fails (the error is routed toonAdapterError).- Startup gap: there is a brief window between
createRealtime()returning and the adapter's subscription (e.g. PostgresLISTEN) being established — events published by peers during that window are missed, so refetch on connect (see the scaling guide).
createPostgresAdapter() (@apco/realtime/postgres)
A ready-made adapter over Postgres LISTEN/NOTIFY, built on a postgres.js sql instance (as provided by @apco/db):
ts
import { createDb } from "@apco/db";
import { createRealtime } from "@apco/realtime";
import { createPostgresAdapter } from "@apco/realtime/postgres";
const db = createDb();
const rt = createRealtime({
channels: ["todos"],
adapter: createPostgresAdapter({ sql: db.sql }),
});ts
interface PostgresAdapterOptions {
/** A postgres.js `Sql` (e.g. `createDb().sql`). */
sql: SqlLike;
/** Postgres NOTIFY channel. Default: "apco_realtime". */
channel?: string;
/** Called on payload/transport errors; never throws into the caller. */
onError?: (err: PostgresAdapterError) => void;
}
interface PostgresAdapterError {
code: "payload_too_large" | "notify_failed" | "listen_failed" | "unlisten_failed" | "malformed_message";
channel: string;
event?: string;
cause?: unknown;
}
function createPostgresAdapter(options: PostgresAdapterOptions): RealtimeAdapter;- One Postgres NOTIFY channel (default
apco_realtime) carries a JSON envelope{ origin, channel, event, data }. Each adapter instance has a randomoriginid and drops its own echoes (a NOTIFY is delivered to the notifying session's listener too). sql.listen()gets its own dedicated connection courtesy of postgres.js;sql.notify()reuses the pool.@apco/realtimenever takes a runtime dependency onpostgres— thesqlparam is typed structurally (SqlLike). Passdb.sqlor apostgres()client directly.
NOTIFY payload limit (~8 KB)
Postgres caps NOTIFY payloads at ~8000 bytes. Envelopes over a conservative 7500-byte serialized budget are not sent — the adapter reports payload_too_large through onError and the event stays local-only. Publish compact "invalidate" events and refetch state over HTTP instead of shipping large payloads; see the scaling guide.
Client (@apco/realtime/client)
subscribe()
ts
import { subscribe } from "@apco/realtime/client";
const sub = subscribe({
url: "/api/events", // relative works in the browser; absolute required in Node
channels: ["todos"],
events: ["todo:created", "todo:updated", "todo:deleted"],
onEvent: (evt) => console.log(evt.channel, evt.event, evt.data),
});
// later:
sub.close();Signatures:
ts
interface SubscribeOptions {
/** SSE endpoint URL. Relative allowed in a browser; absolute required in Node. */
url: string;
/** Channels to subscribe to (sent as the ?channels= query param). */
channels?: string[];
/**
* Named SSE event types to listen for. EventSource only delivers named
* events to explicit listeners — anything published with a custom `event`
* must be listed here. Default (unnamed) "message" events are always delivered.
*/
events?: string[];
/** Called for every received event. */
onEvent: (event: RealtimeClientEvent) => void;
/** Called when EventSource opens. */
onOpen?: () => void;
/** Called on status transitions. */
onStatus?: (status: RealtimeStatus) => void;
/** Called before a reconnect attempt is scheduled. */
onReconnect?: (attempt: number, delayMs: number) => void;
/** Called on connection errors (before a reconnect is scheduled). */
onError?: (error: Event) => void;
/** Parse JSON payloads into `event.json` when possible. Default: false. */
parseJson?: boolean;
/** Initial reconnect delay in ms. Default: 1000. */
reconnectMs?: number;
/** Maximum reconnect delay in ms. Default: 30_000. */
maxReconnectMs?: number;
}
interface RealtimeClientEvent {
event: string; // SSE event type ("message" for unnamed)
data: string; // payload as a string (JSON.parse it yourself)
json?: unknown; // parsed payload when parseJson is true and parsing succeeds
channel: string | undefined; // from the server envelope, when present
}
type RealtimeStatus = "connecting" | "open" | "reconnecting" | "closed";
interface Subscription {
close(): void; // close the connection and stop reconnecting
status(): RealtimeStatus;
}
function subscribe(opts: SubscribeOptions): Subscription;Behavior notes:
- Named events must be listed. This is an
EventSourceplatform rule, not a library quirk: an event published withevent: "todo:created"is only delivered if"todo:created"is inevents. Forgetting this is the most common "no events arriving" bug. - Reconnects use exponential backoff:
reconnectMs * 2^attempt, capped atmaxReconnectMs(1 s → 2 s → 4 s → ... → 30 s by default); the attempt counter resets on a successful open.onReconnectreceives the attempt number and scheduled delay.close()stops everything. - Resume on reconnect. The client remembers the last SSE
idit received and, on a manual reconnect only, appends it as?lastEventId=so a server with the replay buffer can replay what was missed. (It has to: recreating theEventSourceon reconnect drops the nativeLast-Event-IDheader, so the query param restores it.) Resume is best-effort — cross-instance reconnects and buffer overruns still need an application refetch, so keep a refetch-on-onOpen. - URLs: relative URLs resolve against
location.hrefin the browser. In Node (nolocation) a relative URL throws immediately with a descriptive error — pass an absolute URL. evt.datais always a string: the client unwraps the server's{channel, data}envelope, re-stringifying non-string payloads. PassparseJson: trueto also receiveevt.jsonwhen parsing succeeds.
React hook (@apco/realtime/react)
ts
import { useRealtime } from "@apco/realtime/react";
const { connected } = useRealtime({
url: "/api/events",
channel: "todos",
events: ["todo:created", "todo:updated", "todo:deleted"],
onEvent: (evt) => refetch(),
});Signature:
ts
interface UseRealtimeOptions {
url: string;
channel: string; // single channel (the hook wraps subscribe with channels: [channel])
events?: string[]; // named SSE event types
onEvent: (event: RealtimeClientEvent) => void;
enabled?: boolean; // default true; false tears the connection down
}
type RealtimeStatus = "connecting" | "open" | "reconnecting" | "closed";
function useRealtime(opts: UseRealtimeOptions): { connected: boolean; status: RealtimeStatus };Behavior notes:
connectedflips totruewhen EventSource opens and tofalseon reconnecting, closed, errors, and unmount.onEventis kept in a ref — an unstable callback identity does not resubscribe. The subscription is re-created whenurl,channel,enabled, or the joinedeventslist changes (a new array with the same names does not resubscribe).- Cleans up (closes the EventSource, stops reconnecting) on unmount and dependency changes.
Example
The examples/realtime-todo app is the end-to-end reference: Hono SSR + @apco/db + @apco/realtime, where API routes write to Postgres and publish todo:* events, and the frontend subscribes and refetches. Scaffold its manifest with apco init --template realtime-todo.