Building a Chat App

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

This page assembles Chatterbox, the chat scenario of this section, into one application you can run: a browser client written in plain TypeScript, a reference server built on Socket.IO and Node.js, PostgreSQL as the durable store, Redis as the shared fast store, and Docker Compose running two server replicas behind nginx. Every one of the twelve message types of the Chatterbox v1 protocol defined on Message Design and Serialization is wired end to end: hello, join, leave, message, edit, react, typing, receipt, presence, history, ack and error. The page ends with a table that tells you what to change to run the same client against each other server stack of the section, and what the XMPP variant replaces.

The code was written and run against Node.js 24 (the container ran 24.22.1), socket.io and socket.io-client 4.8.4, @socket.io/redis-adapter 8.3.0, redis (node-redis) 6.3.0, pg 8.23.1, PostgreSQL 18.6, Redis 8.8.3, nginx 1.28, TypeScript 7.0.2 and esbuild 0.28.2. Everything was checked on 2026-10-24. The protocol is not repeated here; the server design questions it raises (which stack, how to scale, how to secure it) are answered on Socket.IO (Node.js), Chat Architecture, Scaling WebSockets and Security; this page shows them working together.

What the application does

Capability Behaviour in the running application

Sign-in and hello

The login form asks for a name, the client POSTs it to /api/ticket and receives a one-time ticket that expires after 30 seconds. The first envelope on the socket is hello carrying the ticket. A new ticket is fetched for every connection attempt.

Rooms: join, leave

Joining any room name that matches [a-z0-9-]{1,64} creates it. The ack carries the member list and the room’s last seq. Rooms named dm-<a>-<b> are open only to the two named users.

message, ack, idempotency

The client creates a ULID, which becomes the message id forever. The server stores the message and the ack in one PostgreSQL transaction, so a retry with the same id returns the original ack with dup: true and no second broadcast.

edit and react

Only the author may edit. Reactions are added or removed per user and emoji, with conflict and not_found for the repeated cases. Both take a seq from the same per-room counter as messages.

typing

Sent at most once every three seconds while the user types, fire-and-forget; receivers expire it after five seconds.

receipt

delivered is sent after a burst of incoming messages, read when the room is open and the page is visible. The sender’s own messages show sent, delivered or read.

presence

online or away follows the page visibility, doubles as the application heartbeat, and a user becomes offline only 15 seconds after the last connection closed.

history

The newest 50 messages load when a room is first opened; "Load older messages" pages back with the next cursor.

Reconnection and resume

Backoff with full jitter, a fresh ticket, hello with resume, replay of everything after the last seen seq, and retransmission of every unacknowledged envelope with its original id.

Scale-out

Two replicas share Redis through the Socket.IO adapter. A message sent through one replica reaches users connected to the other, and a client whose replica dies resumes on the survivor.

Which scenarios use it

  • Chatterbox is this page. It is the only scenario that needs all of it.

  • Huddle carries its room chat over a WebRTC data channel in the videoconference build, see Building a Videoconference App and WebRTC Data Channels. The ticket flow, the replica layout and the reconnecting client of this page apply unchanged to its signalling server.

  • Pixel Arena can borrow the ticket flow, the replica layout and the reconnect client for its lobby and in-game chat; its game loop is on Building a Multiplayer Game.

Architecture

flowchart LR subgraph Browsers A["Alice (vanilla TypeScript)"] B["Bob (vanilla TypeScript)"] end N["nginx :8080 (round robin)"] subgraph Replicas CA["chat-a (Node.js 24, Socket.IO)"] CB["chat-b (Node.js 24, Socket.IO)"] end R[("Redis: adapter pub/sub, tickets, sessions")] P[("PostgreSQL: messages, events, reactions, acks")] A -- "wss, direct :8081" --> CA B -- "wss via proxy" --> N N --> CA N --> CB CA <--> R CB <--> R CA --> P CB --> P

The split between the two stores is the heart of the design:

  • PostgreSQL holds everything that must survive a restart: each message with its edits applied, the event log used for replay, reactions, the per-room sequence counter and the acks used for idempotency. A single transaction writes a message, its event and its ack, so there is no state in which a client was told "ok" for something that is not stored.

  • Redis holds everything that is shared but disposable: the Socket.IO adapter’s pub/sub channels that fan a broadcast out to every replica, the one-time tickets (30 seconds) and the resume sessions (120 seconds, refreshed by the heartbeat).

  • Each replica holds only per-connection state: the authenticated user, the status and a token bucket. Because it holds nothing else, a replica can be killed and replaced at any moment.

Socket.IO is configured with transports: ["websocket"], so there is no HTTP long-polling and therefore no need for sticky sessions at the proxy (see Proxies, Load Balancers and Deployment). The adapter and the room API are described on Socket.IO (Node.js); the browser client library and the Engine.IO details are on Socket.IO Protocol and Clients.

Design decisions

Decision Reason

One Socket.IO event, cb, carries every envelope

The envelope already has a type, so one event name keeps the protocol identical on every stack. Socket.IO’s own acknowledgement callbacks are not used: the Chatterbox ack envelope carries seq and dup, which a callback cannot express in the same way on other servers.

Connection state recovery is not used

Socket.IO can restore rooms and replay packets after a short disconnect, but the feature belongs to Socket.IO. Chatterbox resume (hello with resume) works the same on every server, survives a replica failure because it lives in Redis, and also covers a full page reload.

seq comes from a rooms.last_seq row, updated by an upsert (INSERT with ON CONFLICT DO UPDATE and RETURNING)

The row lock serialises writers of one room, so commit order equals seq order and there are no gaps caused by rollbacks. Different rooms never block each other.

The client retries with the same id, the server remembers the ack

Together they give effectively-once delivery over an at-least-once transport. The unique key is (user, id) in the acks table.

Envelopes of one socket are handled one at a time

After a reconnect the client sends joins and then the queued messages without waiting. A promise chain per socket guarantees the join finishes before the message that needs it.

Offline after a grace period, decided cluster-wide

After the last socket of a user closes, a timer waits 15 seconds and then asks the cluster (fetchSockets() on a per-user room) whether the user came back, possibly on another replica.

The client talks to a Transport interface

Only transport.ts knows about Socket.IO. A second implementation speaks plain WebSocket, which is how the client runs against every other server page of this section.

Everything the network supplies is rendered with textContent

The page never uses innerHTML for message text, names or reactions, so a message cannot inject markup.

Project layout

chatterbox/
  package.json
  package-lock.json            generated by npm install
  tsconfig.json
  Dockerfile
  .dockerignore
  compose.yaml
  nginx.conf
  public/index.html
  src/
    shared/protocol.ts         envelope and payload types, shared by both halves
    server/store.ts            PostgreSQL: schema, transactions, history
    server/server.ts           HTTP, tickets, Socket.IO, handlers, lifecycle
    client/transport.ts        SocketIoTransport and WebSocketTransport
    client/chatterbox.ts       protocol client: hello, resume, retries, heartbeat
    client/main.ts             the DOM
  test/e2e.mjs                 two headless Chrome users, outage and resume

The server is TypeScript that Node.js runs directly. Since Node.js 22.18 and 24.3 type stripping is enabled by default and prints no warning, and it is marked stable from 24.12. The runtime only removes the types, so the code must use erasable syntax only: import type for types, explicit .ts extensions in imports, no enums, no parameter properties. The erasableSyntaxOnly option in tsconfig.json makes the compiler reject anything else, and npm run typecheck runs in the Docker build before the image is produced. The browser client is the same TypeScript bundled by esbuild.

The project files

package.json

{
  "name": "chatterbox",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=24" },
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build:client": "esbuild src/client/main.ts --bundle --minify --sourcemap --target=es2022 --outfile=public/app.js",
    "start": "node src/server/server.ts"
  },
  "dependencies": {
    "@socket.io/redis-adapter": "8.3.0",
    "pg": "8.23.1",
    "redis": "6.3.0",
    "socket.io": "4.8.4"
  },
  "devDependencies": {
    "@types/node": "24.19.2",
    "@types/pg": "8.23.1",
    "esbuild": "0.28.2",
    "socket.io-client": "4.8.4",
    "typescript": "7.0.2"
  }
}

