/** * 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.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 { getSessionPrincipal } from "../lib/session-middleware.ts"; import { encodeRoom, generateRoomId } from "../lib/room-name.ts"; import { isSandstormEnforced } from "../lib/sandstorm-access.ts"; import type { Env, EtherCalcHonoEnv } 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> = { ".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", ".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 { let decodedPath = pathname; try { // Decode repeatedly because edge and binding path normalization need not // happen at the same layer. Conservatively reject encoded source maps. for (let i = 0; i < 3; i += 1) { const next = decodeURIComponent(decodedPath); if (next === decodedPath) break; decodedPath = next; } } catch { return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } if (decodedPath.toLowerCase().endsWith(".map")) { return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain; charset=utf-8" }, }); } 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, }); } type WorkbookKind = "absent" | "multi" | "single" | "unknown"; async function getWorkbookKind(env: Env, room: string): Promise { try { const response = await doFetch(env, room, "/_do/workbook-kind"); if (!response.ok) return "unknown"; const body = (await response.json()) as { readonly kind?: unknown }; if (body.kind === "absent" || body.kind === "multi" || body.kind === "single") { return body.kind; } } catch { // A failed classifier must not break the deployment root. } return "unknown"; } /** * Register asset-backed routes. See the file header for the inventory. */ export function registerAssets(app: Hono): 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) set a default workbook // room so `/` opens it directly. A legacy multi-sheet migration stores a // TOC in that room, while an existing ordinary room must keep the classic // single-sheet client. Classify only this root entry; the hot `GET /:room` // path remains storage-free. Without the env var, ethercalc.net serves the // landing page. 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 ?? ""; const explicitlyMulti = defaultRoom.startsWith("="); const room = explicitlyMulti ? defaultRoom.slice(1) : defaultRoom; let targetRoom = room; let multi = explicitlyMulti; if (!multi) { const kind = await getWorkbookKind(c.env, room); multi = kind === "absent" || kind === "multi"; // Rewrite releases before #838 stored new Sandstorm grains in // `sheet1`. If the restored `sheet` default is absent, keep opening // that existing room so an upgrade never hides user data. if (kind === "absent" && isSandstormEnforced(c.env)) { const fallbackRoom = `${room}1`; const fallbackKind = await getWorkbookKind(c.env, fallbackRoom); if (fallbackKind === "single" || fallbackKind === "multi") { targetRoom = fallbackRoom; multi = fallbackKind === "multi"; } } } return new Response("", { status: 302, headers: { Location: `${basepath}/${multi ? "=" : ""}${targetRoom}` }, }); } 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)); // Vite builds the React workbook shell under `/multi/`; its index is // served at `/=:room`, but its absolute JS/CSS/font URLs retain this // prefix. Cloudflare Assets handles these before the Worker in hosted // deployments. Standalone workerd sends every request through Hono, so // proxy the bundle explicitly for Docker and Sandstorm. app.get("/multi/*", 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.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 `