UNPKG

ethercalc

Version:

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

326 lines (307 loc) 11.8 kB
/** * Pure WebSocket message handlers. * * The `RoomDO.webSocketMessage` hook receives a `ClientMessage` and must * decide which storage operations to run, which peers to broadcast to, and * which reply frames to emit. That logic was originally inlined in * `src/room.ts` as a per-type switch with direct `this.#state.storage` and * `this.#getSpreadsheet()` access, which meant every WS-dispatch branch * was only reachable through the workers-pool integration tests and so * `src/room.ts` had to be excluded from the Node coverage gate (see * `vitest.node.config.ts` comment). * * Phase 7.1 extract: every handler is now a pure async function that takes * a `WsContext` — an interface covering exactly the I/O and callback * surface each handler needs — plus the already-parsed `ClientMessage`. * `RoomDO.#handleWsMessage` becomes a thin adapter that builds the * context per frame and delegates. Tests can mock the entire surface * trivially, which unlocks 100% branch coverage in the Node suite. * * Cross-references: * - AGENTS.md §5.2 — coverage gate layout * - AGENTS.md §6.2 — WS wire protocol * - AGENTS.md §6.4 — auth gate (rejected writes silently drop) * - `src/lib/ws-dispatch.ts` — the pure builder helpers reused here */ import type { ClientMessage, ExecuteClientMessage, ServerMessage, } from '@ethercalc/shared/messages'; import { buildAskEcellBroadcast, buildChatBroadcast, buildEcellBroadcast, buildEcellsReply, buildExecuteBroadcast, buildLogReply, buildMyEcellBroadcast, buildStopHuddleBroadcast, computeSubmitFormTarget, isFilteredExecuteCommand, isSubmitForm, } from './ws-dispatch.ts'; /** * Storage surface for the handlers. Only the primitives the WS layer * actually uses — snapshot reads/writes, log/chat/audit/ecell list+put, * and the big "wipe everything" hammer that `stopHuddle` triggers. */ export interface WsStorage { /** List all values under `prefix`, in lexicographic key order. */ listPrefix(prefix: string): Promise<string[]>; /** List all entries under `prefix` as a map (prefix stripped). */ listHash(prefix: string): Promise<Record<string, string>>; /** Upsert a single key under `prefix` (prefix NOT stripped). */ putHash(prefix: string, key: string, value: string): Promise<void>; /** Append a value under `prefix` with an auto-incrementing seq. */ appendLog(prefix: string, value: string): Promise<void>; /** Snapshot body, or undefined if no snapshot exists yet. */ getSnapshot(): Promise<string | undefined>; /** Wipe the entire room (snapshot + log + audit + chat + ecell). */ deleteAll(): Promise<void>; } /** * Sibling-DO entry point. `submitform` forwards a mutation to the * `<room>_formdata` peer; tests inject a fake fetcher. */ export interface WsSiblingDO { fetch(path: string, init?: RequestInit): Promise<Response>; } /** * Everything the handlers need. Room.ts assembles this once per frame * (cheap — just function references bound to the accepted WebSocket). An * explicit surface keeps the handler layer pure: no DO primitives leak in. */ export interface WsContext { readonly room: string; readonly user: string; readonly auth: string; readonly storage: WsStorage; /** * Append a command batch to the storage log + audit, run it through * SocialCalc, and rewrite the snapshot. The caller serializes this * behind `state.blockConcurrencyWhile`; the handler layer stays * transport-agnostic. */ readonly applyCommand: (cmdstr: string) => Promise<void>; /** * Broadcast a message to every other peer in the room. If * `includeSelf` is true, the sender also receives the frame — this is * the `submitform` invariant (AGENTS.md §7 item 22). */ readonly broadcast: (msg: ServerMessage, includeSelf: boolean) => Promise<void>; /** Send a message only to the originating socket. */ readonly reply: (msg: ServerMessage) => Promise<void>; /** * True iff the supplied auth matches the configured HMAC. When no * `ETHERCALC_KEY` is set, falls back to identity compare. Callers pass * `ctx.auth` (cached at handshake) to avoid a per-frame hash. */ readonly verifyAuth: () => Promise<boolean>; /** Resolve a sibling DO stub by room name (submitform forwarding). */ readonly siblingDo: (room: string) => WsSiblingDO; } // ─── Handlers ─────────────────────────────────────────────────────────────── // // Every handler is a pure function of `(ctx, msg)` → `Promise<void>`. The // helpers here do NOT throw: storage errors and socket-send errors are // swallowed by the context implementations (matching legacy best-effort // semantics — a dead peer never fails the whole fan-out). /** * `chat` — append the message to storage and fan out to peers. Legacy * (`src/main.ls:505-509`) broadcast to everyone *except* the sender, * relying on the client to echo its own message locally. */ export async function handleChat( ctx: WsContext, msg: Extract<ClientMessage, { type: 'chat' }>, ): Promise<void> { await ctx.storage.appendLog('chat:', msg.msg); await ctx.broadcast(buildChatBroadcast(msg), false); } /** * `ask.ecells` — reply only to the requester with the full ecell map. * Other peers do not observe this query (AGENTS.md §6.2). */ export async function handleAskEcells( ctx: WsContext, msg: Extract<ClientMessage, { type: 'ask.ecells' }>, ): Promise<void> { const ecells = await ctx.storage.listHash('ecell:'); await ctx.reply(buildEcellsReply(msg.room, ecells)); } /** * `my.ecell` — update the sender's cursor position and broadcast to * peers. Empty `user` is treated as "presence announcement" without * persistence (legacy accepted this shape from early clients). Everyone * else receives the broadcast regardless. */ export async function handleMyEcell( ctx: WsContext, msg: Extract<ClientMessage, { type: 'my.ecell' }>, ): Promise<void> { if (msg.user.length > 0) { await ctx.storage.putHash('ecell:', msg.user, msg.ecell); } await ctx.broadcast(buildMyEcellBroadcast(msg), false); } /** * `execute` — the heavy path. Three drop conditions (auth fail, * text-wiki filter, submitform without payload) short-circuit silently. * submitform forks to the sibling `<room>_formdata` DO with * include_self=true per legacy invariant (§7 item 22). Normal commands * go through `applyCommand` and broadcast with include_self=false. */ export async function handleExecute( ctx: WsContext, msg: ExecuteClientMessage, ): Promise<void> { if (!(await ctx.verifyAuth())) return; if (isFilteredExecuteCommand(msg.cmdstr)) return; if (isSubmitForm(msg.cmdstr)) { const { siblingRoom, siblingCommands } = computeSubmitFormTarget( msg.room, msg.cmdstr, ); if (siblingCommands.length > 0) { const stub = ctx.siblingDo(siblingRoom); try { await stub.fetch('https://do.local/_do/commands', { method: 'POST', body: siblingCommands, }); } catch { // Legacy src/main.ls:538 dropped sibling-send failures silently. } } await ctx.broadcast(buildExecuteBroadcast(msg, true), true); return; } await ctx.applyCommand(msg.cmdstr); await ctx.broadcast(buildExecuteBroadcast(msg, false), false); } /** * `ask.log` — reply to the sender with the restoration payload * (snapshot + ordered log + chat). The client resets to the snapshot then * replays the log on top, so the log MUST be empty when a snapshot is * present: RoomDO stores a post-command snapshot that already incorporates * every log entry (see `#getSpreadsheet`), and replaying it again would * DOUBLE-APPLY non-idempotent commands (insertrow/paste/sort) on every * initial load + reconnect. The log is sent only for a log-only room (no * snapshot), where the log is the sole state. */ export async function handleAskLog( ctx: WsContext, msg: Extract<ClientMessage, { type: 'ask.log' }>, ): Promise<void> { const [log, chat, snapshot] = await Promise.all([ ctx.storage.listPrefix('log:'), ctx.storage.listPrefix('chat:'), ctx.storage.getSnapshot(), ]); const replayLog = snapshot ? [] : log; await ctx.reply(buildLogReply(msg, replayLog, chat, snapshot ?? '')); } /** * `ask.recalc` — like ask.log but omits the chat log. Used when the client * needs to resync just the spreadsheet state. Same authoritative-snapshot * rule as `handleAskLog`: empty log when a snapshot is present so the * client doesn't replay commands the snapshot already contains. */ export async function handleAskRecalc( ctx: WsContext, msg: Extract<ClientMessage, { type: 'ask.recalc' }>, ): Promise<void> { const [log, snapshot] = await Promise.all([ ctx.storage.listPrefix('log:'), ctx.storage.getSnapshot(), ]); await ctx.reply({ type: 'recalc', room: msg.room, log: snapshot ? [] : log, snapshot: snapshot ?? '', }); } /** * `stopHuddle` — auth-gated room reset. Wipes every storage key and * broadcasts a `stopHuddle` back so peers drop their local state. */ export async function handleStopHuddle( ctx: WsContext, msg: Extract<ClientMessage, { type: 'stopHuddle' }>, ): Promise<void> { if (!(await ctx.verifyAuth())) return; await ctx.storage.deleteAll(); await ctx.broadcast(buildStopHuddleBroadcast(msg), false); } /** * `ecell` — auth-gated cursor broadcast used for follow-mode. No * persistence; we trust `my.ecell` to own the stored-cursor state. */ export async function handleEcell( ctx: WsContext, msg: Extract<ClientMessage, { type: 'ecell' }>, ): Promise<void> { if (!(await ctx.verifyAuth())) return; await ctx.broadcast(buildEcellBroadcast(msg), false); } /** * `ask.ecell` — cursor-position poll. The asker wants every peer to reply * with their current `editor.ecell` (client-side handler does the reply — * see the dispatcher in `packages/client/src/main.ts`). Server-side we * just rebroadcast to peers; no storage, no auth. Matches the legacy * catch-all `@on data` broadcast at `src/main.ls` end-of-switch. */ export async function handleAskEcell( ctx: WsContext, msg: Extract<ClientMessage, { type: 'ask.ecell' }>, ): Promise<void> { await ctx.broadcast(buildAskEcellBroadcast(msg), false); } // ─── Top-level dispatcher ─────────────────────────────────────────────────── /** * Route a parsed `ClientMessage` to the matching handler. Exhaustive over * the union; TS's `never` branch narrowing enforces updates when a new * type is added. */ export async function dispatchWsMessage( ctx: WsContext, msg: ClientMessage, ): Promise<void> { switch (msg.type) { case 'chat': await handleChat(ctx, msg); return; case 'ask.ecells': await handleAskEcells(ctx, msg); return; case 'my.ecell': await handleMyEcell(ctx, msg); return; case 'execute': await handleExecute(ctx, msg); return; case 'ask.log': await handleAskLog(ctx, msg); return; case 'ask.recalc': await handleAskRecalc(ctx, msg); return; case 'stopHuddle': await handleStopHuddle(ctx, msg); return; case 'ecell': await handleEcell(ctx, msg); return; case 'ask.ecell': await handleAskEcell(ctx, msg); return; default: { // Exhaustiveness sentinel. If a new ClientMessage variant is added // without a handler, TypeScript fails here at compile time. const _exhaustive: never = msg; void _exhaustive; } } }