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 |
The login form asks for a name, the client POSTs it to |
Rooms: |
Joining any room name that matches |
|
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 |
|
Only the author may edit. Reactions are added or removed per user and emoji, with |
|
Sent at most once every three seconds while the user types, fire-and-forget; receivers expire it after five seconds. |
|
|
|
|
|
The newest 50 messages load when a room is first opened; "Load older messages" pages back with the |
Reconnection and resume |
Backoff with full jitter, a fresh ticket, |
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
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, |
The envelope already has a |
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 ( |
|
The row lock serialises writers of one room, so commit order equals |
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 |
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
( |
The client talks to a |
Only |
Everything the network supplies is rendered with |
The page never uses |
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.
Source: npm documentation: package.json
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.
Source: TypeScript: TSConfig reference
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.
roomsholds the counter,eventsthe replay log (one row per message, edit and reaction, keyed by room andseq),messagesthe current state of each message with edits applied,reactionsone row per user and emoji, andacksthe 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 formessage,editandreact. It first looks the ack up (the cheap duplicate case), then opens a transaction, takes the nextseq(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 theseqincrement, 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 withdup: true. A unique violation without a stored ack means the id is already used by another user’s message, which is aconflict. -
History. A page is read newest first with
LIMIT n + 1to 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 consumeseqvalues too, theseqvalues on a page have gaps; thenextcursor is theseqof the oldest message returned.
Source: node-postgres: Transactions
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.
allowRequestrejects a browser whoseOriginis not on the list, the defence against cross-site WebSocket hijacking; requests without anOrigin(non-browser clients) pass and must still present a valid ticket. -
Hello deadline. A 5 second timer closes a socket that never says
hello.helloconsumes the ticket withGETDEL, so a ticket works once even if two sockets race for it. -
Serial handling and rate limit. Every
cbevent first goes through a token bucket (20 tokens per second, burst 40), which is checked before deduplication, then joins a per-socket promise chain. Tenrate_limitederrors within ten seconds close the socket. -
Validation.
dispatch()rejects anything that is not an object withv1, an id of 26 to 36 characters, a payload object and a string-or-null room. A frame beforehellogetsunauthorizedand the socket is closed. The Socket.IO optionmaxHttpBufferSize: 16384enforces the frame limit below the application. -
Disconnect. The
disconnectingevent fires whilesocket.roomsis 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
presenceheartbeat prevents. -
Shutdown. On
SIGTERMthe server sendsunavailableand closes, which makes clients reconnect to another replica at once.
Handlers
-
hellovalidates the ticket, restores the session whenresume.sessionIdbelongs to the same user, joins the socket to its rooms and answers with the hello reply (including the extrainstancefield, which receivers ignore). After a resumed hello it announces the user as online again and replays every event after the client’slastSeqfor each room. -
joincreates the room on first use, refuses someone else’s DM, answers with the member list and the lastseq, replays fromsincewhen given, and tells the room about a new member only when the user was not already in it. -
message,editandreactvalidate, check membership, call the store, send the ack, and broadcast withio.to(room), which reaches the sender and the sockets on the other replica through the adapter. A duplicate is acked withdup: trueand not broadcast. -
typingandreceiptare fire-and-forget: invalid input is dropped silently and the relay usessocket.to(room), which excludes the sender. A receipt beyond the room’s lastseqis dropped. -
presencechanges the stored status, refreshes the resume session (so the heartbeat keeps it alive) and broadcasts only when the status changed. -
historyreturns the page as the ack’sresult.
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
Mapin the order it was created, with a promise that settles on its ack or its error. Sending and acking are decoupled: after a reconnectonHello()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
joinfor every room withsinceset to its last seenseq, 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 whenhellosucceeds. -
Heartbeat. After
hellothe client re-sends its current status aspresenceeveryheartbeatMs. -
Typing throttle. One
startper three seconds per room;stopresets it. -
IDs.
ulid()builds a 26-character Crockford base32 string from the clock andcrypto.getRandomValues, so ids sort roughly by creation time and satisfy the protocol’s length rule.
Source: ULID specification
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.
Source: MDN: Node.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 |
|---|---|---|---|
|
|
|
|
|
Buttons on the message; the broadcast updates text or the reaction set |
Author check, conflict and not-found rules in the store |
|
|
|
Relay to the other sockets of the room |
None |
|
Batched 200 ms after incoming messages; |
Relay after checking |
None (a receipt is not stored) |
|
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 |
|
First page on first join, "Load older" with the |
Cursor query on |
|
Resume |
|
Session lookup, replay of |
Redis |
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 |
Subprotocol token |
Not used by |
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 |
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.
Source: Puppeteer documentation
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. |
|
Replace |
|
|
Java: a |
Plain WebSocket transport; ticket endpoint |
|
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: the same event model as this page with the Redis client manager for several processes. |
|
|
Python: an |
|
|
C#: a hub with groups instead of rooms, Redis or Azure SignalR as the backplane, Entity Framework for the store. |
A transport built on |
|
PHP: callbacks on the Swoole server, Swoole Table for rooms and presence, Redis between servers. |
|
|
Java: a |
|
|
Socket.IO inside a NestJS application (see NestJS WebSockets) |
A gateway class with the same handlers; |
|
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 |
|---|---|
|
SASL authentication on the WebSocket stream, with a token instead of a password (see XMPP in the Browser) |
|
Multi-user chat presence to the room JID and unavailable presence (XEP-0045) |
|
A groupchat message stanza whose |
|
Last message correction (XEP-0308) |
|
Message reactions (XEP-0444) |
|
Chat states (XEP-0085) |
|
Delivery receipts (XEP-0184) and displayed markers (XEP-0333) |
|
Presence stanzas with |
|
Message Archive Management (XEP-0313) with result set paging |
Resume |
Stream Management resumption (XEP-0198) |
Idempotency and |
Stanza ids and the archive’s stable ids replace the per-room |
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.yamlare all inALLOWED_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_timeoutabove two heartbeats and forwardUpgradeandConnection. -
Cannot find moduleorERR_UNSUPPORTED_TYPESCRIPT_SYNTAXat start-up. An import without the.tsextension, a missingimport type, an enum or a constructor parameter property.npm run typecheckreports all of them because oferasableSyntaxOnly. -
npm cifails in the Docker build. There is nopackage-lock.json. Runnpm installonce 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
forbiddenfor a room it was in. The envelopes were processed out of order. Process the envelopes of one connection serially, asrt.queuedoes, and send the joins before the retransmitted messages. -
socket.dataloses a field on another replica.fetchSockets()returns serialised data, so only JSON-compatible fields survive. Keep timers, sets and functions in aWeakMapoutsidesocket.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
Originon 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,receiptandhistoryrequire the socket to be in the room, andeditrequires 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-meand 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
-
Chat Architecture, Presence, Typing and Receipts and History, Offline Delivery and Moderation: the design behind each feature.
-
Scaling WebSockets, Proxies, Load Balancers and Deployment and Observability: running it in production.
-
Mobile and Native Clients: the same protocol from an app.
-
Databases from Node.js: more on
pgpooling and transactions.