Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js · BroadcastChannel API

Real-time Chat with Next.js and NestJS WebSockets Demo

Three simulated people, Ayesha, Ben and Carmen, chat through an in-browser stand-in for a NestJS Socket.IO gateway. Switch rooms, watch typing indicators and read receipts, raise one client's latency, or turn their network off, send a few messages and turn it back on to see the outbox flush and the missed messages sync. Open a second tab to chat across tabs.

Read the case study →

Interactive demo

samiullah.tech/lab/realtime-chat
connecting sockets…

## How it works

server.ts is the gateway: it checks the token on connect, keeps rooms, presence and read positions, rate-limits to 10 messages per 5 seconds and gives every message a key from a Lamport clock plus its node id. Keys define one order that every client agrees on.

client.ts shows a message straight away with a client id, then swaps in the server key when the ack arrives. Offline messages wait in an outbox. On reconnect the client rejoins each room with the last key it saw, gets only the missed messages, and resends anything that never got an ack; the server dedupes by client id.

Every event in either direction is delayed by that client's latency. One link keeps its own order, like a TCP stream, but two clients can reach the server in either order, which is why the server, not the clock on each device, decides message order.

## View source

The code running in the window above, exactly as deployed.

app/lab/realtime-chat/server.ts335 lines
/**
 * An in-browser stand-in for a NestJS WebSocket gateway on the Socket.IO adapter.
 * No React here. It owns rooms, message history, presence, typing state, read positions and the rate limit,
 * and it talks to clients only through `emit` (server -> one socket) and acks (reply to one client event).
 *
 * Each browser tab runs one ChatServer "node". Nodes in different tabs share messages through a
 * BroadcastChannel, which plays the role of the Redis adapter that fans events out between server
 * instances in production.
 */

export type RoomId = "general" | "design" | "random";
export const ROOMS: { id: RoomId; name: string; topic: string }[] = [
  { id: "general", name: "#general", topic: "Team chat for Acme Dental's new booking site" },
  { id: "design", name: "#design", topic: "Screens, copy and colour decisions" },
  { id: "random", name: "#random", topic: "Everything else" },
];

export type Message = {
  key: string; // total order: lamport clock + node id, compared as strings
  clientId: string; // the sender's id for the optimistic copy (dedupes retries)
  room: RoomId;
  userId: string;
  name: string;
  text: string;
  at: number;
  node: string;
};

export type UserReceipt = { delivered: string; read: string };

/** Events the server sends to a socket. */
export type ServerEvent =
  | { type: "message"; msg: Message }
  | { type: "history"; room: RoomId; messages: Message[]; reason: "join" | "sync" }
  | { type: "presence"; room: RoomId; online: { userId: string; name: string; remote: boolean }[] }
  | { type: "typing"; room: RoomId; userId: string; name: string; typing: boolean }
  | { type: "receipts"; room: RoomId; receipts: Record<string, UserReceipt> };

/** Events a client sends. `ack` is the Socket.IO acknowledgement callback. */
export type ClientEvent =
  | { type: "join"; room: RoomId; afterKey?: string }
  | { type: "leave"; room: RoomId }
  | { type: "send"; room: RoomId; clientId: string; text: string }
  | { type: "typing"; room: RoomId; typing: boolean }
  | { type: "receipt"; room: RoomId; delivered?: string; read?: string };

export type Ack = { ok: true; key: string; at: number; duplicate?: boolean } | { ok: false; error: "rate_limited" | "too_long" | "empty" | "not_in_room" };

export type ServerLog = { text: string; tone: "ok" | "warn" | "err" | "info" | "dim" | "accent" };

/** What travels over the BroadcastChannel between tabs (the "adapter"). */
export type AdapterMessage =
  | { type: "message"; msg: Message }
  | { type: "presence"; node: string; users: { userId: string; name: string; room: RoomId }[]; at: number }
  | { type: "bye"; node: string };

export const MAX_LEN = 500;
export const RATE = { max: 10, windowMs: 5000 };
export const HISTORY_LIMIT = 200;

/** `joined`: rooms the socket receives messages for (Socket.IO rooms). `room`: the one on screen (presence, typing). */
type Socket = { id: string; userId: string; name: string; room: RoomId | null; joined: Set<RoomId>; connected: boolean };

export class ChatServer {
  readonly node: string;
  private clock = 0;
  private sockets = new Map<string, Socket>();
  private messages = new Map<RoomId, Message[]>();
  private receipts = new Map<RoomId, Record<string, UserReceipt>>();
  private sent = new Map<string, number[]>();
  private remotePresence = new Map<string, { users: { userId: string; name: string; room: RoomId }[]; at: number }>();

  constructor(
    node: string,
    private emit: (socketId: string, e: ServerEvent) => void,
    private publish: (m: AdapterMessage) => void,
    private log: (l: ServerLog) => void,
  ) {
    this.node = node;
    for (const r of ROOMS) {
      this.messages.set(r.id, []);
      this.receipts.set(r.id, {});
    }
  }

  /* ---------- persistence ---------- */

  load(saved: Message[]) {
    for (const m of saved) this.store(m);
  }

  snapshot(): Message[] {
    return ROOMS.flatMap((r) => this.messages.get(r.id)!.slice(-60));
  }

  history(room: RoomId) {
    return this.messages.get(room)!;
  }

  stats() {
    return ROOMS.map((r) => ({
      room: r.id,
      messages: this.messages.get(r.id)!.length,
      online: this.online(r.id).map((u) => u.name + (u.remote ? "*" : "")),
    }));
  }

  get lamport() {
    return this.clock;
  }

  remoteNodes() {
    return [...this.remotePresence.keys()];
  }

  /* ---------- connection lifecycle (gateway handleConnection / handleDisconnect) ---------- */

