UNPKG

ethercalc

Version:

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

298 lines (279 loc) 12.1 kB
/** * Workers Assets integration (Phase 4.1 / 11-assets). * * Wires the curated `assets/` directory (produced by * `scripts/build-assets.ts`) onto the Hono router. The directory is * declared in `wrangler.toml` under `[assets] directory = "../../assets"`, * which produces an `env.ASSETS: Fetcher` binding at runtime. * * Behavior: * - When `env.ASSETS` is bound, requests are proxied through to it via * `env.ASSETS.fetch(request)` using a synthesized URL. The Workers * Assets binding resolves by pathname alone. * - When the binding is unbound (unit tests that import the router * without a Miniflare env), routes return 404 with * `Content-Type: text/plain; charset=utf-8`. Keeps oracle-replay * Phase 4 expectations intact. * * Dynamic endpoints that can't be served as static files: * - `GET /manifest.appcache` — DevMode returns a dynamic stub * (§7 item 29). Production serves the file from ASSETS. * - `GET /static/form:part.js` — literal-colon param route (§7 item 26). * Translates to the repo-rooted `form<part>.js`. Delegates to ASSETS. * - `GET /:room` entry page — redirects or serves `index.html` / * `multi/index.html` depending on the `=` prefix and KEY state. * - `GET /:template/form` — duplicates a template into a fresh room; * requires Phase 5 room CRUD (currently stubs 503). * - `GET /:template/appeditor` — serves `panels.html` from ASSETS. * * This file is excluded from coverage for the same reason as * `src/index.ts` — exercised via the workerd integration pool which * istanbul can't instrument (AGENTS.md §5.2). The pure-logic builders * (`manifest-appcache.ts`, `static-form.ts`, `room-entry.ts`) live in * `../handlers/` and carry 100% coverage. */ /* istanbul ignore file */ import type { Hono } from 'hono'; import { buildDynamicAppcache, APPCACHE_CONTENT_TYPE, } from '../handlers/manifest-appcache.ts'; import { buildFormPartPath } from '../handlers/static-form.ts'; import { buildRoomEntry, buildTemplateFormRedirect, } from '../handlers/room-entry.ts'; import { doFetch } from '../lib/do-dispatch.ts'; import { encodeRoom, generateRoomId } from '../lib/room-name.ts'; import type { Env } from '../env.ts'; /** * Extension → MIME map used when the upstream Fetcher doesn't set a * useful `Content-Type`. Cloudflare's Workers Assets binding always * sets the right type, but the standalone-workerd DiskDirectory we * ship to Sandstorm returns `application/octet-stream` for every file * (browsers treat that as a download). Patch it on the way through. */ const MIME_BY_EXT: Readonly<Record<string, string>> = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.map': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.ico': 'image/vnd.microsoft.icon', '.webmanifest': 'application/manifest+json', '.appcache': 'text/cache-manifest', '.xml': 'application/xml; charset=utf-8', '.txt': 'text/plain; charset=utf-8', '.woff': 'font/woff', '.woff2': 'font/woff2', }; function mimeForPath(pathname: string): string | undefined { const dot = pathname.lastIndexOf('.'); if (dot < 0) return undefined; const ext = pathname.slice(dot).toLowerCase(); return MIME_BY_EXT[ext]; } /** * Proxy a request into the `ASSETS` binding. When the binding is absent * (unit tests importing the router without Miniflare), returns a 404 so * callers fail soft. * * We copy the upstream response body into a new Response so we can set * a sensible `Content-Type` when the binding doesn't already provide * one. Cloudflare's production Assets binding sets types correctly; * only the standalone workerd `DiskDirectory` path (used by Sandstorm * self-host) needs this fix-up. */ export async function serveAsset(env: Env, pathname: string): Promise<Response> { if (!env.ASSETS) { return new Response('Not Found', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } const req = new Request(`https://assets.local${pathname}`, { method: 'GET' }); const upstream = await env.ASSETS.fetch(req); const ct = upstream.headers.get('Content-Type'); // Only rewrite when the upstream is the default opaque binary type or // missing — leave legit responses (text/html from CF Assets, errors // with text/plain, etc.) untouched. if (ct && !/^application\/octet-stream/i.test(ct)) return upstream; const sniffed = mimeForPath(pathname); if (sniffed === undefined) return upstream; const headers = new Headers(upstream.headers); headers.set('Content-Type', sniffed); return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers, }); } /** * Register asset-backed routes. See the file header for the inventory. */ export function registerAssets(app: Hono<{ Bindings: Env }>): void { // Root entry page. Legacy served `index.html` at `/`. We forward to // ASSETS which picks it up from the curated dir. // // Single-grain deployments (notably Sandstorm, where a grain IS a // single spreadsheet) set `ETHERCALC_DEFAULT_ROOM=sheet1` so `/` // 302-redirects into the live room instead of the "create new sheet" // landing page. Without the env var, the legacy behavior (landing // page) is preserved — this is what ethercalc.net serves. app.get('/', async (c) => { const defaultRoom = c.env.ETHERCALC_DEFAULT_ROOM; // Truthiness, not `!== undefined`: workerd delivers an unset // `fromEnvironment` binding as `null`, which would otherwise // redirect every `docker compose up` visitor to `/null`. if (defaultRoom) { const basepath = c.env.BASEPATH ?? ''; return new Response('', { status: 302, headers: { Location: `${basepath}/${defaultRoom}` }, }); } return serveAsset(c.env, '/index.html'); }); // Secondary landing page used by the home-screen link. app.get('/_start', async (c) => serveAsset(c.env, '/start.html')); // Icon / PWA manifest family. Each path maps 1:1 to the same file in // `assets/`. Listed explicitly so the Hono router short-circuits before // `/:room` entry matching. for (const path of [ '/favicon.ico', '/favicon-16x16.png', '/favicon-32x32.png', '/apple-touch-icon.png', '/android-chrome-192x192.png', '/mstile-150x150.png', '/mstile-310x310.png', '/safari-pinned-tab.svg', '/browserconfig.xml', '/manifest.json', ]) { app.get(path, async (c) => serveAsset(c.env, path)); } // `manifest.appcache`. DevMode returns a dynamic stub with a fresh // timestamp (forces reload each hit); prod serves the static copy. app.get('/manifest.appcache', async (c) => { const dev = isDevMode(c.env); if (dev) { const body = buildDynamicAppcache({ now: Date.now() }); return new Response(body, { status: 200, headers: { 'Content-Type': APPCACHE_CONTENT_TYPE }, }); } return serveAsset(c.env, '/manifest.appcache'); }); // `/l10n/:lang.json` — curated per-locale bundles. Explicitly registered // so Hono doesn't treat the path segment as the `/:room` entry page. // Any lang file missing from `assets/l10n/` yields a 404 from ASSETS. app.get('/l10n/:lang{.+\\.json}', async (c) => { const lang = c.req.param('lang'); return serveAsset(c.env, `/l10n/${lang}`); }); // Legacy SocialCalc chrome artwork. app.get('/images/*', async (c) => serveAsset(c.env, c.req.path)); // `/static/socialcalc.js` — vendored SocialCalc 2.3.0 UMD (§13 Q8). app.get('/static/socialcalc.js', async (c) => serveAsset(c.env, '/static/socialcalc.js')); // `/static/player.js` — built single-sheet client bundle from // `packages/client/dist/player.js`. app.get('/static/player.js', async (c) => serveAsset(c.env, '/static/player.js')); // `/static/form<part>.js` — literal-colon segment route (§7 item 26). // Hono's trie splits on `/`, so `form:part.js` (the legacy syntax from // zappa/express) isn't recognized as a param; we instead register a // constrained segment `:file{form.+\.js}` that captures the whole // filename and then peel off the `form`/`.js` wrappers ourselves. // `buildFormPartPath` does the extraction + rebuild. app.get('/static/:file{form.+\\.js}', async (c) => { const file = c.req.param('file'); const part = file.slice('form'.length, -'.js'.length); const target = buildFormPartPath(part); return serveAsset(c.env, target); }); // Catch-all for any other static files (like start.css, jszip.js, etc.) app.get('/static/*', async (c) => serveAsset(c.env, c.req.path)); // `/:template/appeditor` — Phase 4.1 panels.html route (§6.1). Ordering: // this has a literal `/appeditor` suffix so it can safely register // alongside the static family above without shadowing. Hono picks the // longer-literal-path match first. app.get('/:template/appeditor', async (c) => serveAsset(c.env, '/panels.html')); // `/:template/form` — duplicate template snapshot into `<template>_<id>` // and redirect to `/app` (legacy main.ls:287-293). app.get('/:template/form', async (c) => { const template = c.req.param('template') ?? ''; const newId = generateRoomId(); const newRoom = `${encodeRoom(template)}_${newId}`; try { await doFetch(c.env, template, '/_do/clone', { method: 'POST', body: JSON.stringify({ to: newRoom }), headers: { 'Content-Type': 'application/json' }, }); } catch { // Legacy always redirected; clone failure yields an empty new room. } const result = buildTemplateFormRedirect({ template, basepath: c.env.BASEPATH ?? '', idGen: () => newId, phase5Ready: true, }); return new Response(result.body, { status: 302, headers: { ...result.headers }, }); }); } /** * Register the `/:room` entry-page handler. MUST be called AFTER every * other GET route registration so the trie matches static prefixes and * more-specific paths first. See `buildApp` in `../index.ts`. * * This is split out (rather than living inside `registerAssets`) so a * future `registerRoomCrud` from Phase 5 can cleanly slot between the * assets routes and the catch-all: extending `registerAssets` to also * register `/:room` would force Phase 5 to either register its `/_rooms` * etc. before `registerAssets` or run a custom re-ordering step. */ export function registerRoomCatchAll(app: Hono<{ Bindings: Env }>): void { app.get('/:room', async (c) => { const roomParam = c.req.param('room') ?? ''; const authQuery = c.req.query('auth'); const key = c.env.ETHERCALC_KEY; const opts = { basepath: c.env.BASEPATH ?? '', room: roomParam, ...(authQuery !== undefined ? { authQuery } : {}), ...(key !== undefined ? { key } : {}), }; const decision = buildRoomEntry(opts); if (decision.kind === 'redirect') { const body = decision.body; return new Response(body, { status: 302, headers: { ...decision.headers }, }); } // kind === 'serve' — hand off to the ASSETS binding for index.html // (or multi/index.html when the room is prefixed with `=`). return serveAsset(c.env, decision.path); }); } /** * DevMode resolution. Legacy `src/main.ls` checks `fs.existsSync('.git')` * at the repo root to decide. We can't reproduce that in workerd, so we * expose a simple env flag: when `DEVMODE=1` is set, return the dynamic * stub. Defaults off in production. In local `wrangler dev` the flag can * be set via `wrangler.toml`'s `[vars]` or `--var DEVMODE=1`. */ function isDevMode(env: Env): boolean { return env.DEVMODE === '1' || env.DEVMODE === 'true'; }