ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
298 lines (279 loc) • 12.1 kB
text/typescript
/**
* 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';
}