  connect(socketId: string, auth: { token: string }) {
    const claims = verifyToken(auth.token);
    if (!claims) {
      this.log({ text: `connect ${socketId} rejected: invalid token`, tone: "err" });
      return false;
    }
    this.sockets.set(socketId, { id: socketId, userId: claims.userId, name: claims.name, room: null, joined: new Set(), connected: true });
    this.log({ text: `connect ${socketId} as ${claims.name}`, tone: "ok" });
    return true;
  }

  disconnect(socketId: string, reason: string) {
    const s = this.sockets.get(socketId);
    if (!s) return;
    this.sockets.delete(socketId);
    this.log({ text: `disconnect ${s.name} (${reason})`, tone: "warn" });
    if (s.room) {
      this.broadcastPresence(s.room);
      this.toRoom(s.room, { type: "typing", room: s.room, userId: s.userId, name: s.name, typing: false });
    }
    this.publishPresence();
  }

  /* ---------- client events (gateway @SubscribeMessage handlers) ---------- */

  handle(socketId: string, e: ClientEvent): Ack | null {
    const s = this.sockets.get(socketId);
    if (!s) return null; // not connected: Socket.IO would drop it
    switch (e.type) {
      case "join": {
        if (s.room && s.room !== e.room) this.handle(socketId, { type: "leave", room: s.room });
        s.room = e.room;
        s.joined.add(e.room);
        const all = this.messages.get(e.room)!;
        if (e.afterKey !== undefined) {
          // Rejoin after a reconnect: only what the client missed, in order.
          const missed = all.filter((m) => m.key > e.afterKey!);
          this.log({ text: `${s.name} rejoined #${e.room}, sync after ${e.afterKey.slice(0, 10).replace(/^0+/, "") || "start"}: ${missed.length} missed`, tone: "accent" });
          this.emit(socketId, { type: "history", room: e.room, messages: missed, reason: "sync" });
        } else {
          this.log({ text: `${s.name} joined #${e.room}`, tone: "dim" });
          this.emit(socketId, { type: "history", room: e.room, messages: all.slice(-50), reason: "join" });
        }
        this.emit(socketId, { type: "receipts", room: e.room, receipts: this.receipts.get(e.room)! });
        this.broadcastPresence(e.room);
        this.publishPresence();
        return null;
      }
      case "leave": {
        if (s.room !== e.room) return null;
        s.room = null;
        this.broadcastPresence(e.room);
        this.toRoom(e.room, { type: "typing", room: e.room, userId: s.userId, name: s.name, typing: false });
        return null;
      }
      case "send": {
        if (!ROOMS.some((r) => r.id === e.room)) return { ok: false, error: "not_in_room" };
        const text = e.text.trim();
        if (!text) return { ok: false, error: "empty" };
        if (text.length > MAX_LEN) return { ok: false, error: "too_long" };
        // Idempotent retries: the same clientId is stored once and acked with the original key.
        const dup = this.messages.get(e.room)!.find((m) => m.clientId === e.clientId);
        if (dup) {
          this.log({ text: `${s.name} resent ${e.clientId.slice(0, 6)}: already stored, ack again`, tone: "dim" });
          return { ok: true, key: dup.key, at: dup.at, duplicate: true };
        }
        const now = Date.now();
        const recent = (this.sent.get(s.userId) ?? []).filter((t) => t > now - RATE.windowMs);
        if (recent.length >= RATE.max) {
          this.log({ text: `${s.name} rate limited (${RATE.max} per ${RATE.windowMs / 1000}s)`, tone: "err" });
          return { ok: false, error: "rate_limited" };
        }
        this.sent.set(s.userId, [...recent, now]);
        this.clock += 1;
        const msg: Message = { key: makeKey(this.clock, this.node), clientId: e.clientId, room: e.room, userId: s.userId, name: s.name, text, at: now, node: this.node };
        this.store(msg);
        this.log({ text: `message ${msg.key.slice(-14)} from ${s.name} → #${e.room}`, tone: "info" });
        this.toMembers(e.room, { type: "message", msg });
        this.publish({ type: "message", msg });
        return { ok: true, key: msg.key, at: msg.at };
      }
      case "typing": {
        if (s.room !== e.room) return null;
        this.toRoom(e.room, { type: "typing", room: e.room, userId: s.userId, name: s.name, typing: e.typing }, socketId);
        return null;
      }
      case "receipt": {
        const all = this.receipts.get(e.room)!;
        const cur = all[s.userId] ?? { delivered: "", read: "" };
        const next = { delivered: maxKey(cur.delivered, e.delivered ?? "", e.read ?? ""), read: maxKey(cur.read, e.read ?? "") };
        if (next.delivered === cur.delivered && next.read === cur.read) return null;
        all[s.userId] = next;
        this.toRoom(e.room, { type: "receipts", room: e.room, receipts: all });
        return null;
      }
    }
  }

  /* ---------- adapter (other tabs) ---------- */

  fromAdapter(m: AdapterMessage) {
    if (m.type === "message") {
      if (this.messages.get(m.msg.room)!.some((x) => x.key === m.msg.key)) return;
      this.clock = Math.max(this.clock, parseInt(m.msg.key, 10)); // Lamport: never fall behind a peer
      this.store(m.msg);
      this.log({ text: `adapter: message from tab ${m.msg.node} → #${m.msg.room}`, tone: "accent" });
      this.toMembers(m.msg.room, { type: "message", msg: m.msg });
    } else if (m.type === "presence") {
      const had = this.remotePresence.has(m.node);
      this.remotePresence.set(m.node, { users: m.users, at: m.at });
      if (!had) this.log({ text: `adapter: tab ${m.node} joined the cluster`, tone: "accent" });
      for (const r of ROOMS) this.broadcastPresence(r.id);
    } else if (m.type === "bye") {
      if (this.remotePresence.delete(m.node)) {
        this.log({ text: `adapter: tab ${m.node} left`, tone: "dim" });
        for (const r of ROOMS) this.broadcastPresence(r.id);
      }
    }
  }

