Chatpack
Real-time

Real-time with SSE

One EventSource - live messages, automatic reconnection, and missed-message backfill from storage.

GET /stream is a Server-Sent Events endpoint. Each connected user receives message.created / message.updated / message.deleted events for their conversations only - participation is re-checked server-side per event.

No WebSocket server, no Socket.IO, no reconnect code to write.

The optional @chatpack/client package owns this EventSource, reconnects through the browser's native Last-Event-ID behavior, and deduplicates durable messages in its per-client cache. It does not poll or put bearer tokens in the stream URL.

Connect

const events = new EventSource("/api/chat/stream");

// TypeScript: custom event names fall outside EventSourceEventMap, so cast
// the listener parameter to MessageEvent to access `.data`.
events.addEventListener("message.created", (e) => {
  const { message } = JSON.parse((e as MessageEvent).data);
  // dedupe by message.id (delivery is at-least-once), then render
});

events.addEventListener("message.updated", (e) => {
  const { message } = JSON.parse((e as MessageEvent).data);
  // re-render the edited message
});

events.addEventListener("message.deleted", (e) => {
  const { message } = JSON.parse((e as MessageEvent).data);
  // render a tombstone - body is "" and deletedAt is set
});

events.onerror = () => {
  if (events.readyState === EventSource.CLOSED) {
    // Fatal (e.g. 401): the browser will NOT retry. Re-auth, then recreate.
  }
  // Otherwise: dropped connection - EventSource retries automatically with
  // Last-Event-ID and the server replays what was missed.
};

No lost messages

Events are published only after the storage write succeeds (durable-first), and every durable event id is conversationId:seq. On reconnect, EventSource sends Last-Event-ID automatically and the server replays whatever was missed from storage before resuming live delivery.

Delivery is at-least-once - dedupe by message.id in the client.

Auth for the stream

EventSource cannot send custom headers, so your auth hook must resolve the user from what the browser sends automatically - typically a session cookie (same-origin cookies are sent by default; pass { withCredentials: true } for cross-origin).

Bearer-token schemes work for the REST routes but not for /stream - write the hook to accept either credential. Full recipes (including iframe-proof cookies for embedded previews) in Authentication.

Deployment reality check

The default transport is in-process and memoryAdapter is per-process - both assume one long-lived server (a Node server, a single Fly/Railway container, next start, Bun.serve). On serverless/edge platforms (Cloudflare Workers, Vercel/AWS Lambda), each isolate has its own memory: SSE events won't reach connections held by other isolates. Use a database adapter there and poll GET /conversations/:id/messages until a distributed transport ships (e.g. Redis - same public API, no code change). Details in Deployment.

Heartbeats

The handler sends an SSE comment as a heartbeat every 15 seconds by default - tune it via chat.handler({ heartbeatIntervalMs }). This keeps proxies and load balancers from closing idle connections.

Verify it works

# expect ": connected", then events as messages are sent
curl -sN localhost:3000/api/chat/stream -H 'cookie: demo_user=bob'

Send a message as alice in another terminal and watch the message.created event arrive on bob's stream.

On this page