ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
326 lines (307 loc) • 11.8 kB
text/typescript
/**
* 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;
}
}
}