  /** Heartbeat for other tabs; also expires tabs that closed without saying bye. */
  heartbeat(now: number) {
    this.publishPresence();
    for (const [node, p] of this.remotePresence) {
      if (now - p.at > 12_000) {
        this.remotePresence.delete(node);
        for (const r of ROOMS) this.broadcastPresence(r.id);
      }
    }
  }

  /* ---------- internals ---------- */

  private store(m: Message) {
    const list = this.messages.get(m.room);
    if (!list || list.some((x) => x.key === m.key)) return;
    this.clock = Math.max(this.clock, parseInt(m.key, 10));
    // Insert in key order (arrivals from other tabs can be out of order).
    let i = list.length;
    while (i > 0 && list[i - 1]!.key > m.key) i--;
    list.splice(i, 0, m);
    if (list.length > HISTORY_LIMIT) list.shift();
  }

  private online(room: RoomId) {
    const local = [...this.sockets.values()].filter((s) => s.connected && s.room === room).map((s) => ({ userId: s.userId, name: s.name, remote: false }));
    const remote = [...this.remotePresence.entries()].flatMap(([node, p]) =>
      p.users.filter((u) => u.room === room).map((u) => ({ userId: `${u.userId}@${node}`, name: `${u.name} (tab ${node})`, remote: true })),
    );
    return [...local, ...remote];
  }

  private broadcastPresence(room: RoomId) {
    this.toRoom(room, { type: "presence", room, online: this.online(room) });
  }

  private publishPresence() {
    const users = [...this.sockets.values()].filter((s) => s.room).map((s) => ({ userId: s.userId, name: s.name, room: s.room! }));
    this.publish({ type: "presence", node: this.node, users, at: Date.now() });
  }

  private toMembers(room: RoomId, e: ServerEvent) {
    for (const s of this.sockets.values()) if (s.joined.has(room)) this.emit(s.id, e);
  }

  private toRoom(room: RoomId, e: ServerEvent, exceptSocket?: string) {
    for (const s of this.sockets.values()) if (s.room === room && s.id !== exceptSocket) this.emit(s.id, e);
  }
}

/* ---------- helpers ---------- */

export const makeKey = (clock: number, node: string) => `${String(clock).padStart(10, "0")}:${node}`;
const maxKey = (...keys: string[]) => keys.reduce((a, b) => (b > a ? b : a), "");

/**
 * Guest tokens. Production signs a JWT on POST /auth/guest and a guard verifies it in handleConnection;
 * the demo uses a readable stand-in with a checksum so a hand-made token is rejected.
 */
export function issueToken(userId: string, name: string) {
  const body = `${userId}.${name}`;
  return `${body}.${checksum(body)}`;
}
function verifyToken(token: string): { userId: string; name: string } | null {
  const [userId, name, sum] = token.split(".");
  if (!userId || !name || !sum || checksum(`${userId}.${name}`) !== sum) return null;
  return { userId, name };
}
function checksum(s: string) {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
  return (h >>> 0).toString(36);
}

/** Seed history: fictional team, fixed keys so every visitor starts with the same rooms. */
export function seedMessages(): Message[] {
  const base = Date.UTC(2026, 10, 11, 8, 30);
  const rows: [RoomId, string, string, string][] = [
    ["general", "u_ayesha", "Ayesha", "Morning! Booking form for Acme Dental is on staging."],
    ["general", "u_ben", "Ben", "Nice. I'll run the checkout flow on my phone after standup."],
    ["general", "u_carmen", "Carmen", "Reminder: client demo moved to Thursday 3 pm."],
    ["general", "u_ayesha", "Ayesha", "Order #1042 test booking went through, confirmation email arrived."],
    ["design", "u_carmen", "Carmen", "Uploaded two options for the appointment picker. Option B has bigger tap targets."],
    ["design", "u_ben", "Ben", "B is easier on mobile. Can we keep the green from the logo?"],
    ["random", "u_ben", "Ben", "Who took the last oat milk?"],
  ];
  return rows.map(([room, userId, name, text], i) => ({
    key: makeKey(i + 1, "seed"),
    clientId: `seed-${i}`,
    room,
    userId,
    name,
    text,
    at: base + i * 240_000,
    node: "seed",
  }));
}
app/lab/realtime-chat/client.ts318 lines
/**
 * The browser side of the chat: what a Next.js client component would keep around its socket.
 * Optimistic sends with acks, an offline outbox, reconnect + sync, typing and read receipts.
 *
 * The "network" is simulated: every event in either direction is delayed by this client's latency
 * (plus jitter, so events can arrive out of order), and anything sent to a disconnected client is lost,
 * exactly like a real dropped connection.
 */

import { issueToken, type Ack, type ChatServer, type ClientEvent, type Message, type RoomId, type ServerEvent, type UserReceipt } from "./server";

export type Status = "queued" | "sending" | "sent" | "failed";
export type ChatMsg = Message & { status: Status; error?: string; mine: boolean };

export type ClientUser = { userId: string; name: string; device: string };

export class ChatClient {
  readonly user: ClientUser;
  readonly socketId: string;
  online = true;
  connected = false;
  latency = 120;
  room: RoomId = "general";
  messages: Record<RoomId, ChatMsg[]> = { general: [], design: [], random: [] };
  unread: Record<RoomId, number> = { general: 0, design: 0, random: 0 };
  presence: { userId: string; name: string; remote: boolean }[] = [];
  typing = new Map<string, { name: string; until: number }>();
  receipts: Record<string, UserReceipt> = {};
  private joined = new Set<RoomId>();
  private seq = 0;
  private typingSentAt = 0;
  private typingStop: ReturnType<typeof setTimeout> | null = null;
  private timers = new Set<ReturnType<typeof setTimeout>>();