The engines field records the Node.js version that can run the server’s .ts files. socket.io-client and the build tools are development dependencies because the browser gets a bundle, not node_modules; the runtime image installs production dependencies only.

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2023",
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "types": ["node"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

noEmit because esbuild and Node.js do the transformation and tsc only checks types; verbatimModuleSyntax forces import type where an import is only a type, which is exactly what type stripping needs; moduleResolution: bundler together with allowImportingTsExtensions accepts the .ts suffixes that Node.js requires.

Dockerfile

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
COPY public ./public
RUN npm run typecheck && npm run build:client

FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src/server ./src/server
COPY src/shared ./src/shared
COPY --from=build /app/public ./public
USER node
EXPOSE 8300
CMD ["node", "src/server/server.ts"]

The first stage installs everything, type-checks and bundles the client. The second installs production dependencies, copies only the server sources and the built public/ directory and runs as the unprivileged node user. The server is started as node src/server/server.ts; there is no compile step. A .dockerignore keeps the host’s node_modules and any stale bundle out of the build context:

node_modules
public/app.js
public/app.js.map

nginx.conf

upstream chatterbox {
  server chat-a:8300;
  server chat-b:8300;          # round robin: WebSocket-only Socket.IO needs no sticky sessions
}
map $http_upgrade $connection_upgrade { default upgrade; '' close; }

server {
  listen 80;
  location / {
    proxy_pass http://chatterbox;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_set_header Host $host;
    proxy_set_header Origin $http_origin;
    proxy_read_timeout 120s;    # longer than the 2 x 20 s application heartbeat
  }
}

nginx needs the Upgrade and Connection headers to be forwarded, because it does not pass them to the upstream by default. proxy_read_timeout must exceed the longest silence the application tolerates (two heartbeats, 40 seconds); the default of 60 seconds is enough, 120 leaves headroom. Origin is passed through so the server can check it. Round robin is fine without sticky sessions because Socket.IO uses the WebSocket transport only.

compose.yaml

name: chatterbox
services:
  postgres:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: chatterbox
      POSTGRES_PASSWORD: change-me
      POSTGRES_DB: chatterbox
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U chatterbox"]
      interval: 2s
      timeout: 2s
      retries: 20

  redis:
    image: redis:8.8.3-alpine
    command: ["redis-server", "--save", "", "--appendonly", "no"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 2s
      retries: 15

  chat-a: &chat
    build: .
    environment: &env
      PORT: "8300"
      INSTANCE: chat-a
      REDIS_URL: redis://redis:6379
      DATABASE_URL: postgres://chatterbox:change-me@postgres:5432/chatterbox
      ALLOWED_ORIGINS: http://localhost:${HTTP_PORT:-8080},http://localhost:${A_PORT:-8081},http://localhost:${B_PORT:-8082}
    ports:
      - "${A_PORT:-8081}:8300"
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_healthy }

  chat-b:
    <<: *chat
    environment:
      <<: *env
      INSTANCE: chat-b
    ports:
      - "${B_PORT:-8082}:8300"

  proxy:
    image: nginx:1.28-alpine
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "${HTTP_PORT:-8080}:80"
    depends_on: [chat-a, chat-b]

volumes:
  pgdata:

PostgreSQL 18 changed the layout of its image: the data directory is a versioned subdirectory of /var/lib/postgresql, so the volume is mounted there rather than on /var/lib/postgresql/data. The two replicas share one definition through a YAML anchor and differ only by INSTANCE and the published port. The three host ports can be overridden with HTTP_PORT, A_PORT and B_PORT; ALLOWED_ORIGINS is built from the same variables so the Origin allow-list always matches the ports in use. The database password is a placeholder (change-me); a real deployment uses a secret and does not publish the database or Redis ports at all, as here. For the Compose features used see Compose in Practice.

The shared protocol types

// Chatterbox wire protocol v1 types, shared by the server and the browser client.
export const PROTOCOL_VERSION = 1;
export const SUBPROTOCOL = 'chatterbox.v1';

export type MessageType =
  | 'hello' | 'join' | 'leave' | 'message' | 'edit' | 'react'
  | 'typing' | 'receipt' | 'presence' | 'history' | 'ack' | 'error';

export type ErrorCode =
  | 'bad_request' | 'unsupported_version' | 'unauthorized' | 'forbidden' | 'not_found'
  | 'conflict' | 'too_large' | 'rate_limited' | 'internal' | 'unavailable';

export type Status = 'online' | 'away' | 'offline';

export interface Envelope<P = Record<string, any>> {
  v: 1;
  type: MessageType;
  id: string;
  room: string | null;
  ts: number;
  payload: P;
}

export interface Attachment { url: string; name: string; mime: string; size: number }
export interface Member { userId: string; name: string; status: Status }

export interface ChatPayload {
  from: { userId: string; name: string };
  text: string;
  replyTo: string | null;
  attachments: Attachment[];
  seq: number;
  edited: boolean;
}
export interface EditPayload { target: string; text: string; seq: number; editedAt: number }
export interface ReactPayload { target: string; emoji: string; op: 'add' | 'remove'; from: { userId: string }; seq: number }

export interface HelloReply {
  ref: string; userId: string; name: string; sessionId: string; heartbeatMs: number;
  maxFrameBytes: number; resumed: boolean; rooms: string[]; instance?: string;
}
export interface AckPayload { ref: string; ok: true; seq: number | null; dup: boolean; result: Record<string, any> }
export interface ErrorPayload { ref: string | null; code: ErrorCode; message: string; retryable: boolean }
export interface HistoryResult { messages: Envelope<ChatPayload>[]; hasMore: boolean; next: number | null }

export const ROOM_RE = /^[a-z0-9-]{1,64}$/;
export const ID_RE = /^[0-9A-Za-z_]{26,36}$/;

Both halves import these types, so a payload field renamed on the server is a compile error in the client. They mirror the table on Message Design and Serialization; that page also shows how to validate envelopes at runtime with a schema library, which this example replaces with the small checks in the handlers to keep the code short.

The PostgreSQL store

// PostgreSQL store: the durable truth for messages, edits, reactions, sequence numbers and acks.
import pg from 'pg';
import type { Attachment, ChatPayload, ErrorCode } from '../shared/protocol.ts';

export const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL ?? 'postgres://chatterbox:change-me@localhost:5432/chatterbox',
  max: 10,
});

export class StoreError extends Error {
  code: ErrorCode;
  constructor(code: ErrorCode, message: string) {
    super(message);
    this.code = code;
  }
}

const SCHEMA = `
CREATE TABLE IF NOT EXISTS rooms (
  name     text PRIMARY KEY,
  last_seq bigint NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS events (            -- replay log: every message, edit and react
  room    text   NOT NULL,
  seq     bigint NOT NULL,
  id      text   NOT NULL,
  type    text   NOT NULL,
  author  text   NOT NULL,
  payload jsonb  NOT NULL,
  PRIMARY KEY (room, seq)
);
CREATE TABLE IF NOT EXISTS messages (          -- current state of each message (edits applied)
  id          text PRIMARY KEY,
  room        text   NOT NULL,
  seq         bigint NOT NULL,
  author      text   NOT NULL,
  author_name text   NOT NULL,
  text        text   NOT NULL,
  reply_to    text,
  attachments jsonb  NOT NULL DEFAULT '[]',
  edited_at   bigint,
  UNIQUE (room, seq)
);
CREATE TABLE IF NOT EXISTS reactions (
  message_id text NOT NULL,
  user_id    text NOT NULL,
  emoji      text NOT NULL,
  PRIMARY KEY (message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS acks (              -- idempotency: the original ack of every request id
  user_id    text NOT NULL,
  id         text NOT NULL,
  ack        jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, id)
);`;

export async function migrate(): Promise<void> {
  const c = await pool.connect();
  try {
    await c.query('SELECT pg_advisory_lock(264001)');   // two replicas may start at the same time
    await c.query(SCHEMA);
    await c.query('SELECT pg_advisory_unlock(264001)');
  } finally {
    c.release();
  }
}

export interface AckRecord { seq: number | null; result: Record<string, unknown> }
export interface Author { userId: string; name: string }
type EventType = 'message' | 'edit' | 'react';

export async function findAck(userId: string, id: string): Promise<AckRecord | null> {
  const { rows } = await pool.query('SELECT ack FROM acks WHERE user_id = $1 AND id = $2', [userId, id]);
  return rows[0]?.ack ?? null;
}

export async function purgeOldAcks(): Promise<void> {
  await pool.query("DELETE FROM acks WHERE created_at < now() - interval '24 hours'");
}

export async function lastSeq(room: string): Promise<number> {
  const { rows } = await pool.query('SELECT last_seq FROM rooms WHERE name = $1', [room]);
  return Number(rows[0]?.last_seq ?? 0);
}

export interface Appended { dup: boolean; seq: number | null; payload: Record<string, any> | null }

// One transaction: take the room's next seq (the row lock serialises writers), apply the change,
// append to the replay log and remember the ack. Commit order equals seq order.
async function append(
  userId: string, reqId: string, room: string, type: EventType,
  apply: (c: pg.PoolClient, seq: number) => Promise<Record<string, any>>,
): Promise<Appended> {
  const prior = await findAck(userId, reqId);
  if (prior) return { dup: true, seq: prior.seq, payload: null };
  const c = await pool.connect();
  try {
    await c.query('BEGIN');
    const next = await c.query(
      `INSERT INTO rooms (name, last_seq) VALUES ($1, 1)
       ON CONFLICT (name) DO UPDATE SET last_seq = rooms.last_seq + 1 RETURNING last_seq`, [room]);
    const seq = Number(next.rows[0].last_seq);
    const payload = await apply(c, seq);
    await c.query('INSERT INTO events (room, seq, id, type, author, payload) VALUES ($1, $2, $3, $4, $5, $6)',
      [room, seq, reqId, type, userId, payload]);
    await c.query('INSERT INTO acks (user_id, id, ack) VALUES ($1, $2, $3)', [userId, reqId, { seq, result: {} }]);
    await c.query('COMMIT');
    return { dup: false, seq, payload };
  } catch (err) {
    await c.query('ROLLBACK').catch(() => {});
    if ((err as { code?: string }).code === '23505') {          // unique violation
      const again = await findAck(userId, reqId);                // a concurrent duplicate won the race
      if (again) return { dup: true, seq: again.seq, payload: null };
      throw new StoreError('conflict', 'id already used');
    }
    throw err;
  } finally {
    c.release();
  }
}

export interface NewMessage { text: string; replyTo: string | null; attachments: Attachment[] }

