"use client"; /** * RealtimeClient — JSON control-protocol WebSocket client. * * Wire format (see ARCHITECTURE.md §5): * * browser → server * { "type": "subscribe", "channel": "page:" | "workspace:" } * { "type": "unsubscribe", "channel": "page:" } * * browser → server (presence, TASK-0093 — identity is server-stamped; * the frame carries ONLY the cursor/viewing state): * { "type": "presence", "cursor"?: {blockId, offset, selection?} | null, "viewing"?: boolean } * { "type": "presence-leave" } * * server → browser * { "type": "presence", "pageId", "user": {id, name, color}, "cursor"?: … | null, "viewing"?: bool, "lastSeen" } * { "type": "presence-leave", "pageId", "userId" } * { "type": "block:upsert", "pageId", "blocks": Block[] } * { "type": "block:delete", "pageId", "blockIds": string[] } * { "type": "block:move", "pageId", "blockId", "parentId", "position" } * { "type": "block:changed", "pageId", "version"? } // (b) refetch-hint * { "type": "comment.event", "kind", "commentId", "pageId", "actorId", "preview" } * { "type": "notification.push", "notificationId", "kind", "title", "deepLink", "actorName"? } * { "type": "ack", "requestId" } * * Features: token-auth query param, auto-reconnect with exponential backoff + * jitter, channel re-subscribe on recovery, typed event emitter, status state. */ import { WS_URL } from "./constants"; import type { OwnPresence, RealtimeServerEvent } from "./types"; export type RealtimeStatus = "disconnected" | "connecting" | "connected" | "reconnecting"; export type RealtimeEvent = | "status" | "presence" | "presence-leave" | "block:upsert" | "block:delete" | "block:move" | "block:changed" | "comment.event" | "notification.push" | "ack"; /** * The WS upgrade URL for the realtime gateway. * * The server's onUpgrade accepts ONLY the `/realtime` path (server.ts — a * root-path upgrade is destroyed without an HTTP response), so the client * MUST connect to `/realtime`, not the bare origin. This helper is * the single source of truth for that (unit-tested); TASK-0095 S13 recorded * the latent defect it fixes (the old inline URL used the bare origin, so a * browser tab's socket could never have joined — the first browser-driven * realtime E2E surfaced it). */ export function realtimeUrl( token: string | null | undefined, base: string = WS_URL, ): string { const root = base.replace(/\/+$/, ""); return token ? `${root}/realtime?token=${encodeURIComponent(token)}` : `${root}/realtime`; } type Listener = (event: RealtimeServerEvent) => void; type StatusListener = (status: RealtimeStatus) => void; const RECONNECT_BASE_MS = 250; const RECONNECT_MAX_MS = 15_000; class RealtimeClient { private ws: WebSocket | null = null; private status: RealtimeStatus = "disconnected"; private channels = new Set(); private reconnectAttempts = 0; private reconnectTimer: ReturnType | null = null; private closedByCaller = false; private token: string | null = null; private listeners = new Map>(); private statusListeners = new Set(); get connectionStatus(): RealtimeStatus { return this.status; } get isAvailable(): boolean { return typeof WebSocket !== "undefined"; } /** Connect (idempotent). The token is read once per connect attempt. */ connect(getToken: () => string | null): void { if (!this.isAvailable) return; this.closedByCaller = false; this.token = getToken(); if ( this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) ) { return; } this.setStatus("connecting"); const url = realtimeUrl(this.token); const ws = new WebSocket(url); this.ws = ws; ws.onopen = () => { this.reconnectAttempts = 0; this.setStatus("connected"); // (Re-)subscribe every channel we want open. for (const channel of this.channels) { this.send({ type: "subscribe", channel }); } }; ws.onmessage = (msg: MessageEvent) => this.handleMessage(msg); ws.onerror = () => { /* onclose follows and drives the reconnect loop. */ }; ws.onclose = () => { this.ws = null; if (this.closedByCaller) { this.setStatus("disconnected"); return; } this.setStatus("reconnecting"); this.scheduleReconnect(getToken); }; } /** Subscribe to a channel; sends immediately when already connected. */ subscribe(channel: string): void { this.channels.add(channel); if (this.ws?.readyState === WebSocket.OPEN) { this.send({ type: "subscribe", channel }); } } unsubscribe(channel: string): void { this.channels.delete(channel); if (this.ws?.readyState === WebSocket.OPEN) { this.send({ type: "unsubscribe", channel }); } } /** * Send this tab's presence state (TASK-0093): `{type:"presence", cursor?, * viewing?}` — the wire schema forbids identity (the server stamps * `user` from the handshake token + the display-name resolver). Only the * provided fields are sent (an omitted `viewing` keeps the server's * stored value). No-op when not connected (reconnect re-sends via the * caller's re-subscribe + the next state change). */ sendPresence(state: OwnPresence): void { const payload: Record = { type: "presence" }; if (state.cursor !== undefined) payload.cursor = state.cursor; if (state.viewing !== undefined) payload.viewing = state.viewing; this.send(payload); } /** Explicitly leave the current page's presence (the server drops this * tab's peer immediately instead of waiting for the 90 s prune). */ sendPresenceLeave(): void { this.send({ type: "presence-leave" }); } disconnect(): void { this.closedByCaller = true; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.channels.clear(); if (this.ws) { const ws = this.ws; this.ws = null; ws.onclose = null; ws.onerror = null; ws.onmessage = null; ws.close(); } this.setStatus("disconnected"); } /** Subscribe to a typed event. Returns an unsubscribe function. */ on(event: RealtimeEvent, listener: Listener): () => void { let set = this.listeners.get(event); if (!set) { set = new Set(); this.listeners.set(event, set); } set.add(listener); return () => set.delete(listener); } onStatus(listener: StatusListener): () => void { this.statusListeners.add(listener); return () => this.statusListeners.delete(listener); } /** Test/SSR escape hatch: emit a synthetic server frame locally. */ emit(event: RealtimeServerEvent): void { const key = event.type as RealtimeEvent; this.listeners.get(key)?.forEach((l) => l(event)); } /* -------------------------------------------------------------- intern */ private send(payload: unknown): void { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(payload)); } } private handleMessage(msg: MessageEvent): void { let event: RealtimeServerEvent; try { event = JSON.parse(String(msg.data)) as RealtimeServerEvent; } catch { return; // Ignore malformed frames. } this.emit(event); } private scheduleReconnect(getToken: () => string | null): void { this.reconnectAttempts += 1; const base = Math.min(RECONNECT_BASE_MS * 2 ** (this.reconnectAttempts - 1), RECONNECT_MAX_MS); const delay = base / 2 + Math.random() * (base / 2); // backoff + jitter this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect(getToken); }, delay); } private setStatus(status: RealtimeStatus): void { if (this.status === status) return; this.status = status; this.statusListeners.forEach((l) => l(status)); } } /** App-wide singleton (one socket per tab). */ export const realtime = new RealtimeClient(); /** Channel helpers. */ export const channelForPage = (pageId: string): string => `page:${pageId}`; export const channelForWorkspace = (workspaceId: string): string => `workspace:${workspaceId}`;