UNPKG

ethercalc

Version:

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

1,234 lines (1,169 loc) 76.8 kB
/** * RoomDO — one Durable Object per spreadsheet room. * * Phase 5 deliverable: snapshot + log + audit + chat + ecell storage, backed * by SocialCalc via `@ethercalc/socialcalc-headless`. Exposes an internal * HTTP API on `/_do/*` that the Worker's room-level routes dispatch to. * * Key patterns come from `@ethercalc/shared/storage-keys`: * - `snapshot` → string (SocialCalc save) * - `meta:updated_at` → number (Date.now()) * - `log:<seq>` → string (one command batch per entry) * - `audit:<seq>` → string (never truncated) * - `chat:<seq>` → string (room chat message) * - `ecell:<user>` → string (cell coordinate) * * Sequence counters (`nextLogSeq`, `nextAuditSeq`, `nextChatSeq`) are lazily * initialized from `storage.list({prefix})` on first write — cheap because * DO storage keeps the SQLite index in memory for warm instances. * * The in-memory SocialCalc `HeadlessSpreadsheet` is hydrated lazily from the * stored snapshot (if any) on first use and cached for the lifetime of the * isolate. Mutations that would drift cache vs storage are wrapped in * `state.blockConcurrencyWhile` to keep the DO serialized. */ import type { ServerMessage } from '@ethercalc/shared/messages'; import { encodeMessage, parseClientMessage } from '@ethercalc/shared/messages'; import { decodeFrame, nativeToSocketIoEvent, PacketType, socketIoEventToNative, } from '@ethercalc/socketio-shim'; import { auditKey, chatKey, ecellKey, logKey, STORAGE_KEYS, snapshotChunkKey, } from '@ethercalc/shared/storage-keys'; import { createSpreadsheet, HeadlessSpreadsheet, } from '@ethercalc/socialcalc-headless'; import type { Env } from './env.ts'; import { buildEmailSender } from './handlers/cron.ts'; import { parseSeedPayload } from './handlers/migrate.ts'; import { verifyAuth } from './lib/auth.ts'; import { hydrateCrossSheetRefs } from './lib/cross-sheet.ts'; import { neutralizeCSVDocument } from './lib/csv-encode.ts'; import { parseCSV } from './lib/csv-parse.ts'; import { parseSendemail } from './lib/email.ts'; import { formdataSiblingRoom } from './lib/formdata-sibling.ts'; import { csvToMarkdown } from './lib/md.ts'; import { bookmarkStorage, isPitrUnavailableError, parsePitrRequest, } from './lib/pitr.ts'; import { encodeRoom } from './lib/room-name.ts'; import { deleteRoomFromD1, mirrorRoomToD1, } from './lib/rooms-index.ts'; import { isSandstormEnforced, sandstormAllowsWsWrite, sandstormCanModify, } from './lib/sandstorm-access.ts'; import { appendAuditRows, appendChatRows, deleteAuditRows, deleteChatRows, type SeqRow, } from './lib/seq-store.ts'; import { hasSnapshot, readSnapshot, readSnapshotMeta, type SnapshotMeta, snapshotEntries, } from './lib/snapshot-storage.ts'; import { isFilteredExecuteCommand } from './lib/ws-dispatch.ts'; import { dispatchWsMessage, type WsContext, type WsSiblingDO, type WsStorage, } from './lib/ws-handlers.ts'; import { upgradeLegacySocketIo, upgradeWebSocket, type WsAttachment, } from './lib/ws-upgrade.ts'; import { BINARY_CONTENT_TYPES, type BinaryFormat, sheetViewToBinaryWorkbook, } from './lib/xlsx-build.ts'; /** Shape returned from `GET /_do/log`. */ export interface RoomLogSnapshot { readonly log: readonly string[]; readonly chat: readonly string[]; } /** Content type used for plain-text bodies returned from the DO. */ const PLAIN_TEXT = 'text/plain; charset=utf-8'; const APP_JSON = 'application/json'; const TEXT_CSV = 'text/csv; charset=utf-8'; const TEXT_HTML = 'text/html; charset=utf-8'; const TEXT_MARKDOWN = 'text/x-markdown; charset=utf-8'; /** * Ring-buffer length for the command log. The stored snapshot is * authoritative on hydrate (see `#getSpreadsheet`), so `log:` is now a * pure client-catch-up buffer: `ask.log` returns the recent tail alongside * the snapshot, and any client that has fallen further behind than this * window resets to the snapshot instead of replaying. `#appendCommand` * deletes `log:<seq - LOG_RING>` as it writes `log:<seq>`, so the live * log never holds more than `LOG_RING` entries. `audit:` is NEVER trimmed * here (it is the append-only record). */ const LOG_RING = 1024; /** * Cap on distinct `ecell:<user>` keys retained per room. ecells are keyed * by an arbitrary client-supplied username, so without a bound an attacker * could blow per-room storage by cycling usernames. We evict the * least-recently-written entry once the cap is exceeded (`#trackEcell`). */ const ECELL_CAP = 256; /** * Maximum concurrent WebSocket connections accepted per room. Past this we * reject the upgrade — a coarse DoS backstop complementing the CF platform * layer (AGENTS.md §13 Q7 keeps real rate limiting at the edge). */ const MAX_CONN = 128; /** * Maximum accepted size (in UTF-16 code units) of a single WS frame. * Generous enough for a large collaborative paste (a `loadclipboard` / * `execute` frame carries the whole clipboard save) while still capping a * single client from forcing a multi-MB `JSON.parse` + storage write. * Pastes larger than this go through the HTTP write path, which has its own * 25 MiB cap (`MAX_WRITE_BYTES` in `src/index.ts`). */ const MAX_FRAME = 1024 * 1024; /** * Number of `chat:` entries the alarm handler keeps in DO storage when it * trims. Chat is mirrored to D1 (`chat_log`) at append time (§13 Q9), so the * dropped oldest entries stay durable there — the DO copy only needs to * cover live catch-up (`ask.log` returns this recent tail). */ const CHAT_KEEP = 500; /** * Number of `audit:` entries the alarm keeps in DO storage when it trims. * The full audit record is mirrored to D1 (`audit_log`) at command time, so * the DO copy is only a recent tail. `audit:` is no longer "never truncated" * in the DO — the durable, queryable record lives in D1. */ const AUDIT_KEEP = 1024; /** * Cadence (ms) at which the housekeeping alarm re-fires while a room stays * active. One hour keeps chat-trim/TTL checks cheap without a tight loop. */ const ALARM_INTERVAL_MS = 60 * 60 * 1000; /** * Give the accepted restore response time to cross the DO boundary before * aborting the instance that produced it. The exact delay is not semantic. */ // Stryker disable next-line all : any short positive delay has the same contract const PITR_ABORT_DELAY_MS = 100; function plainResponse(body: string, status = 200): Response { return new Response(body, { status, headers: { 'Content-Type': PLAIN_TEXT }, }); } function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': APP_JSON }, }); } function notFound(): Response { return plainResponse('', 404); } function textResponse(body: string, contentType: string, status = 200): Response { return new Response(body, { status, headers: { 'Content-Type': contentType } }); } function binaryResponse(bytes: Uint8Array, contentType: string, status = 200): Response { // workerd accepts a `Uint8Array` as a `BodyInit` at runtime; the cast // satisfies the stricter `Uint8Array<ArrayBufferLike>` lib typing that // doesn't structurally match `BodyInit` (the DO → worker hop streams it). return new Response(bytes as unknown as BodyInit, { status, headers: { 'Content-Type': contentType }, }); } /** * Fold a base snapshot + since-base command log into a single * authoritative SocialCalc save. Used on ingest by `#postSeed` and * `#postInstall` so the stored snapshot already incorporates every log * command (the hydrate path no longer replays the log over a present * snapshot — see `#getSpreadsheet`). `createSpreadsheet({snapshot, log})` * applies each log line exactly once on top of the base, then we * serialise the result. */ function foldSnapshot(snapshot: string, log: readonly string[]): string { const ss = createSpreadsheet( snapshot ? { snapshot, log } : { log }, ); return ss.createSpreadsheetSave(); } /** * Parse the legacy `--expire` / `ETHERCALC_EXPIRE` value (a TTL in * SECONDS, matching the old Redis `EXPIRE` semantics) into milliseconds. * Returns `null` when unset, non-numeric, or non-positive — in those * cases the alarm handler skips TTL expiry entirely (rooms live forever, * the production default). */ function parseExpireMs(raw: string | undefined): number | null { if (raw === undefined) return null; const seconds = Number(raw); if (!Number.isFinite(seconds) || seconds <= 0) return null; return seconds * 1000; } export class RoomDO implements DurableObject { readonly #state: DurableObjectState; readonly #env: Env; readonly #instanceNonce = crypto.randomUUID(); #ss: HeadlessSpreadsheet | null = null; #nextLogSeq: number | null = null; #nextAuditSeq: number | null = null; #nextChatSeq: number | null = null; /** * In-memory LRU order of `ecell:<user>` keys (least-recently-written * first). Lazily seeded from storage on first ecell write so the cap is * enforced even after an isolate restart. Bounds distinct ecell keys to * `ECELL_CAP` so a client cycling arbitrary usernames can't grow storage * without limit. */ #ecellOrder: string[] | null = null; /** Whether the housekeeping alarm is known to be armed (cheap dedupe). */ #alarmArmed = false; /** * Cached room name — set from `?name=…` on each request and retained * for cross-sheet formula resolution (so sibling DO lookups can skip * self-references without forcing every caller to thread the name * through `#getSpreadsheet`). */ #ownName: string | undefined; constructor(state: DurableObjectState, env: Env) { this.#state = state; this.#env = env; // socket.io v0.9 heartbeat is a pure echo of `2::`. Auto-response lets // hibernated legacy sockets answer pings without waking the isolate // (and without a JS timer that would pin the DO awake). /* istanbul ignore next -- @preserve: WebSocketRequestResponsePair is a workerd global */ if (typeof WebSocketRequestResponsePair === 'function') { this.#state.setWebSocketAutoResponse( new WebSocketRequestResponsePair('2::', '2::'), ); } } async fetch(request: Request): Promise<Response> { const url = new URL(request.url); const path = url.pathname; // Every `doFetch` caller now threads the room name through as // `?name=…` so the DO can mirror to D1 without re-deriving it from // its opaque id. `/_do/ping` already used the same param. const roomName = url.searchParams.get('name'); if (roomName) this.#ownName = roomName; if (path === '/_do/ping') { return jsonResponse({ id: this.#state.id.toString(), name: roomName, nonce: this.#instanceNonce, }); } if (path === '/_do/pitr-restore' && request.method === 'POST') { return this.#postPitrRestore(request); } if (path === '/_do/pitr-touch' && request.method === 'POST') { return this.#postPitrTouch(roomName); } if (path === '/_do/snapshot') { if (request.method === 'GET') return this.#getSnapshot(); if (request.method === 'PUT') return this.#putSnapshot(request, roomName); } if (path === '/_do/log' && request.method === 'GET') { return this.#getLog(); } if (path === '/_do/commands' && request.method === 'POST') { return this.#postCommands(request, roomName); } if (path === '/_do/all' && request.method === 'DELETE') { return this.#deleteAll(roomName); } if (path === '/_do/exists' && request.method === 'GET') { return this.#getExists(); } if (path === '/_do/cells' && request.method === 'GET') { return this.#getCells(); } const cellMatch = path.match(/^\/_do\/cells\/(.+)$/); if (cellMatch && request.method === 'GET') { return this.#getCell(decodeURIComponent(cellMatch[1]!)); } // ─── Phase 8: export routes ──────────────────────────────────────── if (path === '/_do/html' && request.method === 'GET') { return this.#getHtml(); } if (path === '/_do/csv' && request.method === 'GET') { return this.#getCsv(); } if (path === '/_do/csv.json' && request.method === 'GET') { return this.#getCsvJson(); } if (path === '/_do/md' && request.method === 'GET') { return this.#getMd(); } if (path === '/_do/xlsx' && request.method === 'GET') { return this.#getBinary('xlsx'); } if (path === '/_do/ods' && request.method === 'GET') { return this.#getBinary('ods'); } if (path === '/_do/fods' && request.method === 'GET') { return this.#getBinary('fods'); } // ─── Phase 8.1: sheet-data for multi-sheet export ──────────────── // Returns the structural SheetData (cells + valueformats + cellformats // + attribs) as JSON, for the top-level `/_/=:room/*` route to walk // cross-DO and build a multi-sheet workbook with formula fidelity. if (path === '/_do/sheet-data' && request.method === 'GET') { return this.#getSheetData(); } // ─── Phase 6: cross-DO rename primitives ───────────────────────── // `set A\d+:B\d+ empty multi-cascade` in the HTTP command layer // moves snapshot/log/audit from <from> into <to> and wipes <from>. // `rename` runs on the source DO; `install` is the target-side // receiver. Both additive; no existing path shape changes. if (path === '/_do/rename' && request.method === 'POST') { return this.#postRename(request); } if (path === '/_do/install' && request.method === 'POST') { return this.#postInstall(request); } if (path === '/_do/clone' && request.method === 'POST') { return this.#postClone(request); } // ─── Phase 11b: full-fidelity migration seed ───────────────────── // `POST /_do/seed` is the migration entry point — it replaces the // entire room (snapshot + log + audit + chat + ecell + meta // timestamp) in one shot, then mirrors the D1 `rooms` row. The // worker-level `PUT /_migrate/seed/:room` route authenticates the // caller before dispatching here; direct DO access from inside the // namespace (tests, future tooling) can bypass that. if (path === '/_do/seed' && request.method === 'POST') { return this.#postSeed(request, roomName); } // ─── Phase 11b: client-side chunked snapshot upload ────────────── // Companion to `/_do/seed` for rooms whose raw SocialCalc save // exceeds CF's ~25 MB per-request body limit. The migrator first // POSTs `/_do/seed` with an empty `snapshot` (which deleteAll's // the DO and installs log/audit/chat/ecell), then streams N chunk // bodies here with `seq=<i>&chunks=<N>`. The final chunk flips // `snapshot:meta` over to the new layout; readers see either the // pre-migration state or the freshly-assembled save, never a // mix. See the `#postSnapshotChunk` doc for the full contract. if (path === '/_do/snapshot-chunk' && request.method === 'POST') { return this.#postSnapshotChunk(request, roomName, url.searchParams); } // ─── Phase 7: native WebSocket upgrade ─────────────────────────── if (path === '/_do/ws' && request.method === 'GET') { return this.#acceptWebSocket(request); } // Legacy socket.io v0.9 WS — hibernation API with `legacy: true` // attachment so webSocketMessage/send use socket.io framing. Worker // routes `/socket.io/1/websocket/:sid` here on a sid-keyed RoomDO. if (path === '/_do/legacy-ws' && request.method === 'GET') { return this.#acceptLegacyWebSocket(request); } // ─── Phase 9: cron fire-trigger hook ─────────────────────────────── // `POST /_do/fire-trigger?cell=<coord>` — called from the // `scheduled()` handler (and from the backwards-compat // /_timetrigger HTTP endpoint) for each due row. Reads the // referenced cell's text, parses it as `sendemail <to> <subject> // <body>`, dispatches through the injected EmailSender, and // broadcasts the legacy `confirmemailsent` WS event to this // room's peers. if (path === '/_do/fire-trigger' && request.method === 'POST') { return this.#fireTrigger(url.searchParams.get('cell') ?? ''); } return new Response('Not implemented', { status: 501 }); } // ─── Handlers ────────────────────────────────────────────────────────── /** * Resolve or schedule a SQLite DO PITR bookmark. A successful restore is * applied only after this instance restarts, so return the target + undo * bookmark first and abort on a short timer. */ async #postPitrRestore(request: Request): Promise<Response> { let body: unknown; try { body = await request.json(); } catch { return plainResponse('body must be valid JSON', 400); } const parsed = parsePitrRequest(body); if (!parsed.ok) return plainResponse(parsed.error, 400); const storage = bookmarkStorage(this.#state.storage); if (!storage) { return plainResponse('PITR is unavailable on this deployment', 501); } let bookmark: string; try { if ('at' in parsed.value) { bookmark = await storage.getBookmarkForTime(parsed.value.at); } else { bookmark = parsed.value.bookmark; if (parsed.value.dryRun) await storage.getBookmarkForTime(Date.now()); } } catch (error) { if (isPitrUnavailableError(error)) { return plainResponse('PITR is unavailable on this deployment', 501); } return plainResponse('PITR target is unavailable', 400); } if (parsed.value.dryRun) { return jsonResponse({ dryRun: true, bookmark }); } let undoBookmark: string; try { undoBookmark = await storage.onNextSessionRestoreBookmark(bookmark); } catch (error) { if (isPitrUnavailableError(error)) { return plainResponse('PITR is unavailable on this deployment', 501); } return plainResponse('PITR target is unavailable', 400); } const { promise, resolve } = Promise.withResolvers<void>(); this.#state.waitUntil(promise); setTimeout(() => { resolve(); this.#state.abort('PITR restore scheduled'); }, PITR_ABORT_DELAY_MS); return jsonResponse({ bookmark, undoBookmark, nonce: this.#instanceNonce, }); } /** * Rebuild metadata that lives outside the restored timeline. This endpoint * is called only after the public route observes a replacement instance. */ async #postPitrTouch(roomName: string | null): Promise<Response> { if (!(await hasSnapshot(this.#state.storage))) { await this.#state.storage.deleteAlarm(); this.#alarmArmed = false; await this.#deleteIndex(roomName); return jsonResponse({ exists: false }); } const updatedAt = Date.now(); await this.#state.storage.put(STORAGE_KEYS.metaUpdatedAt, updatedAt); await this.#mirrorIndex(roomName, updatedAt); await this.#armAlarm(); return jsonResponse({ exists: true, updatedAt }); } async #getSnapshot(): Promise<Response> { // Fast path: single-key. Small snapshots stay materialized (they // fit under CF's 96 MB DO-response body ceiling with plenty of // headroom). const single = await this.#state.storage.get<string>(STORAGE_KEYS.snapshot); if (typeof single === 'string') return plainResponse(single); const meta = await this.#state.storage.get<SnapshotMeta>( STORAGE_KEYS.snapshotMeta, ); if (meta === undefined || meta === null) return notFound(); // Chunked path: stream the reassembled save. Materializing a 148 MB // string into a Response body hits workerd's DO-response-size limit // (empirically ~96 MB on the paid plan); a streamed body bypasses // that because the response is transferred frame-by-frame. const storage = this.#state.storage; const encoder = new TextEncoder(); const total = meta.chunks; let i = 0; const stream = new ReadableStream<Uint8Array>({ async pull(controller) { if (i >= total) { controller.close(); return; } const part = await storage.get<string>(snapshotChunkKey(i)); if (typeof part !== 'string') { controller.error(new Error(`snapshot chunk ${i} missing`)); return; } controller.enqueue(encoder.encode(part)); i += 1; }, }); return new Response(stream, { status: 200, headers: { 'Content-Type': PLAIN_TEXT }, }); } async #putSnapshot(request: Request, roomName: string | null): Promise<Response> { const body = await request.text(); let updatedAt = 0; await this.#state.blockConcurrencyWhile(async () => { await this.#state.storage.deleteAll(); updatedAt = Date.now(); // One batched put — chunked or single, always lands atomically. await this.#state.storage.put({ ...snapshotEntries(body), [STORAGE_KEYS.metaUpdatedAt]: updatedAt, }); this.#ss = null; this.#nextLogSeq = 0; this.#nextAuditSeq = 0; this.#nextChatSeq = 0; this.#resetVolatile(); }); await this.#mirrorIndex(roomName, updatedAt); // Arm the housekeeping alarm so a room created/replaced via PUT and // never subsequently edited still gets TTL expiry. `#putSnapshot` // deleteAll's (which clears any pending alarm) and `#resetVolatile`s, // so we must re-arm here — the command/chat/ecell write paths arm too. await this.#armAlarm(); return plainResponse('OK', 201); } async #getLog(): Promise<Response> { const [log, chat] = await Promise.all([ this.#listPrefix(STORAGE_KEYS.logPrefix), this.#listPrefix(STORAGE_KEYS.chatPrefix), ]); return jsonResponse({ log, chat }); } async #postCommands(request: Request, roomName: string | null): Promise<Response> { const body = await request.text(); if (!body) return plainResponse('', 202); await this.#applyCommandAndMirror(roomName, body); return plainResponse('', 202); } /** * Apply a command batch + mirror to D1. Shared between the HTTP path * (`POST /_do/commands`) and the WS path (`execute` frame). Centralizing * this ensures both paths update the `rooms` index; without mirroring on * the WS path, `/_rooms` and `/_roomtimes` go stale whenever a browser * client edits a fresh room (found during 2026-04-20 browser smoke). */ async #applyCommandAndMirror(roomName: string | null, cmdstr: string): Promise<void> { let auditSeq = 0; let updatedAt = 0; await this.#state.blockConcurrencyWhile(async () => { const applied = await this.#appendCommand(cmdstr); auditSeq = applied.auditSeq; updatedAt = applied.ts; }); await this.#mirrorIndex(roomName, updatedAt); // Offload the audit entry to D1 (the durable record) so the alarm's DO // audit-trim doesn't lose it. Best-effort, outside the lock. await this.#mirrorAudit(roomName, [{ seq: auditSeq, ts: updatedAt, body: cmdstr }]); } async #deleteAll(roomName: string | null): Promise<Response> { await this.#deleteAllAndUnindex(roomName); return plainResponse('OK', 201); } /** * Wipe the entire room + delete its D1 index row. Shared between the * HTTP path (`DELETE /_do/all`) and the WS path (`stopHuddle` frame). * Centralizing this ensures both paths drop the D1 row; without it, * `/_rooms` kept listing rooms that had been stopHuddle'd through the * WS (discovered during 2026-04-20 browser smoke). */ async #deleteAllAndUnindex(roomName: string | null): Promise<void> { await this.#state.blockConcurrencyWhile(async () => { await this.#state.storage.deleteAll(); this.#ss = null; this.#nextLogSeq = 0; this.#nextAuditSeq = 0; this.#nextChatSeq = 0; this.#resetVolatile(); }); await this.#deleteIndex(roomName); await this.#deleteAuditChatFromD1(roomName); await this.#deleteFormdataSibling(roomName); } /** * Best-effort wipe of the submitform sibling `<room>_formdata` DO when * the main room is deleted. Skips when `roomName` is already a form-data * sibling or missing (issue #442). */ async #deleteFormdataSibling(roomName: string | null): Promise<void> { if (!roomName) return; const sibling = formdataSiblingRoom(roomName); if (!sibling) return; try { const id = this.#env.ROOM.idFromName(encodeRoom(sibling)); const stub = this.#env.ROOM.get(id); await stub.fetch( `https://do.local/_do/all?name=${encodeURIComponent(sibling)}`, { method: 'DELETE' }, ); } catch { // Sibling may not exist; legacy delete was silent on orphans too. } } async #getExists(): Promise<Response> { return jsonResponse({ exists: (await hasSnapshot(this.#state.storage)) ? 1 : 0, }); } async #getCells(): Promise<Response> { const ss = await this.#getSpreadsheet(); // Legacy (src/sc.ls:361): `JSON.stringify(window.ss.sheet.cells)`. // Unwrapped — not `{cells: ...}`. External clients parse the map // directly as `response.A1.datavalue`, etc. return jsonResponse(ss.exportCells()); } async #getCell(coord: string): Promise<Response> { const ss = await this.#getSpreadsheet(); return jsonResponse(ss.exportCell(coord)); } // ─── Export handlers (Phase 8) ──────────────────────────────────────── // // Every export derives from the in-memory `HeadlessSpreadsheet` — no // caching beyond what the SocialCalc instance itself does. That keeps // each GET deterministic after any mutation (POST /_do/commands). async #getHtml(): Promise<Response> { const ss = await this.#getSpreadsheet(); return textResponse(ss.createSheetHTML(), TEXT_HTML); } async #getCsv(): Promise<Response> { const ss = await this.#getSpreadsheet(); // The `.csv` download is opened in desktop spreadsheet apps, so defang // formula/DDE injection (`=`, `+`, `-`, `@`) before emitting. csv.json // stays faithful — it's consumed as JSON, not evaluated as a formula. return textResponse(neutralizeCSVDocument(ss.exportCSV()), TEXT_CSV); } async #getCsvJson(): Promise<Response> { const ss = await this.#getSpreadsheet(); return jsonResponse(parseCSV(ss.exportCSV())); } async #getMd(): Promise<Response> { const ss = await this.#getSpreadsheet(); return textResponse(csvToMarkdown(ss.exportCSV()), TEXT_MARKDOWN); } async #getBinary(format: BinaryFormat): Promise<Response> { const ss = await this.#getSpreadsheet(); // Walk the raw SocialCalc sheet rather than going through CSV — that // preserves formulas, number formats, merges, and comments. See // `sheetViewToWorksheet` for the graceful-degrade-to-value rules. const bytes = sheetViewToBinaryWorkbook(ss.exportSheetData(), format); return binaryResponse(bytes, BINARY_CONTENT_TYPES[format]); } async #getSheetData(): Promise<Response> { const ss = await this.#getSpreadsheet(); return jsonResponse(ss.exportSheetData()); } // ─── Rename primitives (Phase 6) ───────────────────────────────────── // // Legacy `set A\d+:B\d+ empty multi-cascade` (src/main.ls:425-436) // renamed the Redis keys `snapshot-<from>` -> `snapshot-<from>.bak` // plus the `log-*` and `audit-*` siblings, then re-ran the command. // In the DO world each "room" IS its own DO, so the equivalent is a // cross-DO state transfer orchestrated by the source DO. // // Design: // POST /_do/rename body {to} -- runs on source, dumps own // snapshot/log/audit, fetches target's POST /_do/install with // those as JSON, then deleteAll's own storage. // POST /_do/install body {snapshot, log, audit} -- wipes own // storage and installs the payload verbatim. // // Chat and ecell are NOT carried over (legacy kept those under // different Redis prefixes so they stayed with the original room's // logical identity). async #postRename(request: Request): Promise<Response> { const parsed = (await request.json()) as { to?: unknown }; const to = parsed.to; if (typeof to !== 'string' || to.length === 0) { return new Response('rename body must be {to: string}', { status: 400 }); } const [snapshot, log, audit] = await Promise.all([ readSnapshot(this.#state.storage), this.#listPrefix(STORAGE_KEYS.logPrefix), this.#listPrefix(STORAGE_KEYS.auditPrefix), ]); if (snapshot === null) { // No-op: legacy `if snapshot` guard at main.ls:427 -- nothing to rename. return new Response(null, { status: 204 }); } const targetStub = this.#env.ROOM.get(this.#env.ROOM.idFromName(to)); const installRes = await targetStub.fetch('https://do.local/_do/install', { method: 'POST', body: JSON.stringify({ snapshot, log, audit }), headers: { 'Content-Type': 'application/json' }, }); if (!installRes.ok) { return new Response(`install failed: ${installRes.status}`, { status: 502 }); } await this.#state.blockConcurrencyWhile(async () => { await this.#state.storage.deleteAll(); this.#ss = null; this.#nextLogSeq = 0; this.#nextAuditSeq = 0; this.#nextChatSeq = 0; this.#resetVolatile(); }); return plainResponse('OK', 201); } /** * Snapshot-only DO-to-DO copy for `GET /:template/form` (legacy * main.ls:287-293). Leaves the source room intact. */ async #postClone(request: Request): Promise<Response> { const parsed = (await request.json()) as { to?: unknown }; const to = parsed.to; if (typeof to !== 'string' || to.length === 0) { return new Response('clone body must be {to: string}', { status: 400 }); } const snapshot = await readSnapshot(this.#state.storage); const targetId = this.#env.ROOM.idFromName(encodeRoom(to)); const targetStub = this.#env.ROOM.get(targetId); const putRes = await targetStub.fetch( `https://do.local/_do/snapshot?name=${encodeURIComponent(to)}`, { method: 'PUT', body: snapshot ?? '' }, ); if (!putRes.ok) { return new Response(`clone failed: ${putRes.status}`, { status: 502 }); } return plainResponse('OK', 201); } /** * Phase 11b — full-fidelity room seed. Accepts the complete payload * derived from a legacy Redis dump (see `@ethercalc/migrate`) and * installs it verbatim, replacing any existing state in this DO. * * Differences from `#postInstall` (rename path): * - carries chat + ecell + explicit updatedAt * - mirrors the D1 `rooms` row via `?name=<room>` * - snapshot field is optional; a log-only room (legacy rooms with * commands recorded but no snapshot yet folded) seeds with an * empty snapshot and no `snapshot` storage key. * * Idempotent — re-running against the same room overwrites. The * migrator calls this exactly once per room per run. */ async #postSeed(request: Request, roomName: string | null): Promise<Response> { let raw: unknown; try { raw = await request.json(); } catch { return new Response('seed body must be valid JSON', { status: 400 }); } const parsed = parseSeedPayload(raw, () => Date.now()); if (!parsed.ok) { return new Response(parsed.error, { status: 400 }); } const payload = parsed.value; // Fold base+log into one authoritative snapshot on ingest. Since // `#getSpreadsheet` no longer replays the log over a present // snapshot, a seeded room that carried a base snapshot + since-base // log would otherwise read back as just the base (log silently // dropped) — or, under the old double-apply hydrate, with every log // command applied twice. Folding here makes the stored snapshot the // single source of truth. // // Two shapes survive folding: // - base snapshot present (with or without log) → fold to a save. // - log-only room (no base snapshot) → fold the log onto an empty // sheet, producing a real snapshot. This collapses the legacy // "commands but never folded" rooms into a normal snapshot room. // - neither → empty room; keep the "no snapshot" shape. const hasState = payload.snapshot.length > 0 || payload.log.length > 0; const foldedSnapshot = hasState ? foldSnapshot(payload.snapshot, payload.log as string[]) : ''; const logTail = (payload.log as string[]).slice(-LOG_RING); await this.#state.blockConcurrencyWhile(async () => { await this.#state.storage.deleteAll(); // One batched `storage.put(entries)` call instead of N // sequential awaits. Each individual `put` is a subrequest // against the DO's SQLite, billed against the request's // 10-ms-CPU budget on the Workers free tier (and a seed for a // room with a 26 KB snapshot + a handful of log entries can hit // that limit). A single entries-object put batches the whole // seed into one transactional write, dropping CPU below the // ceiling. DO storage supports up to 128 keys per call — well // above what a real dump row ever carries. const entries: Record<string, unknown> = { [STORAGE_KEYS.metaUpdatedAt]: payload.updatedAt, }; // Chunk the snapshot when it exceeds the DO-storage 128 KiB // per-value ceiling. `snapshotEntries` returns either `{snapshot: // …}` (fast path) or `{snapshot:meta:{chunks}, snapshot:chunk:<i>: // …}` (>100 KiB, split). Skipped entirely for empty snapshots so // truly empty rooms keep the "no snapshot" shape. if (foldedSnapshot.length > 0) { Object.assign(entries, snapshotEntries(foldedSnapshot)); } // Keep only the bounded recent log tail for client catch-up — the // folded snapshot already incorporates the full log. for (let i = 0; i < logTail.length; i++) { entries[logKey(i)] = logTail[i] as string; } for (let i = 0; i < payload.audit.length; i++) { entries[auditKey(i)] = payload.audit[i] as string; } for (let i = 0; i < payload.chat.length; i++) { entries[chatKey(i)] = payload.chat[i] as string; } for (const [user, cell] of Object.entries(payload.ecell)) { entries[ecellKey(user)] = cell; } await this.#state.storage.put(entries); this.#ss = null; this.#nextLogSeq = logTail.length; this.#nextAuditSeq = payload.audit.length; this.#nextChatSeq = payload.chat.length; this.#resetVolatile(); }); await this.#armAlarm(); // The D1 mirror is a cross-DO write that happens on every seed. Two // opt-outs, from the caller's perspective: // - `payload.skipIndex=true` — the caller plans to batch index // writes via `PUT /_migrate/bulk-index`. Don't touch D1 at all. // - default (skipIndex=false) — fire-and-forget via `waitUntil` // so this 201 returns as soon as DO storage is durable; the D1 // write drains on the DO's background execution context. // At 1.8M rooms the second option alone cuts ~50 ms off the critical // path of each PUT, and the batched path cuts the D1 chokepoint by // 100× (see AGENTS.md §14 2026-04-21). if (!payload.skipIndex) { this.#state.waitUntil(this.#mirrorIndex(roomName, payload.updatedAt)); } // Mirror the seeded audit + chat into the durable D1 stores so a migrated // room's history survives the DO-tail trims. Done regardless of skipIndex // (which only governs the rooms index) — fire-and-forget. Skipped when // empty (the common dir-migration case keeps log/chat in-memory only). if (payload.audit.length > 0) { this.#state.waitUntil( this.#mirrorAudit( roomName, payload.audit.map((body, i) => ({ seq: i, ts: payload.updatedAt, body: body as string, })), ), ); } if (payload.chat.length > 0) { this.#state.waitUntil( this.#mirrorChat( roomName, payload.chat.map((body, i) => ({ seq: i, ts: payload.updatedAt, body: body as string, })), ), ); } return plainResponse('OK', 201); } /** * Phase 11b — client-side chunked snapshot upload. * * Contract (enforced by `routes/migrate.ts` before we ever see the * request, re-checked here for defense-in-depth and because unit * tests construct the DO directly): * - `seq` and `chunks` are integers, `0 ≤ seq < chunks`, `chunks ≥ 1`. * - Body is the verbatim chunk payload (plain UTF-8 text). * * Flow when called N times in order: * 1. seq=0..N-2 → land `snapshot:chunk:<padSeq(seq)>`. Meta is NOT * written yet; readers still see whatever snapshot (if any) was * there before the upload started. * 2. seq=N-1 → batched put of the final chunk + `snapshot:meta = * {chunks: N}` + `meta:updated_at`. Atomically flips the DO's * "current snapshot" over to the new chunked layout. Then * cleans up any legacy single-key `snapshot` from a prior seed * and any higher-seq chunks from a prior larger chunked save. * Finally mirrors the D1 `rooms` row so cross-room indexes pick * up the new `updated_at`. * * Re-migrating the same room: safe. The stale-cleanup step drops * both layouts of any leftover snapshot state. We don't reset * `#nextLogSeq` / `#nextAuditSeq` / `#nextChatSeq` — those counters * track DO-local append sequence and are unrelated to the snapshot * body itself. */ async #postSnapshotChunk( request: Request, roomName: string | null, searchParams: URLSearchParams, ): Promise<Response> { const seqRaw = searchParams.get('seq'); const chunksRaw = searchParams.get('chunks'); // `URLSearchParams.get` returns `null` for missing params; // `Number(null) === 0` would otherwise slip through the range check. const seq = seqRaw === null ? NaN : Number(seqRaw); const chunks = chunksRaw === null ? NaN : Number(chunksRaw); if ( !Number.isInteger(seq) || seq < 0 || !Number.isInteger(chunks) || chunks < 1 || seq >= chunks ) { return new Response('seq/chunks must be integers with 0 ≤ seq < chunks', { status: 400, }); } const body = await request.text(); const isFinal = seq === chunks - 1; let updatedAt = 0; await this.#state.blockConcurrencyWhile(async () => { if (!isFinal) { await this.#state.storage.put(snapshotChunkKey(seq), body); return; } // Read the prior meta BEFORE we overwrite it, so we know which // higher-seq chunks (if any) are stale from a larger previous // chunked save. A prior single-key `snapshot` is always cleaned // up regardless — either it's leftover from a pre-chunked seed // or it's absent, both fine. const priorMeta = await readSnapshotMeta(this.#state.storage); updatedAt = Date.now(); await this.#state.storage.put({ [snapshotChunkKey(seq)]: body, [STORAGE_KEYS.snapshotMeta]: { chunks } satisfies SnapshotMeta, [STORAGE_KEYS.metaUpdatedAt]: updatedAt, }); const stale: string[] = [STORAGE_KEYS.snapshot]; if (priorMeta !== null) { for (let i = chunks; i < priorMeta.chunks; i++) { stale.push(snapshotChunkKey(i)); } } await this.#state.storage.delete(stale); // Next `#getSpreadsheet` will rehydrate from the reassembled save. this.#ss = null; }); if (isFinal) { await this.#mirrorIndex(roomName, updatedAt); } return plainResponse('OK', 201); } async #postInstall(request: Request): Promise<Response> { const parsed = (await request.json()) as { snapshot?: unknown; log?: unknown; audit?: unknown; }; if (typeof parsed.snapshot !== 'string') { return new Response('install body.snapshot must be string', { status: 400 }); } const log = Array.isArray(parsed.log) ? (parsed.log as unknown[]) : []; const audit = Array.isArray(parsed.audit) ? (parsed.audit as unknown[]) : []; if (!log.every((e) => typeof e === 'string')) { return new Response('install body.log must be string[]', { status: 400 }); } if (!audit.every((e) => typeof e === 'string')) { return new Response('install body.audit must be string[]', { status: 400 }); } // Fold base+log into a single authoritative snapshot on ingest. // `#getSpreadsheet` no longer replays the log when a snapshot is // present, so the snapshot we store here must already incorporate the // incoming `log` commands — otherwise a renamed room would silently // lose every since-base command. The bounded recent tail (≤ LOG_RING) // is kept only for client catch-up; audit carries the full record. const foldedSnapshot = foldSnapshot(parsed.snapshot as string, log as string[]); const logTail = (log as string[]).slice(-LOG_RING); await this.#state.blockConcurrencyWhile(async () => { await this.#state.storage.deleteAll(); const entries: Record<string, unknown> = { ...snapshotEntries(foldedSnapshot), [STORAGE_KEYS.metaUpdatedAt]: Date.now(), }; for (let i = 0; i < logTail.length; i++) { entries[logKey(i)] = logTail[i] as string; } for (let i = 0; i < audit.length; i++) { entries[auditKey(i)] = audit[i] as string; } await this.#state.storage.put(entries); this.#ss = null; this.#nextLogSeq = logTail.length; this.#nextAuditSeq = audit.length; this.#nextChatSeq = 0; this.#resetVolatile(); }); await this.#armAlarm(); return plainResponse('OK', 201); } // ─── Cron fire-trigger (Phase 9) ─────────────────────────────────────── /** * `POST /_do/fire-trigger?cell=<coord>` — fire one due cron trigger. * * Legacy flow (src/sc.ls:360-370 + src/main.ls:196 + src/sc.ls:247-253): * - `SC[room].triggerActionCell(cell, cb)` ran * `SocialCalc.TriggerIoAction.Email('<coord>')` against the * cell, which produced a URL-encoded `sendemail <to> <subject> * <body>` string and passed it back via `cb`. * - The server parsed that string, dispatched to `emailer.sendemail`, * and broadcast `{type: confirmemailsent, message}` on * `log-<room>`. * * We collapse all of that here: * 1. Look up the cell's datavalue/formula/comment and derive a * `sendemail` command. SocialCalc's TriggerIoAction logic * stores the email payload directly in the cell's `datavalue` * (as a space-delimited, %20-encoded string starting with * `sendemail `). If the cell doesn't hold one, the trigger is * a no-op. * 2. Parse via `parseSendemail`, dispatch through `buildEmailSender(env)`. * 3. Broadcast `{type: 'confirmemailsent', message}` to every WS peer. * * Every failure path is swallowed into a `200 OK` so the cron * runner never retries on a malformed cell — the legacy handler * also moved on. */ async #fireTrigger(cell: string): Promise<Response> { if (cell.length === 0) return plainResponse('', 200); const ss = await this.#getSpreadsheet(); const cellRecord = ss.exportCell(cell) as | { datavalue?: unknown; formula?: unknown } | null; if (!cellRecord) return plainResponse('', 200); // Legacy's TriggerIoAction.Email reconstructs the command from // formula-like payload stored in the cell. In practice clients put // the full `sendemail <to> <subject> <body>` URL-encoded string // into `formula` (for triggered cells) or `datavalue` (for plain // text). Try both. const candidate = (typeof cellRecord.formula === 'string' && cellRecord.formula.length > 0 ? cellRecord.formula : '') || (typeof cellRecord.datavalue === 'string' ? cellRecord.datavalue : ''); const parsed = parseSendemail(candidate); if (!parsed) return plainResponse('', 200); const sender = buildEmailSender(this.#env); const { message } = await sender.send(parsed.to, parsed.subject, parsed.body); this.#broadcastAll({ type: 'confirmemailsent', message }); return plainResponse('', 200); } // ─── WebSocket acceptance ────────────────────────────────────────────── /** * `GET /_do/ws?user=<user>&auth=<hmac>` — upgrade to WebSocket using the * hibernation API. We attach `{user, room, auth}` so downstream handlers * can gate writes without re-verifying on every frame. */ #acceptWebSocket(request: Request): Response { if (request.headers.get('Upgrade') !== 'websocket') { return plainResponse('Expected Upgrade: websocket', 426); } // Per-room connection cap — a coarse DoS backstop. The hibernation API // keeps every accepted socket retrievable via `getWebSockets()`, so a // simple count is the live connection total for this room. if (this.#state.getWebSockets().length >= MAX_CONN) { return plainResponse('Too many connections', 503); } /* istanbul ignore next -- @preserve * `upgradeWebSocket` needs `WebSocketPair`, `state.acceptWebSocket`, * and a Workers `Response` accepting `status: 101` + `webSocket`. * None of these exist in Node; end-to-end coverage lives in the * workers-pool integration tests (`test/ws.test.ts`, * `test/legacy-socketio.test.ts`, `test/room.test.ts`). */ const wsOpts = isSandstormEnforced(this.#env) ? { sandstormModify: sandstormCanModify(request.headers) } : undefined; return upgradeWebSocket(this.#state, request, wsOpts); } /** * `GET /_do/legacy-ws` — hibernatable socket.io v0.9 upgrade. Same * connection cap as the native path; framing differs (see attachment * `legacy: true` + `#sendTo` / `webSocketMessage`). */ #acceptLegacyWebSocket(request: Request): Response { if (request.headers.get('Upgrade') !== 'websocket') { return plainResponse('Expected Upgrade: websocket', 426); } if (this.#state.getWebSockets().length >= MAX_CONN) { return plainResponse('Too many connections', 503); } /* istanbul ignore next -- @preserve * Same workerd-only surface as `#acceptWebSocket` (WebSocketPair + * acceptWebSocket + 101 Response). Covered by workers-pool tests. */ return upgradeLegacySocketIo(this.#state); } /** * Hibernation-api entrypoint. Parses the incoming frame, assembles a * `WsContext` bound to this socket, and delegates to the pure dispatch * layer in `src/lib/ws-handlers.ts` (Phase 7.1 extract). * * Legacy (`attachment.legacy`) sockets speak socket.io v0.9 framing: * heartbeats are auto-answered by `setWebSocketAutoResponse`; event * packets are unwrapped to native ClientMessage before dispatch. * Session-host DOs (sid-keyed, empty attachment.room) only forward * `execute` to the room named in the frame — matching the pre-hibernate * Worker-shim baseline — so spreadsheet state stays on the real room DO. */ async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> { if (typeof message !== 'string') return; // Per-frame byte cap — drop oversized frames before parsing so a // single client can't force a multi-MB JSON.parse + storage write. if (message.length > MAX_FRAME) return; const attachment = (ws.deserializeAttachment() as WsAttachment | null) ?? { user: '', room: '', auth: '' }; if (attachment.legacy) { await this.#handleLegacyFrame(ws, attachment, message); return; } const parsed = parseClientMessage(message); if (!parsed) return; // Auth-bearing message variants (`execute`, `ecell`, `stopHuddle`) // carry their own `auth` string; others never do. Default to empty so // the downstream `verifyAuth` treats it as view-only. const perMessageAuth = 'auth' in parsed && typeof parsed.auth === 'string' ? parsed.auth : ''; const ctx = this.#buildWsContext(ws, attachment, parsed.room, perMessageAuth); await dispatchWsMessage(ctx, parsed); } /** * Decode one socket.io v0.9 frame and either dispatch it locally (when * this DO is the room the frame names) or forward `execute` to the * named room DO (session-host / sid-keyed case). */ async #handleLegacyFrame( ws: WebSocket, attachment: WsAttachment, raw: string, ): Promise<void> { const packet = decodeFrame(raw); if (!packet) return; if (packet.type === PacketType.Disconnect) { try { ws.close(1000, 'client disconnected'); } catch { /* already closed */ } return; } // Heartbeats are answered by setWebSocketAutoResponse without waking // us; if one does arrive (auto-response unset in tests), ignore it. if (packet.type === PacketType.Heartbeat) return; if (packet.type !== PacketType.Event) return; const parsed = socketIoEventToNative(packet); if (!parsed) return; // Session host: attachment.room is empty because the upgrade was // sid-keyed, not room-keyed. Forward executes to the real room DO // (preserves spreadsheet locality); other types need two-way state // and are dropped here just as the pre-hibernate baseline did. if (!attachment.room) { if (parsed.type !== 'execute') return; if (isFilteredExecuteCommand(parsed.cmdstr)) return; const room = parsed.room; if (!room) return; try { const stub = this.#env.ROOM.get( this.#env.ROOM.idFromName(encodeRoom(room)), ); await stub.fetch(