UNPKG

ethercalc

Version:

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

171 lines (165 loc) 6.91 kB
/** * EtherCalc Worker entry point. HTTP routing lives in Hono; pure request- * handling logic lives in `./handlers/`. This file is intentionally tiny * glue — it's excluded from the 100% coverage gate (see `vitest.config.ts`) * because istanbul inside `@cloudflare/vitest-pool-workers` doesn't track * hits through Hono's bundled invocation path (see AGENTS.md §5.2). */ /* istanbul ignore file */ import { Hono } from 'hono'; import { bodyLimit } from 'hono/body-limit'; import { cors } from 'hono/cors'; import { buildHealthBody } from './handlers/health.ts'; import { clientIpFromHeaders, createRateLimitStore, isRateLimitExemptPath, rateLimitConfigFromEnv, } from './lib/rate-limit.ts'; import { createRateLimitStore as createRoomCreateStore, isRoomCreationRoute, roomCreateLimitFromEnv, } from './lib/room-create-limit.ts'; import { sandstormBlocksMutation } from './lib/sandstorm-access.ts'; import { registerAssets, registerRoomCatchAll } from './routes/assets.ts'; import { registerExports } from './routes/exports.ts'; import { registerMultiSheetImport } from './routes/multi-import.ts'; import { registerLegacySocketIo } from './routes/legacy-socketio.ts'; import { registerMigrate } from './routes/migrate.ts'; import { registerRoomRoutes } from './routes/rooms.ts'; import { registerStateless } from './routes/stateless.ts'; import { registerTimetrigger } from './routes/timetrigger.ts'; import { registerWs } from './routes/ws.ts'; import { scheduled } from './scheduled.ts'; import type { Env } from './env.ts'; export { RoomDO } from './room.ts'; export { scheduled } from './scheduled.ts'; /** * Build the root Hono app. Exported for tests so they can construct it * with whatever `Env` they need. The default export at the bottom wires * the production app. * * Route ordering rationale: Hono's radix/trie router matches static * prefixes before params, so the specific `/:room/edit` etc register * cleanly alongside static `/_new`, `/_start`, `/etc/*`, `/var/*`. The * generic `/:room` entry-page route registers LAST (via * `registerRoomCatchAll`) so future `_rooms`, `_from/:template`, etc * additions sit in front of it in the trie. Phase 5 work that lands * new `/_*` routes should plug into `registerStateless` or a new * `registerRoomCrud` between the two calls — never after * `registerRoomCatchAll`. */ const rateLimitStore = createRateLimitStore(); const roomCreateStore = createRoomCreateStore(); export function buildApp(): Hono<{ Bindings: Env }> { const app = new Hono<{ Bindings: Env }>(); // All API endpoints are CORS-friendly — external embeds (hackfoldr, // third-party dashboards) fetch /_/:room/csv etc cross-origin. app.use('*', cors()); // Optional self-host abuse belt-and-suspenders (§13 Q7). Default off; // when `ETHERCALC_RATELIMIT` is set, apply a per-IP token bucket before // routing. Health probes stay exempt. app.use('*', async (c, next) => { const config = rateLimitConfigFromEnv(c.env); if (!config || isRateLimitExemptPath(c.req.path)) { await next(); return; } const result = rateLimitStore.consume( clientIpFromHeaders(c.req.raw.headers), config, ); if (!result.allowed) { if (result.retryAfterSec != null) { c.header('Retry-After', String(result.retryAfterSec)); } return c.text('Too Many Requests', 429); } await next(); }); // SH-3: optional per-IP cap on room-creation endpoints (default off). app.use('*', async (c, next) => { const config = roomCreateLimitFromEnv(c.env); if ( !config || isRateLimitExemptPath(c.req.path) || !isRoomCreationRoute(c.req.method, c.req.path) ) { await next(); return; } const result = roomCreateStore.consume( clientIpFromHeaders(c.req.raw.headers), config, ); if (!result.allowed) { if (result.retryAfterSec != null) { c.header('Retry-After', String(result.retryAfterSec)); } return c.text('Too Many Requests', 429); } await next(); }); // SH-6: Sandstorm viewer role — block mutations without `modify`. app.use('*', async (c, next) => { if ( sandstormBlocksMutation( c.env, c.req.method, c.req.path, c.req.raw.headers, ) ) { return c.text('Forbidden', 403); } await next(); }); // Cap the body of the anonymous write routes (POST `/_`, PUT/POST // `/_/:room`) so an unauthenticated client can't force the worker to // buffer + persist an unbounded payload (§5). 25 MiB comfortably covers // any real interactive snapshot/command batch; genuinely huge rooms are // seeded through the token-gated, chunked `/_migrate/*` path which is // intentionally not capped here. GET exports under `/_/:room/*` carry no // request body, so the limit is a no-op for them. const MAX_WRITE_BYTES = 25 * 1024 * 1024; app.use('/_', bodyLimit({ maxSize: MAX_WRITE_BYTES })); app.use('/_/*', bodyLimit({ maxSize: MAX_WRITE_BYTES })); app.get('/_health', (c) => c.json(buildHealthBody())); // Phase 7: native WS + legacy socket.io shim. Register early so their // literal prefixes win against the `/:room` catch-all. `/_ws/:room` is // the native transport; `/socket.io/*` covers the old embeds. registerWs(app); registerLegacySocketIo(app); // Phase 9 — backwards-compat `/_timetrigger` endpoint. Registered before // the room routes so the `_timetrigger` literal wins against any // `/_exists/:room` pattern (same leading underscore). Reads the D1 // `cron_triggers` table and fires due rows just like `scheduled()`. registerTimetrigger(app); // Phase 11b — migration seed endpoint (`PUT /_migrate/seed/:room`). // Registered before the room routes so the `_migrate` literal prefix // wins against `/_/:room` patterns. Gated by `ETHERCALC_MIGRATE_TOKEN` // inside the handler; no risk of exposing it accidentally. registerMigrate(app); // Room index + CRUD — register BEFORE stateless so `/_rooms`, `/_exists/:room`, // `/_from/:template` etc take precedence over any `/:room`-style catch-all. registerRoomRoutes(app); // Exports — `/_/:room/<format>` and `/:room.<format>`. Registered after // CRUD (so `/_/:room` itself still wins for raw-save) but before the // `/:room` catch-all (so `/foo.csv` routes to the csv exporter rather // than being treated as a room-entry request). registerExports(app); registerMultiSheetImport(app); registerStateless(app); registerAssets(app); registerRoomCatchAll(app); return app; } // Module-worker default export: Cloudflare calls `fetch` for HTTP + WS // upgrades and `scheduled` for cron triggers (Phase 9). Using the Hono // app's `.fetch` directly keeps the existing HTTP routing intact. const _app = buildApp(); export default { fetch: _app.fetch.bind(_app), scheduled, };