Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 | 11x 11x 6x 6x 7x 6x 7x 21x 19x 18x 7x 7x 5x 5x 5x 7x 7x 11x 11x 9x 9x 6x 6x 10x 7x 7x 6x 4x 3x 52x 8x 8x 4x 4x 4x 4x 12x 12x 7x 7x 4x 4x 7x 7x 3x 3x 2x 2x 1x 1x | /**
* 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:
* - CLAUDE.md §5.2 — coverage gate layout
* - CLAUDE.md §6.2 — WS wire protocol
* - CLAUDE.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 (CLAUDE.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 (CLAUDE.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 full restoration payload
* (snapshot + ordered log + chat). The legacy client replays log on
* top of snapshot to reconstruct the UI 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(),
]);
await ctx.reply(buildLogReply(msg, log, chat, snapshot ?? ''));
}
/**
* `ask.recalc` — similar to ask.log but omits the chat log. Used by the
* client when it needs to resync just the spreadsheet state.
*/
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: 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;
}
}
}
|