  constructor(
    user: ClientUser,
    private server: ChatServer,
    private onChange: () => void,
    private log: (text: string, tone: "ok" | "warn" | "err" | "info" | "dim" | "accent") => void,
  ) {
    this.user = user;
    this.socketId = `sock_${user.userId.slice(2)}_${server.node}`;
  }

  /* ---------- network simulation ---------- */

  private lastAt = { up: 0, down: 0 };

  /**
   * One WebSocket is a single TCP stream, so events in one direction arrive in the order they were sent:
   * jitter can delay an event but never let a later one overtake it. Different clients have different
   * links, which is why messages from two people can still reach the server in either order.
   */
  private delay(fn: () => void, dir: "up" | "down" = "down") {
    const jitter = Math.round(this.latency * 0.4 * Math.random());
    const now = Date.now();
    const at = Math.max(now + this.latency + jitter, this.lastAt[dir] + 1);
    this.lastAt[dir] = at;
    const t = setTimeout(() => {
      this.timers.delete(t);
      fn();
    }, at - now);
    this.timers.add(t);
  }

  /** client -> server, with an optional ack coming back over the same link. */
  private transmit(e: ClientEvent, onAck?: (a: Ack) => void) {
    if (!this.connected) return false;
    this.delay(() => {
      if (!this.online) return; // packet lost on the way
      const ack = this.server.handle(this.socketId, e);
      if (ack && onAck)
        this.delay(() => {
          if (this.connected) onAck(ack);
          this.onChange();
        });
    }, "up");
    return true;
  }

  /** server -> client. Called by the server's emit; dropped if the client went offline meanwhile. */
  deliver(e: ServerEvent) {
    this.delay(() => {
      if (!this.connected) return;
      this.receive(e);
      this.onChange();
    });
  }

  /* ---------- lifecycle ---------- */

  connect() {
    if (!this.online) return;
    this.delay(() => {
      if (!this.online) return;
      const ok = this.server.connect(this.socketId, { token: issueToken(this.user.userId, this.user.name) });
      if (!ok) return;
      this.connected = true;
      // First connect gets recent history; a reconnect asks each room only for what it missed.
      // The room on screen is joined last so it ends up as the active one.
      this.joined.add(this.room);
      for (const room of [...this.joined].filter((r) => r !== this.room).concat(this.room)) {
        const after = this.lastKey(room);
        this.transmit({ type: "join", room, ...(after ? { afterKey: after } : {}) });
      }
      this.flush();
      this.onChange();
    });
  }

  setOnline(online: boolean) {
    if (online === this.online) return;
    this.online = online;
    if (!online) {
      this.connected = false;
      this.typing.clear();
      this.log(`${this.user.name}: network down (offline mode)`, "warn");
      // The server only notices when pings stop: Socket.IO's pingTimeout.
      const t = setTimeout(() => this.server.disconnect(this.socketId, "ping timeout"), 1500);
      this.timers.add(t);
    } else {
      this.log(`${this.user.name}: network back, reconnecting…`, "info");
      this.connect();
    }
    this.onChange();
  }

  dispose() {
    for (const t of this.timers) clearTimeout(t);
    this.timers.clear();
    if (this.typingStop) clearTimeout(this.typingStop);
    this.server.disconnect(this.socketId, "client closed");
  }

  /* ---------- user actions ---------- */

  switchRoom(room: RoomId) {
    if (room === this.room) return;
    this.setTyping(false);
    this.room = room;
    this.unread[room] = 0;
    this.joined.add(room);
    this.transmit({ type: "join", room });
    this.markRead();
    this.onChange();
  }

  send(text: string) {
    this.seq += 1;
    const msg: ChatMsg = {
      key: "",
      clientId: `${this.user.userId}-${Date.now().toString(36)}-${this.seq}`,
      room: this.room,
      userId: this.user.userId,
      name: this.user.name,
      text,
      at: Date.now(),
      node: this.server.node,
      status: this.connected ? "sending" : "queued",
      mine: true,
    };
    this.messages[this.room] = [...this.messages[this.room], msg];
    this.setTyping(false);
    if (this.connected) this.push(msg);
    this.onChange();
  }

  retry(clientId: string) {
    const m = this.find(clientId);
    if (!m) return;
    m.status = this.connected ? "sending" : "queued";
    m.error = undefined;
    if (this.connected) this.push(m);
    this.onChange();
  }

  /** Called on every keystroke in the composer. Throttled to one "typing" per 2 s, auto-stop after 2.5 s idle. */
  draft(text: string) {
    if (!text) return this.setTyping(false);
    const now = Date.now();
    if (now - this.typingSentAt > 2000) {
      this.typingSentAt = now;
      this.transmit({ type: "typing", room: this.room, typing: true });
    }
    if (this.typingStop) clearTimeout(this.typingStop);
    this.typingStop = setTimeout(() => this.setTyping(false), 2500);
  }

  setLatency(ms: number) {
    this.latency = ms;
    this.onChange();
  }

  /* ---------- internals ---------- */

  private setTyping(on: boolean) {
    if (!on && this.typingSentAt) {
      this.typingSentAt = 0;
      this.transmit({ type: "typing", room: this.room, typing: false });
    }
    if (this.typingStop) clearTimeout(this.typingStop);
    this.typingStop = null;
  }

  private push(m: ChatMsg) {
    this.transmit({ type: "send", room: m.room, clientId: m.clientId, text: m.text }, (ack) => {
      const cur = this.find(m.clientId);
      if (!cur) return;
      if (ack.ok) {
        cur.status = "sent";
        cur.key = ack.key;
        cur.at = ack.at;
        this.sort(cur.room);
      } else {
        cur.status = "failed";
        cur.error = ack.error === "rate_limited" ? "Slow down: 10 messages per 5 s" : ack.error === "too_long" ? "Too long (500 max)" : ack.error;
      }
      this.onChange();
    });
  }

