UNPKG

ethercalc

Version:

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

143 lines (134 loc) 5.84 kB
/** * Phase 11b — `PUT /_migrate/seed/:room` — the worker-level entry point * for `@ethercalc/migrate`. Authenticates the caller via * `env.ETHERCALC_MIGRATE_TOKEN`, then forwards the full JSON payload to * the room DO's `POST /_do/seed` (see `src/room.ts`). * * Route logic is intentionally thin: * 1. `verifyMigrateToken` decides whether to serve the request at all. * An unset token yields `404` (the route is invisible) so production * deploys don't accidentally expose a write primitive. * 2. On success we proxy the verbatim body to the DO. Payload validation * lives in `src/handlers/migrate.ts` and runs inside the DO; keeping * the route dumb avoids a second validation surface that could drift. * * Excluded from the Node coverage gate for the same reason as the other * `routes/*.ts` files — `@cloudflare/vitest-pool-workers` istanbul can't * follow dispatch through Hono's bundled router. End-to-end coverage is * in `test/migrate.test.ts`. */ /* istanbul ignore file */ import type { Hono } from 'hono'; import { doFetch } from '../lib/do-dispatch.ts'; import { parseBulkIndexPayload } from '../handlers/migrate.ts'; import { verifyMigrateToken } from '../lib/migrate-auth.ts'; import { bulkMirrorRoomsToD1 } from '../lib/rooms-index.ts'; import type { Env } from '../env.ts'; const TEXT_CT = 'text/plain; charset=utf-8'; export function registerMigrate(app: Hono<{ Bindings: Env }>): void { app.put('/_migrate/seed/:room', async (c) => { const verdict = verifyMigrateToken( c.env.ETHERCALC_MIGRATE_TOKEN, c.req.header('Authorization') ?? null, ); if (verdict.kind === 'disabled') { return c.text('Not Found', 404, { 'Content-Type': TEXT_CT }); } if (verdict.kind === 'missing' || verdict.kind === 'bad') { return c.text('Unauthorized', 401, { 'Content-Type': TEXT_CT }); } const room = c.req.param('room') ?? ''; if (room.length === 0) { return c.text('room segment required', 400, { 'Content-Type': TEXT_CT }); } const body = await c.req.raw.arrayBuffer(); const res = await doFetch(c.env, room, '/_do/seed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }); const text = await res.text(); return c.text(text, res.status as 201 | 400, { 'Content-Type': TEXT_CT }); }); // Batched sibling of the seed endpoint. Callers (the migrator, today) // send `PUT /_migrate/bulk-index` with `{rooms: [{room, updatedAt}, …]}` // after the seed pass; we fold all rows into ONE D1 INSERT to dodge // D1's per-statement primary-region latency. See AGENTS.md §14 // 2026-04-21 for the why. Same auth gate as /_migrate/seed. app.put('/_migrate/bulk-index', async (c) => { const verdict = verifyMigrateToken( c.env.ETHERCALC_MIGRATE_TOKEN, c.req.header('Authorization') ?? null, ); if (verdict.kind === 'disabled') { return c.text('Not Found', 404, { 'Content-Type': TEXT_CT }); } if (verdict.kind === 'missing' || verdict.kind === 'bad') { return c.text('Unauthorized', 401, { 'Content-Type': TEXT_CT }); } let raw: unknown; try { raw = await c.req.json(); } catch { return c.text('bulk-index body must be valid JSON', 400, { 'Content-Type': TEXT_CT, }); } const parsed = parseBulkIndexPayload(raw); if (!parsed.ok) { return c.text(parsed.error, 400, { 'Content-Type': TEXT_CT }); } if (c.env.DB === undefined) { // No D1 bound — silently succeed so Node-only tests (and // hypothetical self-host configs that skip the cross-room // index) don't 500. return c.text('OK', 201, { 'Content-Type': TEXT_CT }); } await bulkMirrorRoomsToD1(c.env.DB, parsed.value); return c.text('OK', 201, { 'Content-Type': TEXT_CT }); }); // Client-side chunked snapshot upload. Used for rooms whose // snapshot exceeds CF's ~25 MB request-body limit (the regular // /_migrate/seed path can't fit the whole body in one PUT). Flow: // 1. Migrator PUTs /_migrate/seed/:room with snapshot = "" and // all other fields as usual. DO lands the room with no // snapshot keys. // 2. Migrator PUTs N × /_migrate/snapshot-chunk/:room with query // params `seq=<i>` and `chunks=<N>`. Body is one chunk // (≤100 KiB). DO stores `snapshot:chunk:<padSeq(seq)>` and, // on the final call (seq === chunks-1), writes `snapshot:meta`. // Query-param based so individual chunks don't carry JSON framing // overhead. app.put('/_migrate/snapshot-chunk/:room', async (c) => { const verdict = verifyMigrateToken( c.env.ETHERCALC_MIGRATE_TOKEN, c.req.header('Authorization') ?? null, ); if (verdict.kind === 'disabled') { return c.text('Not Found', 404, { 'Content-Type': TEXT_CT }); } if (verdict.kind === 'missing' || verdict.kind === 'bad') { return c.text('Unauthorized', 401, { 'Content-Type': TEXT_CT }); } const room = c.req.param('room') ?? ''; if (room.length === 0) { return c.text('room segment required', 400, { 'Content-Type': TEXT_CT }); } const seq = Number(c.req.query('seq')); const chunks = Number(c.req.query('chunks')); if (!Number.isInteger(seq) || seq < 0 || !Number.isInteger(chunks) || chunks < 1 || seq >= chunks) { return c.text('seq/chunks must be integers with 0 ≤ seq < chunks', 400, { 'Content-Type': TEXT_CT, }); } const body = await c.req.raw.arrayBuffer(); const res = await doFetch( c.env, room, `/_do/snapshot-chunk?seq=${seq}&chunks=${chunks}`, { method: 'POST', body }, ); const text = await res.text(); return c.text(text, res.status as 201 | 400, { 'Content-Type': TEXT_CT }); }); }