UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

312 lines (281 loc) 10.5 kB
/** * WebSocket adapter — replaces the legacy socket.io client. * * This is the transport half of AGENTS.md §8 Phase 10: every `broadcast()` * call becomes one JSON frame over a native WS. `onMessage` delivers the * decoded `ServerMessage`s (frames that fail `parseServerMessage` are * dropped — matches the legacy behavior of ignoring unknown events). * * Reconnect policy (ported from `src/player.ls:46`): * - delay between attempts: 500 ms * - max attempts: 1800 (≈15 min of retrying, matches legacy socket.io config) * * Frames published while the socket is not in `OPEN` are queued and flushed * on the next `open`. Frames submitted while permanently closed are dropped * (matches `return unless SocialCalc.isConnected` in the legacy callback). */ import { encodeMessage, parseServerMessage, type ClientMessage, type ServerMessage, } from '@ethercalc/shared/messages'; // ─── WebSocket surface we rely on ───────────────────────────────────────── /** Minimal WS shape we depend on. Both `ws` in Node and browser WS satisfy this. */ export interface WsLike { readyState: number; send(data: string): void; close(code?: number, reason?: string): void; addEventListener( type: 'open' | 'close' | 'error' | 'message', listener: (ev: { data?: string | undefined; code?: number | undefined; reason?: string | undefined }) => void, ): void; removeEventListener?: ( type: 'open' | 'close' | 'error' | 'message', listener: (ev: { data?: string | undefined; code?: number | undefined; reason?: string | undefined }) => void, ) => void; } export type WsFactory = (url: string) => WsLike; // ─── Public options / return shape ──────────────────────────────────────── export interface CreateWsAdapterOptions { /** Spreadsheet id. */ room: string; /** Presence identifier (randomly generated per tab in the legacy client). */ user: string; /** Optional HMAC auth token. */ auth?: string | undefined; /** Full WS URL or HTTP(S) endpoint — we flip the scheme automatically. */ url: string; /** Milliseconds between reconnect attempts. Legacy default: 500. */ reconnectDelayMs?: number | undefined; /** Max reconnect attempts before we give up. Legacy default: 1800. */ maxReconnectAttempts?: number | undefined; /** * Overridable socket constructor — lets tests inject a mock. When omitted * we use `globalThis.WebSocket` (present in browsers + modern Node). */ wsFactory?: WsFactory | undefined; /** * Overridable timer for reconnect backoff. Defaults to `setTimeout` on the * global. Tests pass a fake timer so we can assert timing without sleeping. */ setTimeoutFn?: ((fn: () => void, ms: number) => number) | undefined; /** Matching `clearTimeout`. */ clearTimeoutFn?: ((id: number) => void) | undefined; /** Hook fired on open/close/error/reconnect. Tests observe this. */ onStatus?: ((status: WsStatus) => void) | undefined; } export type WsStatus = | { type: 'open' } | { type: 'close'; code?: number | undefined; reason?: string | undefined } | { type: 'error' } | { type: 'reconnecting'; attempt: number } | { type: 'reconnect_failed' }; export interface WsAdapter { /** * Public surface that `SocialCalc.Callbacks.broadcast` delegates to. * Signature intentionally mirrors the legacy one: `(type, data={})`. */ broadcast(type: string, data?: Record<string, unknown>): void; /** Subscribe to decoded server messages. Returns an unsubscribe fn. */ onMessage(cb: (msg: ServerMessage) => void): () => void; /** Stop reconnecting and close the socket for good. */ close(): void; /** For diagnostics/tests. */ readonly queueLength: number; readonly connected: boolean; readonly reconnectAttempts: number; } // ─── Implementation ─────────────────────────────────────────────────────── const WS_OPEN = 1; /** * Fallbacks for when the caller doesn't pass factories. * * Kept as getters so the module can be imported in Node test environments * that don't have `WebSocket` on the global — it's only required at call * time, after the test has injected its own factory. */ function defaultWsFactory(url: string): WsLike { const ctor = (globalThis as unknown as { WebSocket?: new (u: string) => WsLike }).WebSocket; if (!ctor) { throw new Error('ws-adapter: no global WebSocket; pass wsFactory explicitly'); } return new ctor(url); } function defaultSetTimeout(fn: () => void, ms: number): number { return globalThis.setTimeout(fn, ms) as unknown as number; } function defaultClearTimeout(id: number): void { globalThis.clearTimeout(id); } /** * Turn an `http(s)://…` URL into `ws(s)://…`. Anything else (including a * URL that's already ws/wss) is passed through untouched. */ export function toWsUrl(raw: string): string { if (raw.startsWith('http://')) return 'ws://' + raw.slice(7); if (raw.startsWith('https://')) return 'wss://' + raw.slice(8); return raw; } /** * Build `wss://<base>/_ws/:room?user=…&auth=…` from the options. * Exported so the main entry can display it for debugging. */ export function buildWsEndpoint(opts: { url: string; room: string; user: string; auth?: string | undefined; }): string { const base = toWsUrl(opts.url.replace(/\/+$/, '')); const params = new URLSearchParams(); params.set('user', opts.user); if (opts.auth !== undefined) params.set('auth', opts.auth); // `:room` is URL-encoded — matches the server-side `encodeURI` rule // (AGENTS.md §7 item 15). return `${base}/_ws/${encodeURIComponent(opts.room)}?${params.toString()}`; } /** * Envelope that the adapter injects before shipping a frame. Mirrors the * legacy `data.user`/`data.room`/`data.auth` fill-in at `src/player.ls:74`. */ function envelope( type: string, data: Record<string, unknown>, opts: Pick<CreateWsAdapterOptions, 'room' | 'user' | 'auth'>, ): Record<string, unknown> { const out: Record<string, unknown> = { ...data }; out.type = type; out.user = opts.user; if (out.room === undefined) out.room = opts.room; if (opts.auth !== undefined) out.auth = opts.auth; return out; } export function createWsAdapter(opts: CreateWsAdapterOptions): WsAdapter { const delay = opts.reconnectDelayMs ?? 500; const maxAttempts = opts.maxReconnectAttempts ?? 1800; const wsFactory = opts.wsFactory ?? defaultWsFactory; const setTimeoutFn = opts.setTimeoutFn ?? defaultSetTimeout; const clearTimeoutFn = opts.clearTimeoutFn ?? defaultClearTimeout; const endpoint = buildWsEndpoint(opts); const subscribers = new Set<(msg: ServerMessage) => void>(); const queue: string[] = []; let socket: WsLike | null = null; let closed = false; let reconnectAttempts = 0; let pendingTimer: number | null = null; function notifyStatus(status: WsStatus): void { opts.onStatus?.(status); } function connect(): void { let s: WsLike; try { s = wsFactory(endpoint); } catch { // Factory threw (network offline, test dummy) — schedule a retry just // like the socket.io client used to. scheduleReconnect(); return; } socket = s; s.addEventListener('open', onOpen); s.addEventListener('close', onClose); s.addEventListener('error', onError); s.addEventListener('message', onMessageFrame); } function onOpen(): void { reconnectAttempts = 0; notifyStatus({ type: 'open' }); flushQueue(); } function onClose(ev: { code?: number | undefined; reason?: string | undefined }): void { notifyStatus({ type: 'close', code: ev.code, reason: ev.reason }); socket = null; scheduleReconnect(); } function onError(): void { notifyStatus({ type: 'error' }); // `error` is normally followed by `close`; let `close` drive reconnect. } function onMessageFrame(ev: { data?: string | undefined }): void { if (typeof ev.data !== 'string') return; const parsed = parseServerMessage(ev.data); if (!parsed) return; for (const cb of subscribers) cb(parsed); } function scheduleReconnect(): void { if (closed) return; if (reconnectAttempts >= maxAttempts) { notifyStatus({ type: 'reconnect_failed' }); return; } reconnectAttempts++; notifyStatus({ type: 'reconnecting', attempt: reconnectAttempts }); pendingTimer = setTimeoutFn(() => { pendingTimer = null; connect(); }, delay); } function flushQueue(): void { // Only called from the `open` event handler, so we know `socket` is set // and in `OPEN` state. No defensive guard needed. while (queue.length > 0) { const frame = queue.shift()!; socket!.send(frame); } } function broadcast(type: string, data: Record<string, unknown> = {}): void { if (closed) return; const enveloped = envelope(type, data, opts); // Cast is safe — we accept arbitrary `type` strings at the broadcast // surface for parity (`ask.ecell` is emitted internally and isn't in the // `ClientMessage` union), but the JSON shape is a superset. const frame = encodeMessage(enveloped as unknown as ClientMessage); if (socket && socket.readyState === WS_OPEN) { socket.send(frame); } else { queue.push(frame); } } function onMessage(cb: (msg: ServerMessage) => void): () => void { subscribers.add(cb); return () => { subscribers.delete(cb); }; } function close(): void { closed = true; if (pendingTimer !== null) { clearTimeoutFn(pendingTimer); pendingTimer = null; } if (socket) { try { socket.close(); } catch { // Some mock sockets throw on double-close — ignore. } socket = null; } subscribers.clear(); queue.length = 0; } // Kick off the first connection synchronously so tests that await a // microtask can observe the socket creation. connect(); const adapter: WsAdapter = { broadcast, onMessage, close, get queueLength() { return queue.length; }, get connected() { return socket !== null && socket.readyState === WS_OPEN; }, get reconnectAttempts() { return reconnectAttempts; }, }; return adapter; }