ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
326 lines (238 loc) • 13.2 kB
Markdown
# EtherCalc
* Overview: https://ethercalc.net/
* User guide: [docs.ethercalc.net](https://docs.ethercalc.net) (Starlight). Local: `bun run --cwd packages/docs dev`
* 中文版: http://tw.ethercalc.net/
* 简体中文: http://cn.ethercalc.net/
* REST API: [API.md](./API.md)
EtherCalc is a web spreadsheet for real-time collaborative editing.
This branch is the TypeScript rewrite on the Cloudflare fullstack
(Hono + Workers + Durable Objects + D1 + KV + R2). It deploys to
Cloudflare via `wrangler deploy`, and **self-hosts anywhere via
`docker compose up`** with no Cloudflare account required. See
[AGENTS.md](./AGENTS.md) for agent context and
[docs.ethercalc.net](https://docs.ethercalc.net) for architecture.
The full rewrite plan lives in
[docs/historic/REWRITE_ULTRAPLAN.md](./docs/historic/REWRITE_ULTRAPLAN.md).
Integrated with content management systems:
* [Drupal](https://drupal.org/project/sheetnode)
Browsers tested: Safari, Chrome, Firefox.
## Install
Via npm (requires [Bun](https://bun.sh/) ≥ 1.1 on PATH — the CLI
spawns `bunx wrangler`):
npm install -g ethercalc
ethercalc # starts on http://localhost:8000
Via Docker (no Bun needed on the host — the image carries it):
git clone https://github.com/audreyt/ethercalc
cd ethercalc
docker compose up -d
The CLI path boots wrangler/Miniflare; the Docker path boots standalone
workerd. Both need no Cloudflare account, and Docker persists room state
under `./ethercalc-data/` (or `/data` in the container).
## Self-hosting
### Local / trusted LAN
git clone https://github.com/audreyt/ethercalc
cd ethercalc
docker compose up -d
This boots the standalone workerd Worker on `http://localhost:8000` and
persists spreadsheet room state to `./ethercalc-data/` in the repo. No
Redis, no Node runtime, no Cloudflare account. **Use this path only on
a trusted network** (localhost, office LAN, VPN). It binds plaintext HTTP
with no rate limiting or TLS.
### Internet-facing (required)
If the instance is reachable from the public internet, **you must put a
reverse proxy in front** that terminates TLS and applies rate limits.
Plain `docker compose up` alone is not suitable for that threat model.
The app deliberately keeps anonymous read/write for anyone who knows a
room URL; the edge is where you bound request volume. A runnable nginx
recipe ships in the repo:
docker compose -f docker-compose.proxy.yml up -d
The proxy config at `deploy/nginx/ethercalc.conf` sets a 25 MiB body
limit to match the Worker write cap, limits request/connection rates per
source address, and forwards WebSocket upgrades (with long read
timeouts, so idle spreadsheets stay connected). For production HTTPS:
place your certificates under `deploy/nginx/certs/`, uncomment the 443
listener in that file, **and** uncomment the 443 ports mapping in
`docker-compose.proxy.yml` — or copy the same limits to your existing
nginx/caddy/traefik edge. The bundled proxy serves the app at the URL
root; don't combine it with `ETHERCALC_BASEPATH` (the config does no
prefix stripping).
### Environment variables
Override defaults by exporting these before `docker compose up`:
| Variable | Default | Effect |
| --------------------- | ----------- | ------------------------------------------------- |
| `ETHERCALC_PORT` | `8000` | Listening port (remaps container bind). |
| `ETHERCALC_HOST` | `0.0.0.0` | Listening address. |
| `ETHERCALC_KEY` | *(unset)* | HMAC secret; enables read-only vs. edit auth. |
| `ETHERCALC_DISABLE_ROOM_INDEX` | `1` | Hide `/_rooms*` and `/_exists/:room`. Set `0` to reopen (on the Docker image the directory endpoints then return empty bodies — there is no D1 index; only `/_exists` becomes a live oracle). |
| `ETHERCALC_CORS` | *(unset)* | Legacy room-index gate; CORS headers are always permissive for embeds. |
| `ETHERCALC_BASEPATH` | *(unset)* | URL prefix, e.g. `/ethercalc` behind a proxy. |
| `ETHERCALC_EXPIRE` | *(unset)* | Seconds of inactivity before a room is pruned. |
| `ETHERCALC_RATELIMIT` | *(unset)* | Optional in-Worker per-IP limit (off by default). `1` or `10` = 10 req/s; `60:600` = 600 per minute. Belt-and-suspenders behind nginx — not a substitute for the proxy. |
| `ETHERCALC_ROOM_CREATE_LIMIT` | *(unset)* | Optional per-IP cap on room creation (`POST /_`, `/_new`, `/_from`, `PUT /_/room`). `1` = 6/min. Proxy compose defaults this on. |
Recommended public-instance settings:
- Set `ETHERCALC_KEY` if you want edit/delete URLs to require a per-room
HMAC rather than anonymous write/delete.
- Leave `ETHERCALC_DISABLE_ROOM_INDEX=1` unless you intentionally want a
public room directory and existence oracle.
- Set `ETHERCALC_EXPIRE` for public scratch instances, e.g.
`ETHERCALC_EXPIRE=2592000` for a 30-day inactivity TTL.
- **Always** use `docker-compose.proxy.yml` (or your own nginx/caddy/
traefik edge with equivalent limits) when the service is internet-facing.
- Keep the container on plain HTTP and terminate TLS at the reverse
proxy. If a local proxy fronts the container, publish the container
port on loopback, e.g. `127.0.0.1:8000:8000`; do not change
`ETHERCALC_HOST`, which must stay reachable inside the container.
- Optionally set `ETHERCALC_RATELIMIT=1` for an extra in-Worker per-IP
cap when nginx is already in place.
On Apple Silicon, Docker Desktop's virtio networking has an
intermittent quirk that can make `curl localhost:8000` hang even
against a healthy container. If you hit it, run the worker directly
(`bun run --cwd packages/worker dev`) or use a Linux host.
## CLI
For non-Docker runs (local dev, systemd, etc.) use the `bin/ethercalc`
wrapper. It accepts the legacy flag surface and forwards to
`wrangler dev` + Miniflare/`--var` bindings:
bin/ethercalc [--key SECRET] [--cors] [--port N] [--host ADDR] \
[--expire SEC] [--basepath PREFIX] \
[--persist-to DIR]
Run `bin/ethercalc --help` for the full flag table. `--keyfile` /
`--certfile` are accepted for backward compatibility but currently
print a warning — `wrangler dev` does not expose TLS. Terminate TLS at
a reverse proxy (nginx/caddy/traefik).
The `ETHERCALC_*` environment variables from the table above work here
too (exported before `bin/ethercalc`). Note that the CLI forwards them
to `wrangler dev` as `--var` arguments, which are visible in the local
process list — on shared machines, prefer a loopback bind or put
secrets in `packages/worker/.dev.vars` instead of the environment.
## Deploy to Cloudflare
cd packages/worker
npx wrangler deploy
Store the HMAC secret as a Worker secret:
npx wrangler secret put ETHERCALC_KEY
## Staying on legacy (Redis-backed) EtherCalc
`audreyt/ethercalc:latest` (and every `0.20260422.*` tag and later)
ships the 2026 TypeScript rewrite. It stores rooms in Durable Object SQLite
files, **not** Redis — pulling `latest` over an existing Redis-backed
install will look broken until you migrate.
To keep using Redis without migrating yet, pin the last pre-rewrite release:
docker pull audreyt/ethercalc:0.20201228.1
Or use the bundled compose file (builds the same image locally if the tag
is not cached yet):
git clone https://github.com/audreyt/ethercalc
cd ethercalc
# Reuse your existing Redis data directory:
ETHERCALC_LEGACY_REDIS_DATA=/var/lib/redis docker compose -f docker-compose.legacy.yml up -d
Room state lives in Redis (`appendonly yes`). The legacy stack listens on
port 8000 and speaks socket.io — same URLs and behaviour as pre-2026
self-hosts. When you are ready to move forward, see the migration section
below.
## Migration from a legacy (Redis-backed) EtherCalc
### Turnkey (recommended)
If you have a legacy Redis-backed EtherCalc and just want to upgrade:
# Preserve the Redis dump outside the repo — this is your rollback point
sudo cp /var/lib/redis/dump.rdb ~/ethercalc-dump-$(date +%F).rdb
git clone https://github.com/audreyt/ethercalc
cd ethercalc
cp ~/ethercalc-dump-$(date +%F).rdb ./legacy-dump.rdb
./bin/migrate-legacy.sh
One command stands up a temporary Redis loaded with your dump,
builds and runs the new Worker, streams every room across, and
writes a dated backup to `./backups/ethercalc-<timestamp>.tar.gz`
containing both the migrated state and your source dump. On success
the Worker is left running on http://localhost:8000 — open any
existing room by its URL to confirm.
Requires only `docker` + the `docker compose` plugin on the host.
On Ubuntu: `sudo apt install -y docker.io docker-compose-plugin`.
Tested against OrbStack and Docker Desktop on macOS/arm64; Docker
Engine on Linux.
### Migrating rooms to a Cloudflare deployment
Once the turnkey path above has verified locally, the same dump can
be pushed to a Cloudflare Workers deployment. From the repo root:
# Deploy the worker. Spits out https://ethercalc.<subdomain>.workers.dev
cd packages/worker
npx wrangler login # one-time browser auth
npx wrangler deploy
# Mint a migration token and store it as a Cloudflare secret
TOKEN=$(openssl rand -hex 16)
echo "$TOKEN" | npx wrangler secret put ETHERCALC_MIGRATE_TOKEN
# Stand up a temporary local Redis loaded with the legacy dump
cd ../..
docker run -d --name ec-migrate-redis -p 6379:6379 \
-v "$PWD/legacy-dump.rdb:/input/dump.rdb:ro" \
redis:7-alpine sh -c \
'cp /input/dump.rdb /data/dump.rdb && exec redis-server --save "" --appendonly no'
sleep 3 # let redis finish loading the dump
# Push every room up to the Cloudflare deployment
./bin/ethercalc migrate \
--source redis://localhost:6379 \
--target https://ethercalc.<subdomain>.workers.dev \
--token "$TOKEN"
docker rm -f ec-migrate-redis
Then attach your domain in the Cloudflare dashboard under Workers &
Pages → your worker → Triggers → Custom Domains.
### Manual (advanced)
`bin/ethercalc migrate` streams a running Redis or Zedis directly
into a Worker you already have up:
bin/ethercalc migrate \
--source redis://localhost:6379 \
--target http://new-worker.example/ \
--token $ETHERCALC_MIGRATE_TOKEN
O(1)-per-room memory regardless of dump size — Redis owns the decoding.
The target endpoint is gated by `env.ETHERCALC_MIGRATE_TOKEN` (when
unset, the route returns 404). Pass `--dry-run` to preview without
writing. `--source file:///path` (or bare `/path`) also works for
on-disk legacy dumps (the Sandstorm grain fallback format).
## Development
bun install
bun run --cwd packages/worker dev # wrangler dev --local
bun run --cwd packages/worker test # workers-pool + node tests
Formal verification / Leanstral pump (optional; not required for app builds):
bun run verify:dafny # LemmaScript → Dafny VCs (needs dafny on PATH)
bun run verify:lean # LemmaScript → Lean gen + non-empty + fresh smoke
bun run verify:context # needs sibling https://github.com/audreyt/socialcalc
bun run verify:request # concatenate prompt+context+Lean for Leanstral
omp --print --no-tools --no-session --mode text \
--model mistral/labs-leanstral-1-5-1 @lemma/request.md
Shipping TypeScript is the oracle. Dafny checks a reduced integer facade;
Lean generation feeds Leanstral; only Bun tests decide production behavior.
See [lemma/README.md](./lemma/README.md). Full SocialCalc A1 algebra stays
upstream in [audreyt/socialcalc](https://github.com/audreyt/socialcalc)
(`lemma/`). `verify:context` needs that repo checked out as `../socialcalc`;
tracked `lemma/context.md` / `lemma/request.md` remain usable without regen.
See [AGENTS.md](./AGENTS.md) for the directory map, testing strategy
(100% line/branch/function/statement coverage plus Stryker mutation
gates on gated packages), and the remaining phase plan.
## REST API
See [API.md](./API.md). The public HTTP surface is preserved
byte-for-byte where deterministic, minus a small allow-list of
sensible fixes documented in AGENTS.md §6.1.
# Licensing
### Common Public Attribution License (Socialtext Inc.)
* socialcalcspreadsheetcontrol.js
* socialcalctableeditor.js
### Artistic License 2.0 (Socialtext Inc.)
* formatnumber2.js
* formula1.js
* socialcalc-3.js
* socialcalcconstants.js
* socialcalcpopup.js
#### Artistic License 2.0 (Framasoft)
* l10n/fr.json
### MIT License (John Resig, The Dojo Foundation)
* static/jquery.js
### MIT License (HubSpot, Inc.)
* static/vex-theme-flat-attack.css
* static/vex.combined.min.js
* static/vex.css
### MIT License (Stuart Knightley, David Duponchel, Franz Buchinger, Ant'onio Afonso)
* static/jszip.js
### Apache License 2.0 (SheetJS)
* static/shim.js
* static/xlsx.core.min.js
* static/xlsxworker.js
* assets/start.html (xlsx2socialcalc.js)
### CC0 Public Domain (唐鳳 / Audrey Tang)
* src/*.ls (legacy LiveScript sources, preserved until Phase 12 sweep)
* packages/**/*.ts (TypeScript rewrite)
### Mozilla Public License Version 2.0 (LibreOffice contributors)
* images/sc_*.png