# EtherCalc TypeScript Rewrite — Ultraplan (archived) > **Archived:** 2026-06-12 — moved from root `CLAUDE.md`. Active agent context is > [`../../AGENTS.md`](../../AGENTS.md). Do not edit this file for new work unless > you are deliberately updating historical record. > > **Status at archive:** complete · all §13 questions answered · **Owner:** Audrey Tang · **Started:** 2026-04-19 > > Originally `CLAUDE.md` so it auto-loaded into Claude Code sessions. It is the durable plan-of-record for rewriting EtherCalc in modern TypeScript on the Cloudflare fullstack (Hono + Workers + Durable Objects + D1 + KV + R2), locally runnable via Miniflare, and also self-hostable without a Cloudflare account via `docker compose up`. 100% line/branch/function/statement coverage is enforced in CI on gated packages. > > **How to use this doc:** §1 is the contract. §13 is the canonical decision log — do not re-ask. §8 lists what's still open. §14 is the session log for context continuity. Update §14 when you finish work; edit other sections in place when reality diverges. --- ## 1. North Star ### 1.1 Done definition Ship a greenfield TypeScript implementation of EtherCalc that: 1. **Passes a golden-fixture oracle-equivalence suite** against the current `main` branch — every §6.1 HTTP endpoint (identical status/headers/body for deterministic formats, structural equivalence for HTML/XLSX/ODS), every §6.2 WS message, every §6.3 Redis key pattern mapped to the new storage layer with equivalent semantics. Small allow-list of "sensible fixes" (§13 Q1) documented in §6.1. 2. **Runs locally under Miniflare** with the full feature set — DO WebSockets, D1 snapshots, KV indexes, R2 — no Redis/Node dependency. 3. **Deploys to Cloudflare Workers** via `wrangler deploy`. 4. **Is self-hostable** via `docker compose up` (standalone workerd container, persistent volume, no CF account needed — §13 Q5). 5. **Maintains 100% coverage** on gated packages in CI. Any PR dropping a metric below 100 fails. 6. **Preserves the public HTTP API** byte-for-byte where deterministic (minus sensible fixes). 7. **Client speaks new WS protocol** (raw JSON). Legacy `/socket.io/*` shim retained indefinitely for external embeds (§13 Q4). 8. **`multi/` ported to React 18 + TypeScript** (§13 Q2), preserving `/=:room` URL scheme. 9. **`ethercalc` CLI kept** as a thin wrapper around `wrangler dev` / Miniflare (§13 Q6). ### 1.2 Explicit non-goals - Rewriting SocialCalc itself. - Changing the spreadsheet wire format. - Bug-for-bug preservation as absolute rule — sensible fixes allowed per §13 Q1 (default still leans preservation). - New user-facing features. - Multi-region / strong consistency upgrades. - OAuth/Gmail email sending (replaced by `send_email` binding, §13 Q3). - Dead-platform configs (`snapcraft`/`dotcloud`/`openshift`/`stackato`). - `webworker-threads` backend (DO isolates cover the sandboxing property). - Mandatory application-layer rate limiting on hosted deploys (relies on CF platform / WAF, §13 Q7). Self-host may opt into `ETHERCALC_RATELIMIT` behind a proxy edge. --- ## 2. Glossary - **Oracle** — current `main` branch on Node/Bun + Redis; source of truth for semantics. - **Target** — the new TypeScript Worker implementation. - **Room** — spreadsheet / page, identified by a URL-safe string. - **Multi-sheet** — URL prefix `/=:room` routing; sub-sheets per row of a TOC sheet. - **DO** — Durable Object; one per room. - **Snapshot** — SocialCalc save string (multi-section text blob). - **Log** — commands since last snapshot; periodically folded back. - **Audit** — append-only log of all commands (never folded). - **ECell** — "editing cell"; each user's cursor position. --- ## 3. Target Architecture (Cloudflare fullstack) ### 3.1 Component map | Concern | Old | New | | ----------------------------- | ------------------------------------------------ | ------------------------------------------------------------- | | HTTP router | `zappajs` on Express 3 | **Hono** on Workers | | Static assets | `express.static` | **Workers Assets** (ASSETS binding) | | Live spreadsheet state | `SC[room]` global + `vm.createContext` | **Durable Object** `RoomDO` — one per room | | Persistent snapshot/log/audit | Redis `snapshot-*`, `log-*`, `audit-*`, `chat-*` | **DO storage** (primary) + **D1** mirror for cross-room query | | Room index | `KEYS snapshot-*` | **D1** `rooms` table + optional KV hot path | | Realtime transport | socket.io 0.9 | DO-hosted **WebSocket** (hibernation API), raw JSON protocol | | Cron | External cron pinging `/_timetrigger` | **Cron Triggers** invoking the Worker | | Email | `nodemailer` + gmail xoauth2 | **`send_email` binding** or stub | | Secrets | CLI flag `--key` | Worker secret `ETHERCALC_KEY` **and** CLI `--key` (§13 Q6) | | Self-host | `docker-compose.yml` (Node + Redis) | standalone workerd image (§13 Q5) | ### 3.2 Request flow ``` Browser ──HTTP/WS──> Worker (Hono) │ ├── static: Workers Assets ├── stateless HTTP (rooms list, exists): D1 └── per-room (R/W, WS, exports): env.ROOM.get(idFromName(room)) ──> RoomDO ├── in-memory SocialCalc.SpreadsheetControl ├── state.storage (snapshot/log/audit/chat/ecell) ├── state.acceptWebSocket(ws) per client └── scheduled() — fold log into snapshot, mirror to D1 ``` ### 3.3 Data model **DO storage (per-room)** — authoritative: - `snapshot` — SocialCalc save string (versioned v2: prefix; reader falls back to v1) - `log:` — indexed command strings - `audit:` — same pattern, never truncated - `chat:` — same pattern - `ecell:` — map of user → cell coord - `meta:updated_at` — Date.now() **D1**: ```sql CREATE TABLE rooms ( room TEXT PRIMARY KEY, updated_at INTEGER NOT NULL, cors_public INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE cron_triggers ( room TEXT NOT NULL, cell TEXT NOT NULL, fire_at INTEGER NOT NULL, PRIMARY KEY (room, cell, fire_at) -- compound PK preserves legacy comma-list semantics ); ``` ### 3.4 SocialCalc inside a Durable Object `packages/socialcalc-headless/` wraps SocialCalc (loaded via Vite `?raw` import) through a `new Function(...)` eval scaffold, with DOM stubs (`Node`, `document`, `window`, `navigator`) and a synchronous `setTimeout` shim. Plan A green; Plans B/C not needed. Full details in §16.A. --- ## 4. Oracle strategy ### 4.1 Equivalence For each recorded scenario replayed against the target: - **HTTP**: same status, `Content-Type`, body (exact bytes for deterministic; structural equality after normalization for HTML/XLSX/ODS). - **DO state**: after each scenario, dump keys under the room and compare against oracle's Redis dump (normalized). - **WebSocket transcript**: `(direction, timestamp_delta, message)` tuples; same sequence modulo timestamps. ### 4.2 Normalization rules - Drop headers: `Date`, `Server`, `ETag`, `X-Powered-By`, `Connection`, `Accept-Ranges`, `Cache-Control`, `Content-Length`. `Last-Modified` semi-volatile → relax via `re:` matcher. - **HTML** (`packages/oracle-harness/src/html-canonical.ts`): parse via linkedom; drop comments, whitespace-only text, `id` matching `/^(SocialCalc|[a-f0-9-]{32,})/`, referrer attributes pointing at dropped ids (`for`, `aria-labelledby`, `aria-controls`, `aria-describedby`, `headers`, `form`, `list`, `href="#volatileId"`). Attributes sorted alphabetically (note: linkedom's `setAttribute` prepends — sort reverse-alpha before re-insertion). - **XLSX** (`packages/oracle-harness/src/zip-canonical.ts`): unzip via fflate, sort entries, canonicalize XML. Drop `docProps/core.xml` (`dcterms:created`, `dcterms:modified`, `cp:lastModifiedBy`, `cp:revision`) and `docProps/app.xml` (`AppVersion`, `TotalTime`). - **ODS**: same pipeline; `meta.xml` drops `meta:creation-date`, `dc:date`, `meta:editing-duration`, `meta:editing-cycles`, `meta:generator`, `dc:creator` (depth-walk under ``). - **SocialCalc save**: ignore `version:…` line and metadata-section ordering; compare `sheet:`/`cell:` lines exactly. - **CSV/JSON/Markdown**: exact bytes. - Socket IDs, timestamps, UUIDs, HMACs: `__PLACEHOLDER__` during comparison. --- ## 5. Testing strategy & coverage ### 5.1 Two vitest configs per package - `vitest.config.ts` — `@cloudflare/vitest-pool-workers`, test files `*.test.ts`. **No coverage gate** (neither istanbul nor v8 reliably track hits through the workerd bundle). - `vitest.node.config.ts` — Node env, test files `*.node.test.ts`. **100% coverage gate** on `src/handlers/**`, `src/lib/**`, `src/room.ts`. `src/index.ts` (Hono glue) and workers-only shims like `src/lib/ws-upgrade.ts` are excluded from the Node gate. ### 5.2 Coverage — known limitation **`@cloudflare/vitest-pool-workers` does not play well with istanbul or v8 coverage.** v8 reports 0% because workerd lacks Node's inspector; istanbul misses functions routed through Hono's bundled router. The two-config split above is the workaround; side-effect is handlers stay pure (DI for clocks, no direct env access) which aids testability. ### 5.3 Mutation testing — REQUIRED Per-package `stryker.conf.json` with a `break` threshold pinned to the measured floor. PRs fail a fast `mutation-gate` CI job that runs Stryker only on packages whose `src/` changed. To raise a floor: close mutants (see `docs/MUTATION_REPORT.md` top-gaps), re-run `bun run mutation`, bump `break` in the same PR. Nightly runs the full matrix. ### 5.4 Test file naming - `*.test.ts` — workers-pool integration tests. - `*.node.test.ts` — pure-logic unit tests, coverage-gated. --- ## 6. Surface inventory ### 6.1 HTTP endpoints `BASEPATH` is a prefix (default empty). `KEY` gates edit/view if set. `CORS` is now a legacy room-index gate; CORS headers are emitted unconditionally for embed compatibility. Prefer `ETHERCALC_DISABLE_ROOM_INDEX` for self-host discovery control. | Method | Path | Content-Type req/res | Notes | | ------ | --------------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | GET | `/` | → `index.html` | | | GET | `/_start` | → `start.html` | | | GET | `/etc/*`, `/var/*` | 404 `Content-Type: text/html; charset=utf-8` | Explicit block; legacy Express default is `text/html`. | | GET | `/favicon.ico` | `image/vnd.microsoft.icon` | **Sensible-fix** (§13 Q1) — legacy served as `text/html`. | | GET | `/manifest.appcache` | `text/cache-manifest` | DevMode stub via `DEVMODE=1`. | | GET | `/static/socialcalc.js` | `application/javascript` | From Workers Assets; external embeds depend on this path (§13 Q8). | | GET | `/static/form:part.js` | `application/javascript` | Literal colon in path; routed via `/static/:file{form.+\.js}` constrained segment. | | GET | `/_new`, `/=_new` | 302 → new room (+`/edit` if KEY) | Auto-generated uuid. | | GET | `/_timetrigger` | `application/json` | Legacy cron endpoint; fires due triggers. | | GET | `/_rooms` | `application/json` | `403` if room-index gate (`ETHERCALC_DISABLE_ROOM_INDEX`, legacy `CORS` fallback — §13 Q11). | | GET | `/_roomlinks` | `text/html` | **Sensible-fix** (§13 Q1) — legacy emitted JSON body with HTML CT. | | GET | `/_roomtimes` | `application/json` | Sorted desc by `updated_at`. | | GET | `/_from/:template` | 302 → new room | Copies template via DO-to-DO fetch. | | GET | `/_exists/:room` | `application/json` (**bare** boolean) | Per oracle F-05. Gated like `/_rooms` (§13 Q11). | | GET | `/:room` | → `index.html` (or `multi/index.html` for `=`) | Redirect to `?auth=0` / `?auth=` if `KEY` set. | | GET | `/:template/form` | 302 → `/_/app` | Uses `/_do/clone`. | | GET | `/:template/appeditor` | → `panels.html` | | | GET | `/:room/{edit,view,app}` | 302 with `?auth=&…` | | | GET | `/_/:room` | `text/plain; charset=utf-8` | SocialCalc save; 404 with empty body if missing. | | GET | `/_/:room/html`, `/:room.html` | `text/html` | | | GET | `/_/:room/csv`, `/:room.csv` | `text/csv`; `Content-Disposition: attachment` | | | GET | `/_/:room/csv.json`, `.csv.json` | `application/json` | | | GET | `/_/:room/{ods,fods}` | `application/vnd.oasis.opendocument.spreadsheet` | | | GET | `/_/:room/xlsx`, `/:room.xlsx` | `application/vnd.openxml….sheet` | | | GET | `/_/:room/md`, `/:room.md` | `text/x-markdown` | | | GET | `/_/=:room/xlsx` etc | multi-sheet export | Merges sub-sheets via TOC. | | GET | `/_/:room/cells` | `application/json` | **Unwrapped** `JSON.stringify(sheet.cells)` — legacy shape. | | GET | `/_/:room/cells/:cell` | `application/json` | Single cell. | | PUT | `/_/:room` | sc / json / csv / xlsx bodies | Returns `201 OK`. Replaces snapshot, clears log, broadcasts `snapshot` WS event. | | PUT | `/=:room.xlsx`, `/_/=:room/xlsx` | xlsx/ods/fods | Multi-sheet import: parses, writes TOC + sub-sheets. | | POST | `/_/:room` | json `{command}` OR text `loadclipboard …` OR xlsx | Text-wiki filter → multi-cascade rename → loadclipboard enrichment → DO dispatch → `202 {command}`. | | POST | `/_` | same as PUT | `201` + Location; generates `room` if absent. | | DELETE | `/_/:room` | `201 OK` | Deletes all room keys + D1 row. | **Bug-for-bug preserved**: `PUT /_/:room` returns `201` (not 200) even when overwriting. `POST /_/:room` empty body → `400 'Please send command'` `text/plain`. `encodeURI(room)` everywhere. `GET /_/:room` missing → `404` empty `text/plain`. ### 6.2 WebSocket message types Native `WebSocket` at `wss:///_ws/:room?user=&auth=`. JSON messages, one per frame. **Client → server**: | type | payload | server action | | ------------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `chat` | `{room, msg, user}` | Append to chat log; broadcast to log-room. | | `ask.ecells` | `{room}` | Reply `{type:ecells, ecells, room}`. | | `ask.ecell` | `{room, user, ecell}` | Rebroadcast as catch-all to peers (client polls this on DoPositionCalculations; peers reply with `ecell`). | | `my.ecell` | `{room, user, ecell}` | Store `ecell:`. | | `execute` | `{room, cmdstr, user, auth, saveundo?}`| Validates auth; rejects `set sheet defaulttextvalueformat text-wiki`; appends log+audit; runs in SC; broadcasts. **`submitform` path**: auto-creates `_formdata` sibling, broadcasts with `include_self: true` (legacy invariant; form clients hang without it). | | `ask.log` | `{room, user}` | Replies `{type:log, room, log, chat, snapshot}` (or `{type:ignore}` if DB not ready). | | `ask.recalc` | `{room}` | Reply `{type:recalc, room, log, snapshot}`. Also called by `RecalcInfo.LoadSheet` for cross-sheet formulas. | | `stopHuddle` | `{room, auth}` | Validates auth; deletes all room keys + D1 row. | | `ecell` | `{room, user, ecell, original?, to?, auth}` | Validates auth; broadcasts. **`to?` field preserved** for private-channel routing. | **Server → client**: `log`, `recalc`, `snapshot`, `execute`, `ecells`, `confirmemailsent`, `ignore`, plus fallback-forwarded client messages. **Auth rules** (§6.4): WS `execute`/`ecell`/`stopHuddle` require `auth === hmac(room)` OR no KEY set; `auth === '0'` is view-only and **must be rejected unconditionally** (identity-HMAC when KEY unset would otherwise make `computeAuth(undefined, '0') === '0'` permissive). `verifyAuth` hard-rejects `'0'` first; if `!key`, short-circuits `true` for any non-'0' auth (matches legacy `src/main.ls:506` semantics). HTTP endpoints do **not** require auth (known weakness; preserve). ### 6.3 Storage keys (legacy Redis, for oracle mapping) | Key pattern | Type | New impl | | ------------------------------ | ---- | -------------------------------------------------------------------- | | `snapshot-` | str | DO `snapshot` + D1 mirror on command/snapshot. | | `log-` | list | DO `log:` ordered keys. | | `audit-` | list | DO `audit:`. | | `chat-` | list | DO `chat:` (§13 Q9: D1 mirror beyond DO lifetime). | | `ecell-` | hash | DO `ecell:`. | | `timestamps` | hash | D1 `rooms.updated_at`. | | `cron-list`, `cron-nextTriggerTime` | — | D1 `cron_triggers` table. | TTL (`--expire`) implemented via DO `setAlarm` (§13 Q10). --- ## 7. Live compatibility risks Remaining items worth flagging. Resolved risks are documented in the code; see git history for rationale. 1. **`?raw` Vite imports vs wrangler `[[rules]]` cross-toolchain trap**: wrangler needs `[[rules]] type="Text" globs=["**/SocialCalc.js"]` to bundle the UMD for `wrangler deploy --dry-run`. But when vitest-pool-workers reads `wrangler.configPath`, that same rule gets merged into Miniflare's `modulesRules`, which mangles our Vite `?raw` imports by appending `?mf_vitest_force=Text` and breaking the resolver. Workaround in `packages/worker/vitest.config.ts`: drop `wrangler.configPath`, supply `main` + `miniflare.durableObjects` + `miniflare.assets` inline. 2. **Docker Desktop on macOS/ARM + workerd networking quirk**: `docker compose up` binds 0.0.0.0:8000 inside the container, but Docker Desktop's virtio networking on Apple Silicon returns zero bytes to host curls. Linux CI runners don't reproduce it. Dev-affordance only. If a contributor reports "docker compose up works but curl hangs", answer is "run `bun run --cwd packages/worker dev` directly, or use Linux/WSL". 3. **Self-host env wiring**: `bin/ethercalc` forwards Worker bindings via `--var` for the local wrangler-dev path; Docker/workerd maps `ETHERCALC_BASEPATH` to the Worker's `BASEPATH` binding in `config.capnp`. `ETHERCALC_CORS` is now a legacy room-index gate only; CORS headers are emitted unconditionally for embed compatibility. Prefer `ETHERCALC_DISABLE_ROOM_INDEX` for self-host discovery control. 4. **Third-party bundled libs** (`third-party/class-js/`, `third-party/wikiwyg/`, plus jQuery + vex inlined into `static/ethercalc.js`): some are IE-era. Audit before re-bundling under Vite in the client pipeline. 5. **Offline/sessionStorage client behavior** (`SocialCalc.hadSnapshot` flag): client caches last sheet to sessionStorage and restores on reconnect. Port preserves current behavior; revisit if it becomes load-bearing. 6. **`ScheduleSheetCommands` async path**: headless bypasses it with sync `ExecuteSheetCommand`. Fine for HTTP requests. If any command sequence depends on the async scheduler's `cmdend` callback, we'll need to switch. No known case yet. --- ## 8. Phase plan Phases 0–11 complete (see §14 for merge history). Remaining: ### Phase 3 — Oracle coverage - [x] Expand recorded scenarios to 27 (room CRUD, exports incl. XLSX/ODS, form redirect, WS connect/ask-log/execute; `packages/oracle-harness`). - [x] Structural equality oracle tests for HTML/XLSX/ODS wired to export responses (`zip-canonical`, `html-canonical` matchers). ### Phase 8 — Export polish - [x] csv/csv.json/html/xlsx/ods/fods/md implemented via SocialCalc + SheetJS + pure GFM. - [x] xlsx/ods/fods exports walk `sheet.cells` — formulas, number formats, merges, comments preserved (`54c191e`). - [x] Multi-sheet xlsx/ods/fods export + xlsx/ods import with formula fidelity (`ecb18d3`). - [x] Cross-sheet formulas (`'other'!A1`) via `findCrossSheetRefs` + `/_do/snapshot` DO-to-DO fetch (`ecb18d3`). ### Phase 11 — Loose ends - [x] Playwright skeleton at `packages/e2e/` with 14 passing specs (single-sheet + multi-sheet smoke; no skips). - [x] `/:template/form` DO-to-DO clone (`POST /_do/clone` + 302 `/