ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
1,234 lines (1,169 loc) • 76.8 kB
text/typescript
/**
* 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(