  /** Send the offline outbox (and anything that never got an ack) in the order it was written. */
  private flush() {
    const pending = (Object.keys(this.messages) as RoomId[]).flatMap((r) => this.messages[r].filter((m) => m.mine && (m.status === "queued" || m.status === "sending")));
    if (pending.length) this.log(`${this.user.name}: flushing ${pending.length} queued message(s)`, "accent");
    for (const m of pending) {
      m.status = "sending";
      this.push(m);
    }
  }

  private receive(e: ServerEvent) {
    switch (e.type) {
      case "message":
        this.merge([e.msg]);
        if (e.msg.userId !== this.user.userId) {
          this.typing.delete(e.msg.userId);
          if (e.msg.room === this.room) this.markRead();
          else {
            this.unread[e.msg.room] += 1;
            this.transmit({ type: "receipt", room: e.msg.room, delivered: e.msg.key });
          }
        }
        break;
      case "history":
        this.merge(e.messages);
        if (e.reason === "sync" && e.messages.length) this.log(`${this.user.name}: caught up on ${e.messages.length} missed message(s) in #${e.room}`, "ok");
        if (e.room === this.room) this.markRead();
        else if (e.reason === "sync") this.unread[e.room] += e.messages.filter((m) => m.userId !== this.user.userId).length;
        break;
      case "presence":
        if (e.room === this.room) this.presence = e.online;
        break;
      case "typing":
        if (e.room !== this.room || e.userId === this.user.userId) break;
        if (e.typing) this.typing.set(e.userId, { name: e.name, until: Date.now() + 4000 });
        else this.typing.delete(e.userId);
        break;
      case "receipts":
        if (e.room === this.room) this.receipts = { ...e.receipts };
        break;
    }
  }

  /** Insert server messages by key; replace our optimistic copy when its clientId comes back. */
  private merge(msgs: Message[]) {
    for (const m of msgs) {
      const list = this.messages[m.room];
      const mine = list.find((x) => x.clientId === m.clientId);
      if (mine) {
        Object.assign(mine, m, { status: "sent" as Status });
        continue;
      }
      if (list.some((x) => x.key === m.key)) continue;
      list.push({ ...m, status: "sent", mine: m.userId === this.user.userId && m.node === this.server.node });
    }
    for (const r of new Set(msgs.map((m) => m.room))) this.sort(r);
  }

  private sort(room: RoomId) {
    // Confirmed messages in server order; unconfirmed ones stay at the end in the order they were written.
    this.messages[room] = [...this.messages[room]].sort((a, b) => {
      if (!a.key || !b.key) return a.key ? -1 : b.key ? 1 : a.at - b.at;
      return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
    });
  }

  private markRead() {
    const key = this.lastKey(this.room);
    if (key) this.transmit({ type: "receipt", room: this.room, read: key });
  }

  lastKey(room: RoomId) {
    const list = this.messages[room].filter((m) => m.key);
    return list.length ? list[list.length - 1]!.key : "";
  }

  private find(clientId: string) {
    for (const r of Object.keys(this.messages) as RoomId[]) {
      const m = this.messages[r].find((x) => x.clientId === clientId);
      if (m) return m;
    }
    return null;
  }

  /** Who has read / received a given key (for the ticks under my messages). */
  seenBy(key: string) {
    const read: string[] = [];
    let delivered = false;
    for (const [uid, r] of Object.entries(this.receipts)) {
      if (uid === this.user.userId) continue;
      if (r.read >= key) read.push(uid);
      if (r.delivered >= key || r.read >= key) delivered = true;
    }
    return { read, delivered };
  }
}
app/lab/realtime-chat/Demo.tsx348 lines
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { PanelTitle, Pill, TerminalLog, btn, useInterval, useLog } from "@/components/lab/systems/ui";
import { ChatClient, type ChatMsg, type ClientUser } from "./client";
import { ChatServer, MAX_LEN, ROOMS, seedMessages, type AdapterMessage, type Message, type RoomId } from "./server";

const KEY = "lab:realtime-chat:v1";
const CHANNEL = "lab:realtime-chat";

const USERS: (ClientUser & { colour: string })[] = [
  { userId: "u_ayesha", name: "Ayesha", device: "laptop", colour: "bg-accent-2" },
  { userId: "u_ben", name: "Ben", device: "phone", colour: "bg-accent-3" },
  { userId: "u_carmen", name: "Carmen", device: "tablet", colour: "bg-accent" },
];
const nameOf = (uid: string) => USERS.find((u) => u.userId === uid)?.name ?? uid;

type World = { server: ChatServer; clients: ChatClient[]; dispose: () => void };

function loadSaved(): Message[] | null {
  try {
    const raw = localStorage.getItem(KEY);
    if (!raw) return null;
    const v: unknown = JSON.parse(raw);
    return Array.isArray(v) && v.every((m) => m && typeof (m as Message).key === "string" && typeof (m as Message).text === "string") ? (v as Message[]) : null;
  } catch {
    return null;
  }
}

function createWorld(onChange: () => void, log: (t: string, tone: "ok" | "warn" | "err" | "info" | "dim" | "accent") => void): World {
  const node = Math.random().toString(16).slice(2, 6);
  let clients: ChatClient[] = [];
  const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(CHANNEL) : null;
  const server = new ChatServer(
    node,
    (socketId, e) => clients.find((c) => c.socketId === socketId)?.deliver(e),
    (m: AdapterMessage) => channel?.postMessage(m),
    (l) => log(`[server ${node}] ${l.text}`, l.tone),
  );
  server.load(loadSaved() ?? seedMessages());
  if (channel)
    channel.onmessage = (ev: MessageEvent<AdapterMessage>) => {
      server.fromAdapter(ev.data);
      onChange();
    };
  clients = USERS.map((u) => new ChatClient(u, server, onChange, log));
  clients.forEach((c) => c.connect());
  const hb = window.setInterval(() => server.heartbeat(Date.now()), 4000);
  return {
    server,
    clients,
    dispose: () => {
      window.clearInterval(hb);
      clients.forEach((c) => c.dispose());
      channel?.postMessage({ type: "bye", node } satisfies AdapterMessage);
      channel?.close();
    },
  };
}

