UNPKG

ethercalc

Version:

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

190 lines (176 loc) 6.85 kB
/** * Pure message-dispatch helpers for the native WebSocket layer. * * The `RoomDO.webSocketMessage` handler needs to make several small, pure * decisions on every frame: is this a filterable no-op? Is it the legacy * `submitform` special case? What snapshot / auth / chat structure does * the reply take? Factoring these into one module keeps `room.ts` focused * on storage I/O and lets the Node test runner enforce 100% branch coverage * without needing a workerd runtime. * * References: * - AGENTS.md sec 6.2 (execute / submitform / ask.log flows) * - AGENTS.md sec 7 item 12 (text-wiki filter drop) * - AGENTS.md sec 7 item 22 (submitform include_self=true invariant) */ import type { AskEcellClientMessage, AskEcellServerMessage, AskLogMessage, ChatClientMessage, ChatServerMessage, ClientMessage, EcellClientMessage, EcellServerMessage, EcellsServerMessage, ExecuteClientMessage, ExecuteServerMessage, LogServerMessage, MyEcellMessage, MyEcellServerMessage, StopHuddleMessage, StopHuddleServerMessage, } from '@ethercalc/shared/messages'; /** Legacy filter — server drops this command even when auth checks out. */ const TEXT_WIKI_FILTER = 'set sheet defaulttextvalueformat text-wiki'; /** * Returns true when an `execute` command string should be silently dropped * without logging or broadcasting. Matches the legacy src/main.ls:502 * guard: the command is a UI affordance on legacy clients that our * SocialCalc port doesn't need — accepting it would dirty the log. */ export function isFilteredExecuteCommand(cmdstr: string): boolean { return cmdstr === TEXT_WIKI_FILTER; } /** * Test whether an `execute` payload is the `submitform` special case. * * Legacy (src/main.ls:518-541): the first line of `cmdstr` — split on a * carriage return and trimmed — equals the literal string `submitform`. * The remaining lines carry the row data that must be appended to a * sibling `<room>_formdata` DO. */ export function isSubmitForm(cmdstr: string): boolean { if (typeof cmdstr !== 'string' || cmdstr.length === 0) return false; // `split('\r')[0]` on a non-empty string always returns a string. const firstLine = cmdstr.split('\r')[0] as string; return firstLine.trim() === 'submitform'; } /** * Given a `submitform` `execute` command and the room it was sent against, * compute the sibling form-data room and return the follow-up commands to * append there. * * Legacy behavior (src/main.ls:522-531): * 1. Strip the `submitform` header from the cmd string. * 2. If the current room already ends with `_formdata`, use the same * room (idempotent on sub-submits from a form editor that itself * lives in the sibling). Otherwise append `_formdata`. * 3. The remaining body lines become the commands to replay on the * sibling — NOT on the source room. The source room sees only the * header execute broadcast back to all peers with `include_self: * true`. */ export function computeSubmitFormTarget( room: string, cmdstr: string, ): { siblingRoom: string; siblingCommands: string } { const siblingRoom = room.endsWith('_formdata') ? room : `${room}_formdata`; // Drop everything up to and including the first CR. If the command is // just the bare word `submitform` with no trailing payload, the sibling // commands payload is the empty string. const headerEnd = cmdstr.indexOf('\r'); const siblingCommands = headerEnd === -1 ? '' : cmdstr.slice(headerEnd + 1); return { siblingRoom, siblingCommands }; } /** * Shape a `chat` client message into its broadcast server form. The * server-emitted `chat` message carries exactly the fields the legacy * client consumed on the receiving end (`room`, `user`, `msg`). */ export function buildChatBroadcast(msg: ChatClientMessage): ChatServerMessage { return { type: 'chat', room: msg.room, user: msg.user, msg: msg.msg }; } /** * Shape an execute-broadcast message. `include_self` is only set when the * caller explicitly requests it — legacy treats the missing field and * `false` equivalently, but we preserve an explicit `true` because the * sec 7 item 22 submitform invariant is tested on presence + truthiness. */ export function buildExecuteBroadcast( msg: ExecuteClientMessage, includeSelf: boolean, ): ExecuteServerMessage { const base: ExecuteServerMessage = { type: 'execute', room: msg.room, user: msg.user, cmdstr: msg.cmdstr, }; if (msg.auth !== undefined) base.auth = msg.auth; if (includeSelf) base.include_self = true; return base; } /** Shape an ecells-reply broadcast. */ export function buildEcellsReply( room: string, ecells: Record<string, string>, ): EcellsServerMessage { return { type: 'ecells', room, ecells }; } /** Shape a my.ecell broadcast. */ export function buildMyEcellBroadcast(msg: MyEcellMessage): MyEcellServerMessage { return { type: 'my.ecell', room: msg.room, user: msg.user, ecell: msg.ecell }; } /** * Shape an ask.ecell rebroadcast. Forwards the asker's `user` to peers so * they can target their `ecell` reply via `to: user`. The legacy catch-all * did the same thing with `broadcast @data` — we carry the payload * explicitly because our parser is closed-union. */ export function buildAskEcellBroadcast( msg: AskEcellClientMessage, ): AskEcellServerMessage { return { type: 'ask.ecell', room: msg.room, user: msg.user }; } /** Shape an ecell broadcast. */ export function buildEcellBroadcast(msg: EcellClientMessage): EcellServerMessage { const out: EcellServerMessage = { type: 'ecell', room: msg.room, user: msg.user, ecell: msg.ecell, }; if (msg.original !== undefined) out.original = msg.original; if (msg.auth !== undefined) out.auth = msg.auth; // `to` targets a single peer (ask.ecell reply path). Must survive the // round-trip so the client-side `data.to !== _username` filter works. if (msg.to !== undefined) out.to = msg.to; return out; } /** Shape a stopHuddle broadcast. */ export function buildStopHuddleBroadcast( msg: StopHuddleMessage, ): StopHuddleServerMessage { const out: StopHuddleServerMessage = { type: 'stopHuddle', room: msg.room }; if (msg.auth !== undefined) out.auth = msg.auth; return out; } /** Shape the reply to ask.log. */ export function buildLogReply( msg: AskLogMessage, log: readonly string[], chat: readonly string[], snapshot: string, ): LogServerMessage { return { type: 'log', room: msg.room, log, chat, snapshot }; } /** * Discriminated-union guard over ClientMessage. Keeps the switch * exhaustive in room.ts via TS's `never` branch narrowing; this helper * exists so the Node test runner can assert we enumerate every legal * type. */ export function clientMessageType(msg: ClientMessage): ClientMessage['type'] { return msg.type; }