export function addMessage(from: Author, room: string, id: string, m: NewMessage): Promise<Appended> {
  return append(from.userId, id, room, 'message', async (c, seq) => {
    if (m.replyTo) {
      const r = await c.query('SELECT 1 FROM messages WHERE id = $1 AND room = $2', [m.replyTo, room]);
      if (r.rowCount === 0) throw new StoreError('not_found', 'replyTo is not a message of this room');
    }
    const payload: ChatPayload = { from, text: m.text, replyTo: m.replyTo, attachments: m.attachments, seq, edited: false };
    await c.query(
      `INSERT INTO messages (id, room, seq, author, author_name, text, reply_to, attachments)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
      [id, room, seq, from.userId, from.name, m.text, m.replyTo, JSON.stringify(m.attachments)]);
    return payload as unknown as Record<string, any>;
  });
}

export function editMessage(from: Author, room: string, id: string, target: string, text: string): Promise<Appended> {
  return append(from.userId, id, room, 'edit', async (c, seq) => {
    const r = await c.query('SELECT author FROM messages WHERE id = $1 AND room = $2 FOR UPDATE', [target, room]);
    if (r.rowCount === 0) throw new StoreError('not_found', 'unknown target');
    if (r.rows[0].author !== from.userId) throw new StoreError('forbidden', 'only the author can edit');
    const editedAt = Date.now();
    await c.query('UPDATE messages SET text = $1, edited_at = $2 WHERE id = $3', [text, editedAt, target]);
    return { target, text, seq, editedAt };
  });
}

export function reactToMessage(from: Author, room: string, id: string, target: string, emoji: string, op: 'add' | 'remove'): Promise<Appended> {
  return append(from.userId, id, room, 'react', async (c, seq) => {
    const r = await c.query('SELECT 1 FROM messages WHERE id = $1 AND room = $2', [target, room]);
    if (r.rowCount === 0) throw new StoreError('not_found', 'unknown target');
    if (op === 'add') {
      const ins = await c.query('INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING', [target, from.userId, emoji]);
      if (ins.rowCount === 0) throw new StoreError('conflict', 'reaction already added');
    } else {
      const del = await c.query('DELETE FROM reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3', [target, from.userId, emoji]);
      if (del.rowCount === 0) throw new StoreError('not_found', 'reaction not present');
    }
    return { target, emoji, op, from: { userId: from.userId }, seq };
  });
}

export interface StoredEvent { type: EventType; id: string; payload: Record<string, any> }

export async function eventsSince(room: string, since: number, limit = 500): Promise<StoredEvent[]> {
  const { rows } = await pool.query(
    'SELECT type, id, payload FROM events WHERE room = $1 AND seq > $2 ORDER BY seq LIMIT $3', [room, since, limit]);
  return rows;
}

export interface HistoryPage { messages: { id: string; payload: ChatPayload }[]; hasMore: boolean; next: number | null }

const MAX_PAGE_BYTES = 262144;

export async function history(room: string, before: number | null, limit: number): Promise<HistoryPage> {
  const { rows } = await pool.query(
    `SELECT id, seq, author, author_name, text, reply_to, attachments, edited_at FROM messages
     WHERE room = $1 AND ($2::bigint IS NULL OR seq < $2) ORDER BY seq DESC LIMIT $3`,
    [room, before, limit + 1]);
  const page: { id: string; payload: ChatPayload }[] = [];
  let bytes = 0;
  let trimmed = rows.length > limit;
  for (const r of rows.slice(0, limit)) {
    const payload: ChatPayload = {
      from: { userId: r.author, name: r.author_name }, text: r.text, replyTo: r.reply_to,
      attachments: r.attachments, seq: Number(r.seq), edited: r.edited_at !== null,
    };
    bytes += Buffer.byteLength(JSON.stringify(payload)) + 120;     // + envelope overhead
    if (bytes > MAX_PAGE_BYTES && page.length > 0) { trimmed = true; break; }
    page.push({ id: r.id, payload });
  }
  page.reverse();                                                  // ascending seq
  return { messages: page, hasMore: trimmed, next: trimmed ? page[0]!.payload.seq : null };
}

How it works:

  • Schema. rooms holds the counter, events the replay log (one row per message, edit and reaction, keyed by room and seq), messages the current state of each message with edits applied, reactions one row per user and emoji, and acks the original ack of every request. The migration runs under a session advisory lock because both replicas start at the same time.

  • append(). The single path for message, edit and react. It first looks the ack up (the cheap duplicate case), then opens a transaction, takes the next seq (the upsert locks the room’s row until commit), lets the caller apply its change, appends the event and stores the ack. If the caller throws, the whole transaction rolls back, including the seq increment, so an error never burns a sequence number.

  • Races. Two copies of the same request sent at the same moment both pass the first lookup. One commits; the other hits the primary key of acks, rolls back and re-reads the winner’s ack, which it returns with dup: true. A unique violation without a stored ack means the id is already used by another user’s message, which is a conflict.

  • History. A page is read newest first with LIMIT n + 1 to know whether older rows exist, trimmed so the encoded page stays under the 262144-byte limit the clients must accept, and returned in ascending order. Because edits and reactions consume seq values too, the seq values on a page have gaps; the next cursor is the seq of the oldest message returned.

The Socket.IO server

// Chatterbox v1 on Socket.IO 4.8: every envelope travels as one "cb" event.
// PostgreSQL keeps messages; Redis carries the Socket.IO adapter, tickets and resume sessions.
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { randomBytes } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { createClient } from 'redis';
import { Server, type Socket } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { ID_RE, ROOM_RE, type Envelope, type ErrorCode, type MessageType, type Status } from '../shared/protocol.ts';
import * as store from './store.ts';
import { StoreError } from './store.ts';

const PORT = Number(process.env.PORT ?? 8300);
const INSTANCE = process.env.INSTANCE ?? `node-${PORT}`;
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
const ORIGINS = (process.env.ALLOWED_ORIGINS ?? 'http://localhost:8300').split(',');
const HEARTBEAT_MS = 20_000;
const MAX_FRAME = 16_384;
const SESSION_TTL_S = 120;
const OFFLINE_GRACE_MS = 15_000;

interface User { userId: string; name: string }
interface SocketData { user?: User; status: Status; sessionId?: string }
type Wire = { cb: (env: Envelope) => void };
type CbSocket = Socket<Wire, Wire, Record<string, never>, SocketData>;
interface Runtime { lastSeen: number; tokens: number; refilledAt: number; limited: number[]; queue: Promise<void>; helloTimer?: NodeJS.Timeout }

await store.migrate();
const db = createClient({ url: REDIS_URL });
await db.connect();
const pub = db.duplicate();
const sub = db.duplicate();
await Promise.all([pub.connect(), sub.connect()]);

// ---- HTTP: static client, one-time tickets, health ----------------------------------------------------
const STATIC: Record<string, [string, string]> = {
  '/': ['public/index.html', 'text/html; charset=utf-8'],
  '/app.js': ['public/app.js', 'text/javascript; charset=utf-8'],
  '/app.js.map': ['public/app.js.map', 'application/json'],
};

async function readBody(req: IncomingMessage): Promise<string> {
  let body = '';
  for await (const chunk of req) { body += chunk; if (body.length > 1024) break; }
  return body;
}

async function onRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
  const path = new URL(req.url ?? '/', 'http://x').pathname;
  if (path === '/healthz') { res.end(`ok ${INSTANCE}\n`); return; }
  if (path === '/api/ticket' && req.method === 'POST') {
    const origin = req.headers.origin;
    if (origin !== undefined && !ORIGINS.includes(origin)) { res.writeHead(403).end(); return; }
    const name = String(JSON.parse((await readBody(req)) || '{}').name ?? '');
    if (!/^[a-z0-9]{1,32}$/.test(name)) { res.writeHead(400).end('name must match [a-z0-9]{1,32}'); return; }
    // Placeholder login: a real service authenticates the user here (session cookie, OIDC) before issuing a ticket.
    const ticket = 't_' + randomBytes(16).toString('hex');
    await db.set(`cb:ticket:${ticket}`, name, { EX: 30 });
    res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ticket }));
    return;
  }
  const file = STATIC[path];
  if (!file) { res.writeHead(404).end(); return; }
  res.writeHead(200, { 'content-type': file[1], 'cache-control': 'no-cache' }).end(await readFile(file[0]));
}

const httpServer = createServer((req, res) => {
  onRequest(req, res).catch((err) => { console.error(err); if (!res.headersSent) res.writeHead(500); res.end(); });
});

const io = new Server<Wire, Wire, Record<string, never>, SocketData>(httpServer, {
  maxHttpBufferSize: MAX_FRAME,                       // bytes per packet; a larger one closes the socket
  transports: ['websocket'],                          // no long-polling, so no sticky sessions
  allowRequest: (req, cb) => cb(null, req.headers.origin === undefined || ORIGINS.includes(req.headers.origin)),
  adapter: createAdapter(pub, sub),
});

// ---- helpers -------------------------------------------------------------------------------------------
const runtime = new WeakMap<CbSocket, Runtime>();
const sid = () => 's_' + randomBytes(13).toString('hex').toUpperCase();
const roomOf = (room: string) => `r:${room}`;
const userRoom = (userId: string) => `user:${userId}`;
const sessKey = (id: string) => `cb:session:${id}`;

type Target = { emit: (ev: 'cb', env: Envelope) => unknown };
const send = (target: Target, type: MessageType, room: string | null, payload: Record<string, unknown>, id = sid()) =>
  target.emit('cb', { v: 1, type, id, room, ts: Date.now(), payload });
const ack = (socket: CbSocket, req: Envelope, seq: number | null, dup: boolean, result: Record<string, unknown> = {}) =>
  send(socket, 'ack', req.room, { ref: req.id, ok: true, seq, dup, result });
const fail = (socket: CbSocket, ref: string | null, code: ErrorCode, message: string, retryable = false) =>
  send(socket, 'error', null, { ref, code, message, retryable });
const closeAfter = (socket: CbSocket) => setTimeout(() => socket.disconnect(true), 50);   // let the error frame flush

const chatRooms = (socket: CbSocket) => [...socket.rooms].filter((r) => r.startsWith('r:')).map((r) => r.slice(2));
const isMember = (socket: CbSocket, room: string | null): room is string => room !== null && socket.rooms.has(roomOf(room));

async function saveSession(socket: CbSocket): Promise<void> {
  const { sessionId, user } = socket.data;
  if (sessionId && user) await db.set(sessKey(sessionId), JSON.stringify({ userId: user.userId, rooms: chatRooms(socket) }), { EX: SESSION_TTL_S });
}

async function members(room: string) {
  const seen = new Map<string, { userId: string; name: string; status: Status }>();
  for (const s of await io.in(roomOf(room)).fetchSockets()) {        // works across replicas
    if (s.data.user) seen.set(s.data.user.userId, { ...s.data.user, status: s.data.status });
  }
  return [...seen.values()];
}

async function replay(socket: CbSocket, room: string, since: number): Promise<void> {
  for (const e of await store.eventsSince(room, since)) send(socket, e.type, room, e.payload, e.id);
}

// ---- handlers ------------------------------------------------------------------------------------------
type Handler = (socket: CbSocket, env: Envelope) => Promise<void>;

const handlers: Partial<Record<MessageType, Handler>> = {
  async hello(socket, env) {
    if (socket.data.user) return void fail(socket, env.id, 'conflict', 'hello already completed');
    const ticket = String(env.payload.token ?? '');
    const name = /^t_[0-9a-f]{32}$/.test(ticket) ? await db.getDel(`cb:ticket:${ticket}`) : null;   // one-time
    if (!name) { fail(socket, env.id, 'unauthorized', 'invalid or expired ticket'); return void closeAfter(socket); }
    clearTimeout(runtime.get(socket)!.helloTimer);
    const user: User = { userId: `u_${name}`, name };
    socket.data.user = user;
    socket.join(userRoom(user.userId));
    let resumed = false;
    const resume = env.payload.resume as { sessionId?: string; rooms?: Record<string, number> } | undefined;
    const saved = resume?.sessionId ? JSON.parse((await db.get(sessKey(resume.sessionId))) ?? 'null') : null;
    if (saved?.userId === user.userId) {
      resumed = true;
      socket.data.sessionId = resume!.sessionId;
      for (const room of saved.rooms as string[]) socket.join(roomOf(room));
    }
    socket.data.sessionId ??= 'sess_' + randomBytes(8).toString('hex');
    await saveSession(socket);
    send(socket, 'hello', null, {
      ref: env.id, ...user, sessionId: socket.data.sessionId, heartbeatMs: HEARTBEAT_MS, maxFrameBytes: MAX_FRAME,
      resumed, rooms: chatRooms(socket), instance: INSTANCE,
    });
    if (resumed) {
      for (const room of chatRooms(socket)) {
        send(socket.to(roomOf(room)), 'presence', room, { ...user, status: socket.data.status });
        await replay(socket, room, resume!.rooms?.[room] ?? 0);
      }
    }
  },

  async join(socket, env) {
    const room = env.room;
    if (room === null || !ROOM_RE.test(room)) return void fail(socket, env.id, 'bad_request', 'invalid room');
    const dm = /^dm-([a-z0-9]+)-([a-z0-9]+)$/.exec(room);
    if (dm && !dm.slice(1).includes(socket.data.user!.name)) return void fail(socket, env.id, 'forbidden', 'not your DM');
    const already = isMember(socket, room);
    socket.join(roomOf(room));
    await saveSession(socket);
    ack(socket, env, null, false, { members: await members(room), lastSeq: await store.lastSeq(room) });
    const since = env.payload.since;
    if (typeof since === 'number') await replay(socket, room, since);
    if (!already) send(socket.to(roomOf(room)), 'presence', room, { ...socket.data.user, status: socket.data.status });
  },

  async leave(socket, env) {
    if (!isMember(socket, env.room)) return void fail(socket, env.id, 'not_found', 'not a member');
    socket.leave(roomOf(env.room));
    await saveSession(socket);
    ack(socket, env, null, false);
  },

  async message(socket, env) {
    const { text, replyTo = null, attachments = [] } = env.payload;
    if (typeof text !== 'string' || text.length < 1 || text.length > 4000) return void fail(socket, env.id, 'bad_request', 'text must be 1..4000 characters');
    if (replyTo !== null && typeof replyTo !== 'string') return void fail(socket, env.id, 'bad_request', 'replyTo must be a string or null');
    if (!Array.isArray(attachments) || attachments.length > 5 || !attachments.every(validAttachment)) return void fail(socket, env.id, 'bad_request', 'invalid attachments');
    if (!isMember(socket, env.room)) return void fail(socket, env.id, 'forbidden', 'join the room first');
    const out = await store.addMessage(socket.data.user!, env.room, env.id, { text, replyTo, attachments });
    ack(socket, env, out.seq, out.dup);
    if (!out.dup) send(io.to(roomOf(env.room)), 'message', env.room, out.payload!, env.id);   // sender included, across replicas
  },

  async edit(socket, env) {
    const { target, text } = env.payload;
    if (typeof target !== 'string' || typeof text !== 'string' || text.length < 1 || text.length > 4000) return void fail(socket, env.id, 'bad_request', 'target and text (1..4000) required');
    if (!isMember(socket, env.room)) return void fail(socket, env.id, 'forbidden', 'join the room first');
    const out = await store.editMessage(socket.data.user!, env.room, env.id, target, text);
    ack(socket, env, out.seq, out.dup);
    if (!out.dup) send(io.to(roomOf(env.room)), 'edit', env.room, out.payload!, env.id);
  },

  async react(socket, env) {
    const { target, emoji, op } = env.payload;
    const graphemes = typeof emoji === 'string' ? [...new Intl.Segmenter().segment(emoji)].length : 0;
    if (typeof target !== 'string' || graphemes !== 1 || (op !== 'add' && op !== 'remove')) return void fail(socket, env.id, 'bad_request', 'target, one-grapheme emoji and op required');
    if (!isMember(socket, env.room)) return void fail(socket, env.id, 'forbidden', 'join the room first');
    const out = await store.reactToMessage(socket.data.user!, env.room, env.id, target, emoji, op);
    ack(socket, env, out.seq, out.dup);
    if (!out.dup) send(io.to(roomOf(env.room)), 'react', env.room, out.payload!, env.id);
  },

  async typing(socket, env) {                      // fire-and-forget: no ack, errors are dropped
    const state = env.payload.state;
    if (isMember(socket, env.room) && (state === 'start' || state === 'stop')) {
      send(socket.to(roomOf(env.room)), 'typing', env.room, { userId: socket.data.user!.userId, state });
    }
  },

  async receipt(socket, env) {                     // fire-and-forget, cumulative
    const { kind, upTo } = env.payload;
    if (!isMember(socket, env.room) || (kind !== 'delivered' && kind !== 'read') || !Number.isInteger(upTo)) return;
    if (upTo > (await store.lastSeq(env.room))) return;
    send(socket.to(roomOf(env.room)), 'receipt', env.room, { userId: socket.data.user!.userId, kind, upTo });
  },

  async presence(socket, env) {
    const status = env.payload.status;
    if (status !== 'online' && status !== 'away') return void fail(socket, env.id, 'bad_request', 'status must be online or away');
    const changed = socket.data.status !== status;
    socket.data.status = status;
    await saveSession(socket);                     // the heartbeat also keeps the resume session alive
    if (changed) for (const room of chatRooms(socket)) send(socket.to(roomOf(room)), 'presence', room, { ...socket.data.user, status });
    ack(socket, env, null, false);
  },

  async history(socket, env) {
    if (!isMember(socket, env.room)) return void fail(socket, env.id, 'forbidden', 'join the room first');
    const { before = null, limit = 50 } = env.payload;
    if ((before !== null && !Number.isInteger(before)) || !Number.isInteger(limit) || limit < 1 || limit > 100) return void fail(socket, env.id, 'bad_request', 'before: int|null, limit: 1..100');
    const page = await store.history(env.room, before, limit);
    const messages = page.messages.map((m) => ({ v: 1, type: 'message', id: m.id, room: env.room, ts: Date.now(), payload: m.payload }));
    ack(socket, env, null, false, { messages, hasMore: page.hasMore, next: page.next });
  },
};

function validAttachment(a: any): boolean {
  return a !== null && typeof a === 'object' && typeof a.url === 'string' && a.url.startsWith('https://') &&
    typeof a.name === 'string' && typeof a.mime === 'string' && Number.isInteger(a.size);
}

// ---- connection lifecycle ------------------------------------------------------------------------------
io.on('connection', (socket) => {
  socket.data.status = 'online';
  const rt: Runtime = { lastSeen: Date.now(), tokens: 40, refilledAt: Date.now(), limited: [], queue: Promise.resolve() };
  runtime.set(socket, rt);
  rt.helloTimer = setTimeout(() => { fail(socket, null, 'unauthorized', 'hello not received within 5 s'); closeAfter(socket); }, 5000);

  socket.on('cb', (env) => {
    const now = Date.now();
    rt.lastSeen = now;
    rt.tokens = Math.min(40, rt.tokens + ((now - rt.refilledAt) / 1000) * 20);   // 20 tokens/s, burst 40
    rt.refilledAt = now;
    const ref = typeof env?.id === 'string' ? env.id : null;
    if (rt.tokens < 1) {                                                          // checked before deduplication
      rt.limited = rt.limited.filter((t) => now - t < 10_000).concat(now);
      fail(socket, ref, 'rate_limited', 'too many envelopes', true);
      if (rt.limited.length >= 10) closeAfter(socket);                            // protocol: close 4429
      return;
    }
    rt.tokens -= 1;
    rt.queue = rt.queue.then(() => dispatch(socket, env));                        // one at a time, in arrival order
  });

  // "disconnecting" fires while socket.rooms is still populated.
  socket.on('disconnecting', () => {
    clearTimeout(rt.helloTimer);
    const user = socket.data.user;
    if (!user) return;
    const rooms = chatRooms(socket);
    void saveSession(socket);                                                     // keeps the session for 120 s
    setTimeout(async () => {                                                      // offline only after a 15 s grace
      if ((await io.in(userRoom(user.userId)).fetchSockets()).length > 0) return; // reconnected, maybe on another replica
      for (const room of rooms) send(io.to(roomOf(room)), 'presence', room, { ...user, status: 'offline' });
    }, OFFLINE_GRACE_MS).unref();
  });
});

async function dispatch(socket: CbSocket, env: Envelope): Promise<void> {
  try {
    if (env === null || typeof env !== 'object') return void fail(socket, null, 'bad_request', 'not an envelope');
    const ref = typeof env.id === 'string' ? env.id : null;
    if (env.v !== 1) { fail(socket, ref, 'unsupported_version', 'v must be 1'); return void closeAfter(socket); }   // protocol: close 4426
    if (ref === null || !ID_RE.test(ref) || env.payload === null || typeof env.payload !== 'object' ||
        (env.room !== null && typeof env.room !== 'string')) {
      return void fail(socket, ref, 'bad_request', 'bad id, room or payload');
    }
    if (env.type !== 'hello' && !socket.data.user) { fail(socket, ref, 'unauthorized', 'send hello first'); return void closeAfter(socket); }
    const handler = handlers[env.type];
    if (!handler) return void fail(socket, ref, 'bad_request', `unknown type ${String(env.type)}`);
    await handler(socket, env);
  } catch (err) {
    if (err instanceof StoreError) return void fail(socket, env?.id ?? null, err.code, err.message);
    console.error(`[${INSTANCE}]`, err);
    fail(socket, env?.id ?? null, 'internal', 'internal error', true);
  }
}

// Application heartbeat: close sockets that sent nothing for 2 x heartbeatMs.
setInterval(() => {
  for (const [, socket] of io.sockets.sockets) {
    const rt = runtime.get(socket as CbSocket);
    if (rt && Date.now() - rt.lastSeen > 2 * HEARTBEAT_MS) socket.disconnect(true);
  }
}, HEARTBEAT_MS / 2).unref();
setInterval(() => void store.purgeOldAcks(), 3_600_000).unref();

httpServer.listen(PORT, () => console.log(`[${INSTANCE}] listening on ${PORT}`));
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
  process.on(sig, () => {
    send(io, 'error', null, { ref: null, code: 'unavailable', message: 'server restarting', retryable: true });   // protocol: close 4503
    setTimeout(() => io.close(() => process.exit(0)), 100);
  });
}

HTTP: the static client and tickets

The same process serves the three static files and one API call. POST /api/ticket checks the Origin header against the allow-list, validates the name and stores a random ticket in Redis for 30 seconds. It is the stand-in for the application’s real login: the comment marks the place where a session cookie, an OIDC token or a database lookup belongs. Browsers cannot add headers to a WebSocket handshake, which is why the credential travels in the first frame instead; see Authentication and Authorization for the alternatives.

The connection lifecycle

  • Handshake. allowRequest rejects a browser whose Origin is not on the list, the defence against cross-site WebSocket hijacking; requests without an Origin (non-browser clients) pass and must still present a valid ticket.

  • Hello deadline. A 5 second timer closes a socket that never says hello. hello consumes the ticket with GETDEL, so a ticket works once even if two sockets race for it.

  • Serial handling and rate limit. Every cb event first goes through a token bucket (20 tokens per second, burst 40), which is checked before deduplication, then joins a per-socket promise chain. Ten rate_limited errors within ten seconds close the socket.

  • Validation. dispatch() rejects anything that is not an object with v 1, an id of 26 to 36 characters, a payload object and a string-or-null room. A frame before hello gets unauthorized and the socket is closed. The Socket.IO option maxHttpBufferSize: 16384 enforces the frame limit below the application.

  • Disconnect. The disconnecting event fires while socket.rooms is still populated, so it is where the session is saved for 120 seconds and the 15 second offline timer starts.

  • Heartbeat. Socket.IO pings the transport by itself; in addition a timer closes sockets that sent no envelope for two heartbeat intervals, which is what the client’s presence heartbeat prevents.

  • Shutdown. On SIGTERM the server sends unavailable and closes, which makes clients reconnect to another replica at once.

Handlers

  • hello validates the ticket, restores the session when resume.sessionId belongs to the same user, joins the socket to its rooms and answers with the hello reply (including the extra instance field, which receivers ignore). After a resumed hello it announces the user as online again and replays every event after the client’s lastSeq for each room.

  • join creates the room on first use, refuses someone else’s DM, answers with the member list and the last seq, replays from since when given, and tells the room about a new member only when the user was not already in it.

  • message, edit and react validate, check membership, call the store, send the ack, and broadcast with io.to(room), which reaches the sender and the sockets on the other replica through the adapter. A duplicate is acked with dup: true and not broadcast.

  • typing and receipt are fire-and-forget: invalid input is dropped silently and the relay uses socket.to(room), which excludes the sender. A receipt beyond the room’s last seq is dropped.

  • presence changes the stored status, refreshes the resume session (so the heartbeat keeps it alive) and broadcasts only when the status changed.

  • history returns the page as the ack’s result.

The browser client

Transport

// The only part of the client that knows which server stack it talks to.
import { io, type Socket } from 'socket.io-client';
import { SUBPROTOCOL, type Envelope } from '../shared/protocol.ts';

export interface Transport {
  onopen: () => void;
  onenvelope: (env: Envelope) => void;
  onclose: (reason: string) => void;
  open(): void;            // always a brand-new connection; the client core owns the retry policy
  send(env: Envelope): void;
  close(): void;
}

// Socket.IO server (this page's reference server): one "cb" event per envelope.
export class SocketIoTransport implements Transport {
  onopen = () => {};
  onenvelope = (_env: Envelope) => {};
  onclose = (_reason: string) => {};
  private socket: Socket | null = null;
  private readonly url: string;
  private readonly event: string;
  private readonly path: string | undefined;

  // `event` is the Socket.IO event that carries envelopes; other server pages may use another name.
  constructor(url: string, event = 'cb', path?: string) {
    this.url = url;
    this.event = event;
    this.path = path;
  }

  open(): void {
    const socket = io(this.url, { transports: ['websocket'], reconnection: false, ...(this.path && { path: this.path }) });
    this.socket = socket;
    socket.on('connect', () => this.onopen());
    socket.on(this.event, (env: Envelope) => this.onenvelope(env));
    socket.on('disconnect', (reason) => this.onclose(reason));
    socket.on('connect_error', (err) => this.onclose(err.message));
  }
  send(env: Envelope): void { this.socket?.emit(this.event, env); }
  close(): void { this.socket?.close(); }
}

// Any server that speaks Chatterbox v1 over a plain WebSocket (ws, uWebSockets.js, Spring, Reverb, ...).
export class WebSocketTransport implements Transport {
  onopen = () => {};
  onenvelope = (_env: Envelope) => {};
  onclose = (_reason: string) => {};
  private ws: WebSocket | null = null;

  private readonly url: string;
  constructor(url: string) { this.url = url; }

  open(): void {
    const ws = new WebSocket(this.url, SUBPROTOCOL);
    this.ws = ws;
    ws.onopen = () => this.onopen();
    ws.onmessage = (e) => { if (typeof e.data === 'string') this.onenvelope(JSON.parse(e.data)); };
    ws.onclose = (e) => this.onclose(`${e.code} ${e.reason}`.trim());
  }
  send(env: Envelope): void { this.ws?.send(JSON.stringify(env)); }
  close(): void { this.ws?.close(); }
}

The interface is the whole contract between the protocol client and a server stack: open a brand-new connection, send an envelope, report an envelope, report a close. SocketIoTransport turns the Socket.IO events into those calls (the event name and the engine path are constructor arguments, so it can talk to other Socket.IO servers) with Socket.IO’s own reconnection switched off (reconnection: false): the protocol client owns the retry policy so that it behaves identically on every transport and can fetch a fresh ticket before each attempt. WebSocketTransport is the same thing over the browser WebSocket, requesting the chatterbox.v1 subprotocol.

Protocol client

// Protocol client: hello and resume, request/ack correlation, retry with the same id, heartbeat, backoff.
import type {
  AckPayload, Envelope, ErrorPayload, HelloReply, HistoryResult, MessageType, Status,
} from '../shared/protocol.ts';
import type { Transport } from './transport.ts';

export type ConnectionState = 'connecting' | 'ready' | 'reconnecting' | 'closed';
type Listener<T> = (value: T) => void;

const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
export function ulid(now = Date.now()): string {
  let time = '';
  for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32)) time = CROCKFORD[t % 32] + time;
  const rnd = crypto.getRandomValues(new Uint8Array(16));
  return time + Array.from(rnd, (b) => CROCKFORD[b % 32]).join('');
}

interface Pending {
  env: Envelope;
  sentAt: number | null;
  resolve: (ack: AckPayload) => void;
  reject: (err: Error & Partial<ErrorPayload>) => void;
}

export class ChatterboxClient {
  readonly rooms = new Set<string>();
  readonly lastSeq = new Map<string, number>();
  state: ConnectionState = 'connecting';
  hello: HelloReply | null = null;
  status: Status = 'online';

  onstate: Listener<ConnectionState> = () => {};
  onenvelope: Listener<Envelope> = () => {};   // everything except ack, error with a ref, and the hello reply
  onready: Listener<HelloReply> = () => {};
  onfatal: Listener<string> = () => {};

  private pending = new Map<string, Pending>();   // insertion order = original send order
  private attempt = 0;
  private helloId = '';
  private heartbeat: ReturnType<typeof setInterval> | undefined;
  private watchdog: ReturnType<typeof setInterval> | undefined;
  private typingSentAt = new Map<string, number>();
  private stopped = false;
  private ticket = '';

  private readonly transport: Transport;
  private readonly getTicket: () => Promise<string>;

  constructor(transport: Transport, getTicket: () => Promise<string>) {
    this.transport = transport;
    this.getTicket = getTicket;
    transport.onopen = () => void this.sendHello();
    transport.onenvelope = (env) => this.receive(env);
    transport.onclose = () => this.lost();
  }

  start(): void { this.stopped = false; void this.connect(); }
  stop(): void { this.stopped = true; this.setState('closed'); this.cleanup(); this.transport.close(); }

  private setState(s: ConnectionState) { this.state = s; this.onstate(s); }

  private async connect(): Promise<void> {
    try {
      this.ticket = await this.getTicket();                  // a fresh one-time ticket for every connection attempt
      this.transport.open();
    } catch {
      this.lost();
    }
  }

  private async sendHello(): Promise<void> {
    this.helloId = ulid();
    const resume = this.hello && { sessionId: this.hello.sessionId, rooms: Object.fromEntries(this.lastSeq) };
    this.transport.send({
      v: 1, type: 'hello', id: this.helloId, room: null, ts: 0,
      payload: { token: this.ticket, client: 'web/1.4', ...(resume && { resume }) },
    });
  }

  private receive(env: Envelope): void {
    switch (env.type) {
      case 'hello': return void this.onHello(env.payload as HelloReply);
      case 'ack': {
        const p = this.pending.get(env.payload.ref);
        if (p) { this.pending.delete(env.payload.ref); p.resolve(env.payload as AckPayload); }
        return;
      }
      case 'error': {
        const e = env.payload as ErrorPayload;
        const p = e.ref ? this.pending.get(e.ref) : undefined;
        if (p && e.ref) { this.pending.delete(e.ref); p.reject(Object.assign(new Error(e.message), e)); return; }
        if (e.ref === this.helloId && e.code === 'unauthorized') { this.stopped = true; this.onfatal(e.message); }
        return;                                               // other unmatched errors: the close that follows triggers a retry
      }
      default: {
        const seq = env.payload.seq;
        if (env.room && typeof seq === 'number') this.lastSeq.set(env.room, Math.max(this.lastSeq.get(env.room) ?? 0, seq));
        this.onenvelope(env);
      }
    }
  }

  private onHello(h: HelloReply): void {
    this.hello = h;
    this.attempt = 0;
    const unacked = [...this.pending.values()];
    this.setState('ready');
    if (!h.resumed) {                                         // session lost: join again, replaying from lastSeq
      for (const room of this.rooms) void this.join(room, this.lastSeq.get(room) ?? null).catch(() => {});
    }
    for (const p of unacked) this.transmit(p);                // resend unacked envelopes: same ids, same original order
    this.heartbeat = setInterval(() => void this.setStatus(this.status), h.heartbeatMs);
    this.watchdog = setInterval(() => this.checkAcks(), 1000);
    this.onready(h);
  }

  private checkAcks(): void {                                 // client ack timeout: 10 s means the connection is dead
    const now = Date.now();
    for (const p of this.pending.values()) if (p.sentAt !== null && now - p.sentAt > 10_000) return this.transport.close();
  }

  private lost(): void {
    this.cleanup();
    if (this.stopped) return;
    for (const p of this.pending.values()) p.sentAt = null;
    this.setState('reconnecting');
    const delay = Math.random() * Math.min(30_000, 500 * 2 ** this.attempt++);   // full jitter
    setTimeout(() => { if (!this.stopped) void this.connect(); }, delay);
  }

  private cleanup(): void { clearInterval(this.heartbeat); clearInterval(this.watchdog); }

  private transmit(p: Pending): void {
    p.sentAt = Date.now();
    this.transport.send(p.env);
  }

  private enqueue(type: MessageType, room: string | null, payload: Record<string, unknown>) {
    const env: Envelope = { v: 1, type, id: ulid(), room, ts: 0, payload };
    const ack = new Promise<AckPayload>((resolve, reject) => {
      const p: Pending = { env, sentAt: null, resolve, reject };
      this.pending.set(env.id, p);
      if (this.state === 'ready') this.transmit(p);           // otherwise onHello sends it after (re)connecting
    });
    return { id: env.id, ack };
  }
  request(type: MessageType, room: string | null, payload: Record<string, unknown> = {}): Promise<AckPayload> {
    return this.enqueue(type, room, payload).ack;
  }

  private fireAndForget(type: MessageType, room: string, payload: Record<string, unknown>): void {
    if (this.state === 'ready') this.transport.send({ v: 1, type, id: ulid(), room, ts: 0, payload });
  }

  // ---- the twelve-type API used by the UI ----
  async join(room: string, since: number | null = null) {
    const ack = await this.request('join', room, { since });
    this.rooms.add(room);
    return ack.result as { members: { userId: string; name: string; status: Status }[]; lastSeq: number };
  }
  async leave(room: string) { await this.request('leave', room); this.rooms.delete(room); this.lastSeq.delete(room); }
  send(room: string, text: string, replyTo: string | null = null) {
    return this.enqueue('message', room, { text, replyTo, attachments: [] });   // the id is the message id forever
  }
  edit(room: string, target: string, text: string) { return this.request('edit', room, { target, text }); }
  react(room: string, target: string, emoji: string, op: 'add' | 'remove') { return this.request('react', room, { target, emoji, op }); }
  async history(room: string, before: number | null = null, limit = 50) {
    return (await this.request('history', room, { before, limit })).result as unknown as HistoryResult;
  }
  async setStatus(status: Status) { this.status = status; await this.request('presence', null, { status }).catch(() => {}); }
  typing(room: string, state: 'start' | 'stop'): void {          // fire-and-forget, at most one start per 3 s
    const now = Date.now();
    if (state === 'start') {
      if (now - (this.typingSentAt.get(room) ?? 0) < 3000) return;
      this.typingSentAt.set(room, now);
    } else {
      this.typingSentAt.delete(room);
    }
    this.fireAndForget('typing', room, { state });
  }
  receipt(room: string, kind: 'delivered' | 'read', upTo: number): void { this.fireAndForget('receipt', room, { kind, upTo }); }
}

The behaviour worth knowing:

  • Pending table. Every request is stored in a Map in the order it was created, with a promise that settles on its ack or its error. Sending and acking are decoupled: after a reconnect onHello() simply transmits the table again, so an unacked message is retried with its original id and the server’s idempotency turns the retry into a no-op if the first copy was stored.

  • Ordering after reconnect. If the session was not resumed, the client first sends join for every room with since set to its last seen seq, then the old pending envelopes, and the server’s per-socket queue keeps that order.

  • Ack timeout. A request unanswered for 10 seconds is taken as proof that the connection is dead: the transport is closed and the normal reconnect path runs. Nothing is rejected, so a message typed during an outage is delivered when the connection returns.

  • Backoff. The delay is a random value between zero and min(30 s, 500 ms * 2^attempt), the "full jitter" strategy that avoids a thundering herd after a server restart. It resets when hello succeeds.

  • Heartbeat. After hello the client re-sends its current status as presence every heartbeatMs.

  • Typing throttle. One start per three seconds per room; stop resets it.

  • IDs. ulid() builds a 26-character Crockford base32 string from the clock and crypto.getRandomValues, so ids sort roughly by creation time and satisfy the protocol’s length rule.

The user interface

// DOM layer. Everything from the network is rendered with textContent, never innerHTML.
import type { ChatPayload, EditPayload, Envelope, Member, ReactPayload, Status } from '../shared/protocol.ts';
import { ChatterboxClient } from './chatterbox.ts';
import { SocketIoTransport, WebSocketTransport } from './transport.ts';

interface Msg {
  id: string; seq: number; from: ChatPayload['from']; text: string; replyTo: string | null; edited: boolean;
  reactions: Map<string, Set<string>>;
}
interface RoomState {
  msgs: Map<string, Msg>;
  members: Map<string, Member>;
  typing: Map<string, ReturnType<typeof setTimeout>>;
  receipts: Map<string, { delivered: number; read: number }>;   // by user
  next: number | null | undefined;                              // history cursor; undefined = not loaded yet
  unread: number;
}

const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const params = new URLSearchParams(location.search);
const EMOJI = ['👍', '❤️', '😂'];

let me = { userId: '', name: '' };
let client: ChatterboxClient;
let active: string | null = null;
let editing: string | null = null;
let replyTo: string | null = null;
const rooms = new Map<string, RoomState>();

// ---- connect -----------------------------------------------------------------------------------------
// URL parameters pick the server stack without a rebuild:
//   (none)                                     Socket.IO on the page's own origin (this page's server)
//   ?event=chatterbox&path=/ws/socket.io       other Socket.IO servers: the event name and the engine path
//   ?transport=ws&url=wss://host/ws            any server that speaks Chatterbox v1 over a plain WebSocket
//   &ticket=https://host/ticket?user={name}    where to POST for a one-time ticket (default /api/ticket)
function createClient(name: string): ChatterboxClient {
  const transport = params.get('transport') === 'ws'
    ? new WebSocketTransport(params.get('url') ?? `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`)
    : new SocketIoTransport(location.origin, params.get('event') ?? 'cb', params.get('path') ?? undefined);
  const ticketUrl = (params.get('ticket') ?? '/api/ticket').replace('{name}', encodeURIComponent(name));
  const getTicket = async () => {
    const res = await fetch(ticketUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) });
    if (!res.ok) throw new Error(`ticket ${res.status}`);
    return (await res.json()).ticket as string;
  };
  return new ChatterboxClient(transport, getTicket);
}

$('login').addEventListener('submit', (e) => {
  e.preventDefault();
  const name = $<HTMLInputElement>('name').value.trim();
  client = createClient(name);
  client.onstate = (s) => {
    const banner = $('banner');
    banner.style.display = s === 'ready' ? 'none' : 'block';
    banner.textContent = s === 'ready' ? '' : s === 'closed' ? 'Disconnected' : 'Reconnecting...';
    $<HTMLInputElement>('text').disabled = $<HTMLButtonElement>('send').disabled = s !== 'ready';
  };
  client.onfatal = (m) => { $('banner').style.display = 'block'; $('banner').textContent = `Sign-in failed: ${m}`; };
  client.onready = (h) => {
    me = { userId: h.userId, name: h.name };
    $('login').style.display = 'none';
    $('app').style.display = 'flex';
    document.title = `Chatterbox - ${h.name} (${h.instance ?? '?'})`;
    (window as any).chatterbox = { client, rooms, me };      // handy in the console and for browser tests
  };
  client.onenvelope = onEnvelope;
  client.start();
});

// ---- rooms -------------------------------------------------------------------------------------------
function state(room: string): RoomState {
  let r = rooms.get(room);
  if (!r) { r = { msgs: new Map(), members: new Map(), typing: new Map(), receipts: new Map(), next: undefined, unread: 0 }; rooms.set(room, r); }
  return r;
}

async function joinRoom(room: string): Promise<void> {
  const st = state(room);
  const { members } = await client.join(room, client.lastSeq.get(room) ?? null);
  st.members = new Map(members.map((m) => [m.userId, m]));
  if (st.next === undefined) {                                // first visit: newest page of history
    const page = await client.history(room, null, 50);
    for (const env of page.messages) addMessage(room, env.id, env.payload as ChatPayload);
    st.next = page.next;
  }
  select(room);
}

function select(room: string): void {
  active = room;
  state(room).unread = 0;
  render();
  markRead();
}

$('join-form').addEventListener('submit', (e) => {
  e.preventDefault();
  const input = $<HTMLInputElement>('room-input');
  void joinRoom(input.value.trim()).catch((err) => alert(err.message));
  input.value = '';
});

$('older').addEventListener('click', async () => {
  if (!active) return;
  const st = state(active);
  if (st.next == null) return;
  const page = await client.history(active, st.next, 50);
  for (const env of page.messages) addMessage(active, env.id, env.payload as ChatPayload);
  st.next = page.next;
  render();
});

// ---- incoming ----------------------------------------------------------------------------------------
function addMessage(room: string, id: string, p: ChatPayload): void {
  const st = state(room);
  if (!st.msgs.has(id)) st.msgs.set(id, { id, seq: p.seq, from: p.from, text: p.text, replyTo: p.replyTo, edited: p.edited, reactions: new Map() });
}

function onEnvelope(env: Envelope): void {
  const room = env.room;
  if (!room) return;
  const st = state(room);
  switch (env.type) {
    case 'message': {
      const p = env.payload as ChatPayload;
      addMessage(room, env.id, p);
      if (room !== active) st.unread++;
      if (p.from.userId !== me.userId) scheduleReceipts(room);
      break;
    }
    case 'edit': {
      const p = env.payload as EditPayload;
      const m = st.msgs.get(p.target);
      if (m) { m.text = p.text; m.edited = true; }
      break;
    }
    case 'react': {
      const p = env.payload as ReactPayload;
      const m = st.msgs.get(p.target);
      if (!m) break;
      const who = m.reactions.get(p.emoji) ?? new Set<string>();
      if (p.op === 'add') who.add(p.from.userId); else who.delete(p.from.userId);
      if (who.size > 0) m.reactions.set(p.emoji, who); else m.reactions.delete(p.emoji);
      break;
    }
    case 'typing': {
      const { userId, state: s } = env.payload as { userId: string; state: 'start' | 'stop' };
      clearTimeout(st.typing.get(userId));
      st.typing.delete(userId);
      if (s === 'start') st.typing.set(userId, setTimeout(() => { st.typing.delete(userId); render(); }, 5000));   // expire after 5 s
      break;
    }
    case 'receipt': {
      const { userId, kind, upTo } = env.payload as { userId: string; kind: 'delivered' | 'read'; upTo: number };
      const r = st.receipts.get(userId) ?? { delivered: 0, read: 0 };
      r[kind] = Math.max(r[kind], upTo);
      st.receipts.set(userId, r);
      break;
    }
    case 'presence': {
      const p = env.payload as { userId: string; name: string; status: Status };
      if (p.status === 'offline') st.members.delete(p.userId); else st.members.set(p.userId, p);
      break;
    }
  }
  render();
}

// ---- receipts, typing, presence --------------------------------------------------------------------
const receiptTimers = new Map<string, ReturnType<typeof setTimeout>>();
function scheduleReceipts(room: string): void {                // batch: one receipt per burst of messages
  if (receiptTimers.has(room)) return;
  receiptTimers.set(room, setTimeout(() => {
    receiptTimers.delete(room);
    const upTo = client.lastSeq.get(room);
    if (upTo === undefined) return;
    client.receipt(room, 'delivered', upTo);
    if (room === active && document.visibilityState === 'visible') client.receipt(room, 'read', upTo);
  }, 200));
}
function markRead(): void {
  if (active && document.visibilityState === 'visible') scheduleReceipts(active);
}
document.addEventListener('visibilitychange', () => {
  void client?.setStatus(document.visibilityState === 'visible' ? 'online' : 'away');
  markRead();
});

$('text').addEventListener('input', () => { if (active) client.typing(active, 'start'); });
$('text').addEventListener('blur', () => { if (active) client.typing(active, 'stop'); });

// ---- composer ----------------------------------------------------------------------------------------
$('composer').addEventListener('submit', async (e) => {
  e.preventDefault();
  const input = $<HTMLInputElement>('text');
  const text = input.value;
  if (!active || !text) return;
  input.value = '';
  client.typing(active, 'stop');
  if (editing) {
    const target = editing;
    editing = null;
    await client.edit(active, target, text).catch((err) => alert(err.message));
  } else {
    // The message appears when its broadcast (with the seq) arrives; the ack only confirms or reports an error.
    const reply = replyTo;
    replyTo = null;
    client.send(active, text, reply).ack.catch((err) => alert(`${err.code}: ${err.message}`));
  }
});

// ---- rendering -------------------------------------------------------------------------------------
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLElementTagNameMap[K]> & { className?: string } = {}, ...kids: (Node | string)[]) {
  const node = Object.assign(document.createElement(tag), props);
  node.append(...kids);
  return node;
}

function tick(st: RoomState, m: Msg): string {                  // sent < delivered < read, from other members' receipts
  let delivered = false, read = false;
  for (const [userId, r] of st.receipts) {
    if (userId === me.userId) continue;
    delivered ||= r.delivered >= m.seq;
    read ||= r.read >= m.seq;
  }
  return read ? 'read' : delivered ? 'delivered' : 'sent';
}

function render(): void {
  const list = $('rooms');
  list.replaceChildren(...[...rooms].map(([name, st]) => {
    const b = el('button', { textContent: `#${name}${st.unread ? ` (${st.unread})` : ''}`, onclick: () => select(name) });
    return el('li', {}, b);
  }));
  if (!active) return;
  const st = state(active);
  $('title').textContent = `#${active}`;
  $('older').hidden = st.next == null;
  $('members').replaceChildren(...[...st.members.values()].map((m) =>
    el('li', { className: 'member', title: m.status }, el('span', { className: `dot ${m.status}` }), m.name)));
  const names = [...st.typing.keys()].map((u) => st.members.get(u)?.name ?? u);
  $('typing').textContent = names.length ? `${names.join(', ')} ${names.length > 1 ? 'are' : 'is'} typing...` : '';

  const box = $('messages');
  const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
  box.replaceChildren(...[...st.msgs.values()].sort((a, b) => a.seq - b.seq).map((m) => {
    const mine = m.from.userId === me.userId;
    const node = el('div', { className: mine ? 'msg mine' : 'msg' });
    node.dataset.id = m.id;
    node.dataset.seq = String(m.seq);
    node.append(el('div', { className: 'meta' }, `${m.from.name} #${m.seq}${m.edited ? ' (edited)' : ''}${mine ? ` - ${tick(st, m)}` : ''}`));
    const parent = m.replyTo ? st.msgs.get(m.replyTo) : undefined;
    if (parent) node.append(el('div', { className: 'reply', textContent: `${parent.from.name}: ${parent.text.slice(0, 60)}` }));
    node.append(el('div', { className: 'body', textContent: m.text }));
    const reactions = el('div', { className: 'reactions' });
    for (const emoji of EMOJI) {
      const who = m.reactions.get(emoji);
      const reacted = who?.has(me.userId) ?? false;
      reactions.append(el('button', {
        textContent: `${emoji}${who?.size ? ` ${who.size}` : ''}`, title: reacted ? 'remove' : 'add',
        onclick: () => void client.react(active!, m.id, emoji, reacted ? 'remove' : 'add').catch((err) => alert(err.message)),
      }));
    }
    node.append(reactions);
    const tools = el('div', { className: 'tools' }, el('button', { textContent: 'Reply', onclick: () => { $<HTMLInputElement>('text').focus(); replyTo = m.id; } }));
    if (mine) tools.append(el('button', { textContent: 'Edit', onclick: () => { editing = m.id; const i = $<HTMLInputElement>('text'); i.value = m.text; i.focus(); } }));
    node.append(tools);
    return node;
  }));
  if (atBottom) box.scrollTop = box.scrollHeight;
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chatterbox</title>
<link rel="icon" href="data:,">
<style>
  :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
  body { margin: 0; height: 100vh; display: flex; flex-direction: column; }
  #banner { padding: 4px 12px; font-size: 13px; background: #fde68a; color: #422006; display: none; }
  #login { margin: 20vh auto; display: flex; gap: 8px; }
  #app { display: none; flex: 1; min-height: 0; }
  aside { width: 220px; border-right: 1px solid #8884; padding: 8px; overflow: auto; }
  main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
  #messages { flex: 1; overflow: auto; padding: 8px 12px; }
  .msg { margin: 6px 0; padding: 4px 8px; border-radius: 6px; background: #8881; }
  .msg.mine { background: #2563eb22; }
  .meta { font-size: 12px; opacity: .7; }
  .reply { font-size: 12px; border-left: 3px solid #8886; padding-left: 6px; opacity: .8; }
  .tools button, .reactions button { border: 0; background: none; cursor: pointer; font-size: 12px; }
  #typing { height: 18px; padding: 0 12px; font-size: 12px; opacity: .8; }
  form#composer { display: flex; gap: 8px; padding: 8px 12px; border-top: 1px solid #8884; }
  form#composer input { flex: 1; padding: 6px; }
  .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; background: #9ca3af; }
  .dot.online { background: #16a34a; } .dot.away { background: #f59e0b; }
</style>
</head>
<body>
<div id="banner" role="status"></div>
<form id="login"><input id="name" placeholder="name (a-z, 0-9)" pattern="[a-z0-9]{1,32}" required autofocus><button>Sign in</button></form>
<div id="app">
  <aside>
    <form id="join-form"><input id="room-input" placeholder="room" pattern="[a-z0-9\-]{1,64}" required><button id="join-btn">Join</button></form>
    <h4>Rooms</h4><ul id="rooms"></ul>
    <h4>Members</h4><ul id="members"></ul>
  </aside>
  <main>
    <h3 id="title" style="margin:8px 12px"></h3>
    <button id="older" hidden>Load older messages</button>
    <div id="messages"></div>
    <div id="typing"></div>
    <form id="composer"><input id="text" autocomplete="off" placeholder="Message" disabled><button id="send" disabled>Send</button></form>
  </main>
</div>
<script src="app.js"></script>
</body>
</html>

The state is a Map of rooms, each with its messages keyed by id. Keying by id makes every source idempotent: a message that arrives live, through since replay and through history is stored once. Message order is the seq. The "sent, delivered, read" mark of a message is computed from the receipts of the other members: a read receipt with upTo at or beyond the message’s seq means read. visibilitychange switches the status between online and away and triggers the read receipt when the page becomes visible. Text reaches the DOM only through textContent.

Build and run

npm install                    # creates package-lock.json, which the Dockerfile needs
docker compose up -d --build   # builds the image once, starts postgres, redis, chat-a, chat-b and nginx
curl http://localhost:8080/healthz    # answered by one of the replicas; repeat to see both names

Open http://localhost:8080/ in two browser windows, sign in as different users, join the same room and chat. Opening http://localhost:8081/ and http://localhost:8082/ connects directly to each replica; the window title shows which replica serves the tab, so the cross-replica path is easy to see. To free everything: docker compose down -v (without -v the messages stay in the pgdata volume).

docker compose exec postgres psql -U chatterbox -c "SELECT room, seq, author, text FROM messages ORDER BY seq DESC LIMIT 5"
docker compose exec redis redis-cli --scan --pattern 'cb:*'

How one feature crosses the layers

Feature Client Server Storage

message

send() creates the ULID and a pending entry; the message appears when the broadcast arrives

handlers.message validates, calls addMessage, acks, broadcasts to r:<room>

messages, events and acks rows in one transaction; rooms.last_seq incremented

edit, react

Buttons on the message; the broadcast updates text or the reaction set

Author check, conflict and not-found rules in the store

messages.text updated; reactions row added or removed; events row for replay

typing

input event, throttled to one start per 3 s, expiry after 5 s

Relay to the other sockets of the room

None

receipt

Batched 200 ms after incoming messages; read only when the page is visible

Relay after checking upTo against last_seq

None (a receipt is not stored)

presence

Status from page visibility, resent as heartbeat

Broadcast on change; offline after 15 s without a socket anywhere in the cluster

Socket data, Redis session

history

First page on first join, "Load older" with the next cursor

Cursor query on messages

messages by (room, seq)

Resume

hello with resume, then retransmission of the pending table

Session lookup, replay of events with seq above lastSeq

Redis cb:session:*, PostgreSQL events

sequenceDiagram participant C as Client participant S as Replica participant R as Redis participant P as PostgreSQL Note over C: connection lost, pending table keeps unacked envelopes C->>C: wait random(0, min(30 s, 500 ms * 2^n)) C->>S: POST /api/ticket, then connect C->>S: hello { token, resume: { sessionId, rooms: { general: 41 } } } S->>R: GETDEL ticket, GET session S-->>C: hello { resumed: true } S->>P: events with seq above 41 S-->>C: replay of message, edit and react with original seq C->>S: retransmit pending envelopes, same ids, original order S->>P: acks lookup (dup: true when it was stored) S-->>C: ack

If the session has expired (more than 120 seconds, or Redis was flushed), the reply says resumed: false and the client sends join with since for each room instead, which replays the same events; the application behaves the same, only one round trip slower. Replay sends at most 500 events per room and reconnect, so an outage that produced more is replayed only in part. The example client does not detect that; a production client compares the first replayed seq with its last seen seq plus one and asks for history when they differ.

Where this server departs from the protocol text

Socket.IO is a messaging layer on top of WebSocket, so three details of the wire protocol cannot be literal here.

Protocol text In this server

WebSocket close codes 4400 to 4503

Socket.IO cannot send an application close code. The server sends an error envelope and then disconnects the socket. The codes that are errors in the table (unauthorized, unsupported_version, rate_limited, unavailable) are sent as such; a silent-connection timeout and an oversized frame end the connection without a Chatterbox frame.

Subprotocol token chatterbox.v1

Not used by SocketIoTransport. WebSocketTransport sends it for servers that negotiate it.

Acks kept for 24 hours and at least the last 1000 ids per user

Acks are kept for 24 hours by an hourly purge; the 1000-id minimum is not implemented.

Hello timeout and heartbeat close

Error code unauthorized for a missing hello; no code for heartbeat silence.

Two protocol features are deliberately absent: attachments are validated (up to five https URLs with name, mime type and size) but there is no upload service, and history returns the message text with edits applied but not the reactions, exactly as the protocol defines the history result, so reactions to old messages appear only in the session that saw them. A real product adds an upload endpoint (see History, Offline Delivery and Moderation) and either includes reaction counts in a richer history result or fetches them separately.

Verifying it with real browsers

The script below starts two headless Chrome instances and one more for the third user, drives the page like a person, and exercises the outage paths by stopping containers. It covers hello, join, message, receipts, typing, reaction, edit, presence, history paging, a resume after the proxy goes away and a rejoin after the session has been deleted. The browsers are separate processes because background tabs of one headless Chrome are throttled. Alice connects to chat-a directly, Bob through nginx and Carol to chat-b, so every assertion crosses a replica boundary.

// Two real browsers, one scale-out check, one outage. Run from the project directory with the stack up:
//   npm i --no-save puppeteer-core && CHROME_PATH=/path/to/chrome node test/e2e.mjs
import puppeteer from 'puppeteer-core';
import { execSync } from 'node:child_process';
import assert from 'node:assert/strict';

const { HTTP_PORT = '8080', A_PORT = '8081', B_PORT = '8082', CHROME_PATH } = process.env;
const compose = (args) => execSync(`docker compose ${args}`, { stdio: 'pipe' });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const room = 'e2e' + Math.random().toString(36).slice(2, 6);
const browsers = [];

async function open(url, name) {
  // One browser per user: background tabs of a single headless Chrome are throttled.
  const browser = await puppeteer.launch({ executablePath: CHROME_PATH, headless: 'new', protocolTimeout: 30_000 });
  browsers.push(browser);
  const page = await browser.newPage();
  page.on('pageerror', (e) => console.log(name, 'page error:', e.message));
  page.on('dialog', (d) => { console.log(name, 'alert:', d.message()); void d.dismiss(); });
  await page.goto(url);
  await page.type('#name', name);
  await page.click('#login button');
  await page.waitForSelector('#app', { visible: true });
  return page;
}
const joinRoom = async (page, r) => {
  await page.type('#room-input', r);
  await page.click('#join-btn');
  await page.waitForFunction((r) => document.getElementById('title').textContent === `#${r}`, {}, r);
};
const msgs = (page) => page.$$eval('.msg', (els) => els.map((e) => ({
  id: e.dataset.id, seq: Number(e.dataset.seq), meta: e.querySelector('.meta').textContent,
  body: e.querySelector('.body').textContent, reactions: [...e.querySelectorAll('.reactions button')].map((b) => b.textContent),
})));
const waitBody = (page, text) => page.waitForFunction(
  (t) => [...document.querySelectorAll('.msg .body')].some((e) => e.textContent === t), { timeout: 8000 }, text);
const bannerShown = (page, shown) => page.waitForFunction(
  (s) => (document.getElementById('banner').style.display === 'block') === s, { timeout: 30_000 }, shown);
const clickIn = (page, body, selector, index) => page.evaluate((body, selector, index) =>
  [...document.querySelectorAll('.msg')].find((m) => m.querySelector('.body').textContent === body).querySelectorAll(selector)[index].click(),
  body, selector, index);

try {
  // hello, join, presence across two replicas: alice talks to chat-a directly, bob goes through nginx
  const alice = await open(`http://localhost:${A_PORT}/`, 'alice');
  const bob = await open(`http://localhost:${HTTP_PORT}/`, 'bob');
  await joinRoom(alice, room);
  await joinRoom(bob, room);
  await alice.waitForFunction(() => [...document.querySelectorAll('#members li')].some((l) => l.textContent === 'bob'));

  // message, ack and receipts
  await alice.type('#text', 'Hello from Alice');
  await alice.keyboard.press('Enter');
  await waitBody(bob, 'Hello from Alice');
  await alice.waitForFunction(() => document.querySelector('.msg.mine .meta').textContent.endsWith('read'), { timeout: 8000 });

  // typing
  await bob.type('#text', 'x');
  await alice.waitForFunction(() => document.getElementById('typing').textContent.includes('bob is typing'), { timeout: 5000 });
  await bob.$eval('#text', (e) => e.select());
  await bob.type('#text', 'Bob here');
  await bob.keyboard.press('Enter');
  await waitBody(alice, 'Bob here');

  // react and edit
  await clickIn(alice, 'Bob here', '.reactions button', 0);
  await bob.waitForFunction(() => [...document.querySelectorAll('.msg')].find((m) => m.querySelector('.body').textContent === 'Bob here')
    .querySelector('.reactions button').textContent.includes('1'), { timeout: 5000 });
  await clickIn(bob, 'Bob here', '.tools button', 1);
  await bob.$eval('#text', (e) => e.select());
  await bob.type('#text', 'Bob here (fixed)');
  await bob.keyboard.press('Enter');
  await waitBody(alice, 'Bob here (fixed)');
  assert.match((await msgs(alice)).find((m) => m.body === 'Bob here (fixed)').meta, /\(edited\)/);

  // presence
  await bob.evaluate(() => window.chatterbox.client.setStatus('away'));
  await alice.waitForFunction(() => [...document.querySelectorAll('#members li')].find((l) => l.textContent === 'bob')?.title === 'away', { timeout: 5000 });
  await bob.evaluate(() => window.chatterbox.client.setStatus('online'));

  // history: 70 more messages (paced under the rate limit), then a third user pages back through them
  await alice.evaluate(async (room) => {
    for (let i = 0; i < 70; i++) { await window.chatterbox.client.send(room, `bulk ${i}`).ack; await new Promise((r) => setTimeout(r, 55)); }
  }, room);
  const carol = await open(`http://localhost:${B_PORT}/`, 'carol');
  await joinRoom(carol, room);
  const firstPage = (await msgs(carol)).length;
  await carol.click('#older');
  await carol.waitForFunction((n) => document.querySelectorAll('.msg').length > n, {}, firstPage);
  const seqs = (await msgs(carol)).map((m) => m.seq);
  assert.deepEqual(seqs, [...seqs].sort((a, b) => a - b));
  console.log(`history: ${firstPage} messages first, ${seqs.length} after "older"`);

  // outage 1: nginx stops, alice keeps writing, bob queues a message offline; the session is resumed
  compose('stop proxy');
  await bannerShown(bob, true);
  await alice.type('#text', 'sent while bob offline');
  await alice.keyboard.press('Enter');
  await bob.evaluate((room) => { window.chatterbox.client.send(room, 'queued offline by bob'); }, room);
  compose('start proxy');
  await bannerShown(bob, false);
  await waitBody(bob, 'sent while bob offline');
  await waitBody(alice, 'queued offline by bob');
  console.log('resumed:', await bob.evaluate(() => window.chatterbox.client.hello.resumed));
  assert.equal((await msgs(alice)).filter((m) => m.body === 'queued offline by bob').length, 1);

  // outage 2: the resume session is gone, so bob joins again with since = lastSeq
  const sessionId = await bob.evaluate(() => window.chatterbox.client.hello.sessionId);
  compose('stop proxy');
  await bannerShown(bob, true);
  compose(`exec -T redis redis-cli DEL cb:session:${sessionId}`);
  await alice.type('#text', 'missed with no session');
  await alice.keyboard.press('Enter');
  await sleep(500);
  compose('start proxy');
  await bannerShown(bob, false);
  await waitBody(bob, 'missed with no session');
  console.log('resumed:', await bob.evaluate(() => window.chatterbox.client.hello.resumed));
  const ids = (await msgs(bob)).map((m) => m.id);
  assert.equal(new Set(ids).size, ids.length, 'no duplicates');
  console.log('OK');
} finally {
  for (const b of browsers) await b.close();
}

Run it from the project directory with the stack up: npm i --no-save puppeteer-core and then CHROME_PATH="/path/to/chrome" node test/e2e.mjs. The same page-driving approach, with more patterns, is on Testing Real-Time Apps and End-to-End Testing in the Browser.

Two further checks are worth running by hand. Stop the replica a browser is connected to (docker compose stop chat-a): the banner shows Reconnecting…​, the client fetches a new ticket through nginx, lands on chat-b and the hello reply says resumed: true, because the session is in Redis and not in the dead process. And send the same envelope twice from a script (a Socket.IO client emitting the same id on the cb event): the second ack has dup: true and the same seq, and the other members receive one message.

Swapping the server stack

The client talks to a stack through one Transport and one ticket URL. The URL parameters of main.ts select them without a rebuild: ?transport=ws&url=wss://host/ws selects WebSocketTransport, &ticket=https://host/ticket?user={name} points the one-time ticket request elsewhere, and ?event=name&path=/prefix/socket.io adapts SocketIoTransport to another Socket.IO server. The server of every page in the table implements the same protocol, so the protocol client, the user interface, the PostgreSQL schema ideas and the test script carry over unchanged. A browser may POST to another origin only if that server answers CORS preflight requests, so in practice put the client and the server behind one origin, as the nginx here does.

Stack What changes on the server What changes on the client

Socket.IO on Node.js (this page)

Nothing: this is the reference.

SocketIoTransport on the page origin.

uWebSockets.js

Replace server.ts with the uWebSockets.js app: topics instead of rooms, app.ws("/ws"), per-socket backpressure. Keep store.ts and add a Redis pub/sub bridge for several replicas.

?transport=ws&url=ws://host/ws, ticket URL /ticket?user={name}.

Spring WebFlux

Java: a WebSocketHandler per socket, Reactor sinks for rooms, Redis pub/sub for fan-out, Spring Data or R2DBC for the store.

Plain WebSocket transport; ticket endpoint /api/tickets with authentication as that page describes.

Laravel Reverb

PHP: Reverb speaks the Pusher protocol, so the Chatterbox envelopes are mapped to presence channels, whispers and server events as that page explains.

A third transport built on Laravel Echo instead of the two here; the protocol client and UI stay.

python-socketio

Python: the same event model as this page with the Redis client manager for several processes.

SocketIoTransport with ?event=chatterbox, because that page names its envelope event chatterbox; add &path=/ws/socket.io when the app is mounted below a prefix. The close codes of that page travel in a second event, chatterbox.close.

Django Channels

Python: an AsyncJsonWebsocketConsumer on /ws, ORM for the store, a channel layer on Redis for fan-out.

?transport=ws&url=ws://host/ws.

SignalR

C#: a hub with groups instead of rooms, Redis or Azure SignalR as the backplane, Entity Framework for the store.

A transport built on HubConnection, as that page’s client does; everything above the transport stays.

Swoole

PHP: callbacks on the Swoole server, Swoole Table for rooms and presence, Redis between servers.

?transport=ws&url=ws://host/ws.

Atmosphere

Java: a @ManagedService on /ws, broadcasters for rooms, WebSocket with SSE or long-polling fallback.

?transport=ws&url=ws://host/ws; atmosphere.js only if the fallback transports are wanted.

Socket.IO inside a NestJS application (see NestJS WebSockets)

A gateway class with the same handlers; store.ts becomes an injectable service.

SocketIoTransport unchanged.

Choosing between them is covered on Choosing a WebSocket Stack and Comparison & Decision Guide. Replacing PostgreSQL or Redis is a separate axis: only store.ts touches PostgreSQL, and only the ticket, session and adapter code touches Redis, so SQLite for a single node, or Redis Streams as the adapter (see Redis Reference), are local changes.

The XMPP variant

Replace the server and the protocol client; keep the user interface. An XMPP server such as ejabberd, Prosody, Openfire or MongooseIM already provides accounts, rooms, history and offline storage, so there is no server.ts and no schema to write. The browser module ChatterboxXmpp of XMPP in the Browser exposes the same operations and event shapes as ChatterboxClient, with these equivalents:

Chatterbox XMPP

hello and ticket

SASL authentication on the WebSocket stream, with a token instead of a password (see XMPP in the Browser)

join, leave

Multi-user chat presence to the room JID and unavailable presence (XEP-0045)

message

A groupchat message stanza whose id is the Chatterbox message id; the room echoes it back

edit

Last message correction (XEP-0308)

react

Message reactions (XEP-0444)

typing

Chat states (XEP-0085)

receipt

Delivery receipts (XEP-0184) and displayed markers (XEP-0333)

presence

Presence stanzas with show

history

Message Archive Management (XEP-0313) with result set paging

Resume

Stream Management resumption (XEP-0198)

Idempotency and seq

Stanza ids and the archive’s stable ids replace the per-room seq

The stanzas are on XMPP Chat Features, the servers on XMPP Servers, and a ready-made interface on Converse.js. The trade-off: you gain federation and a decade of clients and lose the freedom to define the message model, such as the seq ordering key.

Common errors and pitfalls

  • The page loads but never connects. The Origin allow-list does not contain the exact origin including the port. The three ports of compose.yaml are all in ALLOWED_ORIGINS; add a fourth when you publish another.

  • Connects through nginx but drops after a minute. The proxy’s read timeout is shorter than the silence between frames. Keep proxy_read_timeout above two heartbeats and forward Upgrade and Connection.

  • Cannot find module or ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX at start-up. An import without the .ts extension, a missing import type, an enum or a constructor parameter property. npm run typecheck reports all of them because of erasableSyntaxOnly.

  • npm ci fails in the Docker build. There is no package-lock.json. Run npm install once and commit the lock file.

  • Second ticket use fails with unauthorized. Tickets are single-use by design. A custom client must request a new ticket for each connection attempt, including every reconnect.

  • Messages work on one replica only. The adapter is missing or each replica uses a different Redis. Everything shared, including the tickets, must live in the same Redis.

  • A reconnecting client is told forbidden for a room it was in. The envelopes were processed out of order. Process the envelopes of one connection serially, as rt.queue does, and send the joins before the retransmitted messages.

  • socket.data loses a field on another replica. fetchSockets() returns serialised data, so only JSON-compatible fields survive. Keep timers, sets and functions in a WeakMap outside socket.data.

  • Duplicates after a retry. The client generated a new id for the retry. The id must be created once, when the message is created, and reused.

  • Seeding scripts get rate_limited. 20 envelopes per second with a burst of 40 is enforced per connection. Pace bulk imports or send them from the server side.

  • Presence flickers on every reload. The offline broadcast waits 15 seconds and is cancelled when the user has another socket anywhere in the cluster; if it flickers, the check is looking at one replica only.

  • Markup in a message is shown as text. That is the intended behaviour. If a feature needs formatting, render a safe subset on the client from a parsed structure; never assign received strings to innerHTML.

Security considerations

  • Authenticate in the first frame with a short-lived, single-use ticket, check Origin on both the ticket endpoint and the handshake, and serve everything over TLS in production. See Security.

  • Validate every field on the server. The example checks types, lengths, the room name pattern, emoji grapheme count, attachment URL scheme and the DM membership rule; the client is never trusted.

  • Authorise by membership: message, edit, react, typing, receipt and history require the socket to be in the room, and edit requires authorship, checked inside the transaction with a row lock.

  • Limit abuse: the token bucket, the 16384-byte frame limit, the 4000-character text limit and the five-attachment limit.

  • Keep secrets out of the image and the page: the Compose file uses the placeholder change-me and a real deployment injects secrets from the environment or a secret store.

  • Do not publish PostgreSQL or Redis ports; only the proxy and, for debugging, the replicas are published.

Version and history notes

  • Socket.IO 4.x servers have defaulted to the WebSocket upgrade path with an HTTP long-polling start; restricting the client and server to ["websocket"] is what removes the sticky-session requirement, at the cost of clients that cannot open a WebSocket.

  • Node.js type stripping was added behind a flag in 22.6, enabled by default in 22.18 and 23.6, lost its experimental warning in 24.3 and was declared stable in 24.12 and 25.2.

  • PostgreSQL 18 images keep the data in a major-version subdirectory under /var/lib/postgresql.

  • The ULID specification and UUID version 7 (RFC 9562) are the two time-ordered id formats the Chatterbox protocol accepts for client ids.

Where next