export default function Demo() {
  const [world, setWorld] = useState<World | null>(null);
  const [, setVersion] = useState(0);
  const [active, setActive] = useState(0);
  const { lines, push, clear } = useLog(120);
  const frame = useRef<number | null>(null);
  const saveTimer = useRef<number | null>(null);
  const worldRef = useRef<World | null>(null);

  // Batch many network events into one render per animation frame, and save history (throttled).
  const onChange = useCallback(() => {
    if (frame.current === null)
      frame.current = requestAnimationFrame(() => {
        frame.current = null;
        setVersion((v) => v + 1);
      });
    if (saveTimer.current === null)
      saveTimer.current = window.setTimeout(() => {
        saveTimer.current = null;
        try {
          if (worldRef.current) localStorage.setItem(KEY, JSON.stringify(worldRef.current.server.snapshot()));
        } catch {
          /* storage full or blocked: chat still works in memory */
        }
      }, 1000);
  }, []);

  const build = useCallback(() => {
    const w = createWorld(onChange, push);
    worldRef.current = w;
    setWorld(w);
    return w;
  }, [onChange, push]);

  useEffect(() => {
    const w = build();
    const bye = () => w.dispose();
    window.addEventListener("pagehide", bye);
    return () => {
      window.removeEventListener("pagehide", bye);
      worldRef.current?.dispose();
      if (frame.current !== null) cancelAnimationFrame(frame.current);
      if (saveTimer.current !== null) window.clearTimeout(saveTimer.current);
    };
  }, [build]);

  // Re-render each second so typing indicators expire on time.
  useInterval(() => setVersion((v) => v + 1), 1000);

  function reset() {
    worldRef.current?.dispose();
    try {
      localStorage.removeItem(KEY);
    } catch {
      /* ignore */
    }
    clear();
    build();
  }

  if (!world) return <div className="p-6 font-mono text-sm text-fg-muted">connecting sockets…</div>;
  const { server, clients } = world;
  const tabs = server.remoteNodes();

  return (
    <div className="space-y-4 p-4 sm:p-6">
      <div className="flex flex-wrap items-center gap-2 lg:hidden" role="tablist" aria-label="Simulated clients">
        {clients.map((c, i) => (
          <button
            key={c.user.userId}
            type="button"
            role="tab"
            aria-selected={active === i}
            onClick={() => setActive(i)}
            className={`rounded-md border px-3 py-1 font-mono text-xs ${active === i ? "border-accent text-accent" : "border-border"}`}
          >
            {c.user.name} {c.online ? "" : "· offline"}
          </button>
        ))}
      </div>

      <div className="grid gap-4 lg:grid-cols-3">
        {clients.map((c, i) => (
          <div key={c.user.userId} className={active === i ? "" : "hidden lg:block"}>
            <ClientPane client={c} colour={USERS[i]!.colour} />
          </div>
        ))}
      </div>

      <div className="grid gap-4 lg:grid-cols-[1fr_1.4fr]">
        <section aria-label="Server state" className="rounded-md border border-border p-3 text-xs">
          <PanelTitle
            right={
              <button type="button" className={btn} onClick={reset}>
                reset demo
              </button>
            }
          >
            gateway <span className="text-accent">node {server.node}</span>
          </PanelTitle>
          <table className="w-full text-left">
            <caption className="sr-only">Rooms on this server node</caption>
            <thead className="font-mono text-fg-muted">
              <tr className="border-b border-border">
                <th className="py-1 font-normal">room</th>
                <th className="py-1 font-normal">msgs</th>
                <th className="py-1 font-normal">on screen</th>
              </tr>
            </thead>
            <tbody>
              {server.stats().map((r) => (
                <tr key={r.room} className="border-b border-border">
                  <td className="py-1 font-mono">#{r.room}</td>
                  <td className="py-1 font-mono">{r.messages}</td>
                  <td className="py-1">{r.online.join(", ") || "—"}</td>
                </tr>
              ))}
            </tbody>
          </table>
          <p className="mt-2 font-mono text-fg-muted">
            lamport clock {server.lamport} · other tabs: {tabs.length ? tabs.join(", ") : "none"}
          </p>
          <p className="mt-2 text-fg-muted">
            Open this page in a second tab: each tab is a server node, and a BroadcastChannel carries messages and presence between them, like the Redis adapter does between Socket.IO servers.{" "}
            <a href="/lab/realtime-chat" target="_blank" rel="noopener" className="text-accent underline">
              Open another tab
            </a>
          </p>
        </section>
        <section aria-label="Socket events">
          <PanelTitle>socket events</PanelTitle>
          <TerminalLog lines={lines} label="Socket and gateway events" className="h-52" />
        </section>
      </div>
    </div>
  );
}

function ClientPane({ client: c, colour }: { client: ChatClient; colour: string }) {
  const [text, setText] = useState("");
  const listRef = useRef<HTMLOListElement>(null);
  const msgs = c.messages[c.room];
  const lastMine = [...msgs].reverse().find((m) => m.mine && m.key);
  const now = Date.now();
  const typing = [...c.typing.values()].filter((t) => t.until > now).map((t) => t.name);
  const id = `pane-${c.user.userId}`;

  useEffect(() => {
    const el = listRef.current;
    if (el) el.scrollTop = el.scrollHeight;
  }, [msgs.length, c.room]);

  function submit(e?: React.FormEvent) {
    e?.preventDefault();
    const t = text.trim();
    if (!t || t.length > MAX_LEN) return;
    c.send(t);
    setText("");
  }

  return (
    <section aria-labelledby={`${id}-title`} className="flex h-[34rem] flex-col overflow-hidden rounded-md border border-border bg-bg-elev">
      <header className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
        <span className={`grid size-7 place-items-center rounded-full text-xs font-bold text-[#0b0b0d] ${colour}`} aria-hidden="true">
          {c.user.name[0]}
        </span>
        <h3 id={`${id}-title`} className="text-sm font-semibold">
          {c.user.name} <span className="font-normal text-fg-muted">· {c.user.device}</span>
        </h3>
        <span className="ml-auto">{c.connected ? <Pill tone="ok">connected</Pill> : c.online ? <Pill tone="info">connecting</Pill> : <Pill tone="warn">offline</Pill>}</span>
      </header>
      <div className="flex flex-wrap items-center gap-3 border-b border-border px-3 py-1.5 text-[11px]">
        <label className="flex items-center gap-1.5">
          <input type="checkbox" checked={c.online} onChange={(e) => c.setOnline(e.target.checked)} />
          network
        </label>
        <label className="flex items-center gap-1.5 font-mono text-fg-muted">
          latency
          <input type="range" min={20} max={1500} step={10} value={c.latency} onChange={(e) => c.setLatency(Number(e.target.value))} className="w-20 accent-[var(--accent)]" aria-label={`${c.user.name} latency in milliseconds`} />
          {c.latency} ms
        </label>
      </div>
      <nav aria-label={`${c.user.name}'s rooms`} className="flex gap-1 border-b border-border px-2 py-1.5">
        {ROOMS.map((r) => (
          <button
            key={r.id}
            type="button"
            onClick={() => c.switchRoom(r.id as RoomId)}
            aria-current={c.room === r.id ? "page" : undefined}
            className={`rounded px-2 py-0.5 font-mono text-xs ${c.room === r.id ? "bg-accent-soft text-accent" : "text-fg-muted hover:text-fg"}`}
          >
            {r.name}
            {c.unread[r.id] > 0 && <span className="ml-1 rounded-full bg-accent px-1.5 text-[10px] text-accent-fg">{c.unread[r.id]}</span>}
          </button>
        ))}
      </nav>
      <p className="truncate px-3 pt-1 text-[11px] text-fg-muted">
        online: {c.presence.map((p) => p.name).join(", ") || "—"}
      </p>
      <ol ref={listRef} role="log" aria-live="polite" aria-label={`Messages in #${c.room} as seen by ${c.user.name}`} className="flex-1 space-y-1.5 overflow-y-auto px-3 py-2">
        {msgs.length === 0 && <li className="py-6 text-center text-xs text-fg-muted">{c.connected ? "No messages yet." : "Waiting for connection…"}</li>}
        {msgs.map((m) => (
          <Bubble key={m.clientId + m.key} m={m} c={c} showSeen={m === lastMine} />
        ))}
      </ol>
      <p className="h-5 px-3 text-[11px] text-fg-muted" aria-live="polite">
        {typing.length ? `${typing.join(", ")} ${typing.length > 1 ? "are" : "is"} typing…` : ""}
      </p>
      <form onSubmit={submit} className="flex items-end gap-2 border-t border-border p-2">
        <label className="sr-only" htmlFor={`${id}-input`}>
          Message as {c.user.name}
        </label>
        <textarea
          id={`${id}-input`}
          rows={1}
          value={text}
          maxLength={MAX_LEN + 50}
          onChange={(e) => {
            setText(e.target.value);
            c.draft(e.target.value);
          }}
          onKeyDown={(e) => {
            if (e.key === "Enter" && !e.shiftKey) {
              e.preventDefault();
              submit();
            }
          }}
          placeholder={c.connected ? `Message #${c.room}` : "Offline: messages will queue"}
          className="min-h-9 flex-1 resize-none rounded-md border border-border bg-bg px-2 py-1.5 text-sm outline-none focus:border-accent"
        />
        <button type="submit" disabled={!text.trim() || text.length > MAX_LEN} className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg disabled:opacity-50">
          Send
        </button>
      </form>
      {text.length > MAX_LEN - 50 && (
        <p className={`px-3 pb-1 text-right font-mono text-[10px] ${text.length > MAX_LEN ? "text-danger" : "text-fg-muted"}`}>
          {text.length}/{MAX_LEN}
        </p>
      )}
    </section>
  );
}

function Bubble({ m, c, showSeen }: { m: ChatMsg; c: ChatClient; showSeen: boolean }) {
  const time = new Date(m.at).toISOString().slice(11, 16);
  let tick: React.ReactNode = null;
  if (m.mine) {
    if (m.status === "queued") tick = <span className="text-warn-fg">queued (offline)</span>;
    else if (m.status === "sending") tick = <span>sending…</span>;
    else if (m.status === "failed")
      tick = (
        <span className="text-danger">
          {m.error}{" "}
          <button type="button" className="underline" onClick={() => c.retry(m.clientId)}>
            retry
          </button>
        </span>
      );
    else {
      const s = c.seenBy(m.key);
      tick = s.read.length ? (
        <span className="text-accent">✓✓ {showSeen ? `seen by ${s.read.map(nameOf).join(", ")}` : ""}</span>
      ) : s.delivered ? (
        <span>✓✓ delivered</span>
      ) : (
        <span>✓ sent</span>
      );
    }
  }
  return (
    <li className={`flex flex-col ${m.mine ? "items-end" : "items-start"}`}>
      <div className={`max-w-[85%] rounded-lg px-2.5 py-1.5 text-sm ${m.mine ? "bg-accent-soft" : "bg-bg-muted"} ${m.status === "failed" ? "border border-danger" : ""}`}>
        {!m.mine && (
          <p className="text-[11px] font-semibold text-fg-muted">
            {m.name}
            {m.node !== "seed" && m.node !== c.socketId.split("_").pop() && <span className="ml-1 font-normal">· tab {m.node}</span>}
          </p>
        )}
        <p className="break-words whitespace-pre-wrap">{m.text}</p>
      </div>
      <p className="mt-0.5 font-mono text-[10px] text-fg-muted">
        {time} {tick}
      </p>
    </li>
  );
}
components/lab/systems/ui.tsx175 lines
"use client";

/**
 * Small shared pieces for the systems/backend lab demos (job queue, webhook relay, uploads, chat).
 * Theme-aware controls use tokens; terminal panels are always dark and use fixed colours.
 */

import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";

export const btn =
  "inline-flex items-center justify-center gap-1.5 rounded-md border border-border px-2.5 py-1 font-mono text-xs hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:text-inherit";
export const btnPrimary =
  "inline-flex items-center justify-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50";
export const btnDanger =
  "inline-flex items-center justify-center gap-1.5 rounded-md border border-border px-2.5 py-1 font-mono text-xs hover:border-danger hover:text-danger disabled:opacity-50";
export const input =
  "w-full rounded-md border border-border bg-bg-elev px-2.5 py-1.5 text-sm outline-none focus:border-accent";
export const label = "block text-xs font-medium text-fg-muted";

export type Tone = "ok" | "warn" | "err" | "info" | "dim" | "accent";
export type LogLine = { id: number; text: string; tone: Tone };

export const TONE: Record<Tone, string> = {
  ok: "text-[#4ade80]",
  warn: "text-[#fbbf24]",
  err: "text-[#f87171]",
  info: "text-[#a1a1aa]",
  dim: "text-[#71717a]",
  accent: "text-[#60a5fa]",
};

/** Always-dark request/event log. Newest line first. */
export function TerminalLog({
  lines,
  label: name,
  empty = "$ waiting for events…",
  className = "h-56",
}: {
  lines: LogLine[];
  label: string;
  empty?: string;
  className?: string;
}) {
  return (
    <ol
      role="log"
      aria-live="polite"
      aria-label={name}
      className={`overflow-y-auto rounded-md border border-border bg-[#0b0b0d] p-3 font-mono text-[12px] leading-relaxed ${className}`}
    >
      {lines.length === 0 && <li className="text-[#71717a]">{empty}</li>}
      {lines.map((l) => (
        <li key={l.id} className={`break-words ${TONE[l.tone]}`}>
          {l.text}
        </li>
      ))}
    </ol>
  );
}

/** Log buffer with stable ids (no Date.now in render). */
export function useLog(max = 80) {
  const [lines, setLines] = useState<LogLine[]>([]);
  const seq = useRef(0);
  const push = useCallback(
    (text: string, tone: Tone = "info") => {
      seq.current += 1;
      const id = seq.current;
      setLines((l) => [{ id, text, tone }, ...l].slice(0, max));
    },
    [max],
  );
  const clear = useCallback(() => setLines([]), []);
  return { lines, push, clear };
}

/**
 * State persisted to localStorage under `key`. The initial render uses `null` (server and first client
 * render match); the stored value or the seed is loaded in an effect, so it is hydration-safe.
 */
export function useStoredState<T>(key: string, seed: () => T, isValid: (v: unknown) => boolean = () => true) {
  const [state, setState] = useState<T | null>(null);
  const seedRef = useRef(seed);
  const validRef = useRef(isValid);

  useEffect(() => {
    let loaded: T | null = null;
    try {
      const raw = localStorage.getItem(key);
      if (raw) {
        const parsed: unknown = JSON.parse(raw);
        if (validRef.current(parsed)) loaded = parsed as T;
      }
    } catch {
      /* storage blocked or corrupted: fall back to the seed */
    }
    setState(loaded ?? seedRef.current());
  }, [key]);

  // Throttled save (at most every 750 ms) so a fast simulation does not write on every tick,
  // but a simulation that changes state constantly still gets saved.
  const latest = useRef<T | null>(null);
  const timer = useRef<number | null>(null);
  useEffect(() => {
    latest.current = state;
    if (state === null || timer.current !== null) return;
    timer.current = window.setTimeout(() => {
      timer.current = null;
      try {
        localStorage.setItem(key, JSON.stringify(latest.current));
      } catch {
        /* quota exceeded or storage blocked: the demo still works in memory */
      }
    }, 750);
  }, [key, state]);
  useEffect(
    () => () => {
      if (timer.current !== null) window.clearTimeout(timer.current);
    },
    [],
  );

  const reset = useCallback(() => {
    try {
      localStorage.removeItem(key);
    } catch {
      /* ignore */
    }
    setState(seedRef.current());
  }, [key]);

  return [state, setState, reset] as const;
}

/** setInterval that always calls the latest callback. `ms = null` stops it. */
export function useInterval(cb: () => void, ms: number | null) {
  const ref = useRef(cb);
  useEffect(() => {
    ref.current = cb;
  }, [cb]);
  useEffect(() => {
    if (ms === null) return;
    const id = window.setInterval(() => ref.current(), ms);
    return () => window.clearInterval(id);
  }, [ms]);
}

/** Section heading used inside demos. */
export function PanelTitle({ children, right }: { children: ReactNode; right?: ReactNode }) {
  return (
    <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
      <h3 className="font-mono text-sm font-semibold">{children}</h3>
      {right}
    </div>
  );
}

/** Small status pill with token colours. */
export function Pill({ tone, children }: { tone: "ok" | "warn" | "err" | "muted" | "info"; children: ReactNode }) {
  const cls = {
    ok: "border-accent/40 bg-accent-soft text-accent",
    warn: "border-warn-fg/40 bg-warn-bg text-warn-fg",
    err: "border-danger/40 text-danger",
    muted: "border-border text-fg-muted",
    info: "border-accent-2/40 text-accent-2",
  }[tone];
  return <span className={`inline-flex items-center rounded-full border px-2 py-0.5 font-mono text-[11px] ${cls}`}>{children}</span>;
}

export function formatBytes(n: number) {
  if (n < 1024) return `${n} B`;
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
  return `${(n / 1024 / 1024).toFixed(2)} MB`;
}