ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
206 lines (195 loc) • 7.36 kB
text/typescript
/**
* `GET /:room`, `GET /:template/form`, `GET /:template/appeditor` —
* Phase 4.1 entry routes.
*
* Legacy (src/main.ls:277-294):
*
* ```livescript
* @get "#BASEPATH/:room": ->
* room = encodeURI(@params.room)
* ui-file = if room is /^=/ then \multi/index.html else \index.html
* if KEY then
* if @query.auth?length
* sendFile(ui-file).call @
* else @response.redirect "#BASEPATH/#{ @params.room }?auth=0"
* else sendFile(ui-file).call @
*
* @get "#BASEPATH/:template/form": ->
* template = encodeURI(@params.template)
* room = template + \_ + new-room!
* delete SC[room]
* {snapshot} <~ SC._get template, IO
* <~ SC._put room, snapshot
* @response.redirect "#BASEPATH/#room/app"
*
* @get "#BASEPATH/:template/appeditor": sendFile \panels.html
* ```
*
* This module owns the two pure-logic parts:
*
* 1. `buildRoomEntry` — decides "serve `/index.html`", "serve
* `/multi/index.html`", or "302 to `?auth=0`" based on the KEY /
* `?auth` query state.
*
* 2. `buildTemplateFormRedirect` — produces the shape legacy emits
* when `/some-template/form` is hit. Full implementation depends
* on Phase 5 Room CRUD — specifically, cloning the template's
* snapshot into `<template>_<newid>`. Since that lands in a
* parallel agent, we STUB for now: return a 503 with a body
* describing the deferral. When Phase 5 wires the DO-to-DO fetch,
* this function's stubbed branch swaps to produce a 302.
*
* `GET /:template/appeditor` is just a static `panels.html` serve — no
* pure logic needed here; the route layer hands off to ASSETS directly.
*
* Encoding note: legacy applies `encodeURI(@params.room)` everywhere.
* Hono decodes the param before we see it, so we re-encode via
* `encodeRoom` to keep storage keys byte-identical with the oracle.
*/
import { encodeRoom, generateRoomId } from '../lib/room-name.ts';
/** Marker statuses for the template-form stub. Exported so the route
* layer can read `TEMPLATE_FORM_STUB_STATUS` as its `init.status`.
*/
export const TEMPLATE_FORM_STUB_STATUS = 503;
export interface BuildRoomEntryOpts {
readonly basepath?: string;
readonly room: string;
/** The `?auth=` query value, undefined if absent. Empty string = present but empty. */
readonly authQuery?: string | undefined;
/** `ETHERCALC_KEY`. When unset, we serve the index page regardless of `authQuery`. */
readonly key?: string;
}
/** The decision carried back from `buildRoomEntry`. */
export type RoomEntryDecision =
| {
readonly kind: 'redirect';
readonly status: 302;
readonly body: string;
readonly headers: {
readonly Location: string;
readonly 'Content-Type': string;
readonly 'Content-Length': string;
readonly Vary: string;
};
}
| {
readonly kind: 'serve';
/** The asset path to hand off to `env.ASSETS.fetch` — `/index.html` or `/multi/index.html`. */
readonly path: string;
};
/**
* Decide how to handle a `GET /:room` request. See file header for the
* legacy semantics. Returns a discriminated union so the route layer
* can either return a 302 or forward to `env.ASSETS.fetch(path)`.
*
* Defense-in-depth: `:room` path params that collide with the reserved
* prefixes (`_rooms`, `_exists`, `_from`, etc.) would already be
* routed to those specific handlers by Hono's trie (static wins over
* `/:room`). We do NOT re-check that here — double-guarding would just
* diverge from Hono's routing table and create subtle bugs if a new
* reserved prefix lands. Ordering is enforced at registration time in
* `src/routes/assets.ts`.
*/
export function buildRoomEntry(opts: BuildRoomEntryOpts): RoomEntryDecision {
const basepath = opts.basepath ?? '';
const encoded = encodeRoom(opts.room);
const isMulti = encoded.startsWith('=');
const path = isMulti ? '/multi/index.html' : '/index.html';
// KEY unset → always serve the index page.
if (!opts.key) {
return { kind: 'serve', path };
}
// KEY set and a non-empty `?auth=…` value was supplied → serve index.
// The auth value itself is validated later on the WS connect path;
// legacy doesn't gate the page serve on HMAC match (§6.4).
if (opts.authQuery !== undefined && opts.authQuery.length > 0) {
return { kind: 'serve', path };
}
// KEY set, no `?auth` → 302 to the view-only sentinel. Express's
// redirect body shape is `Found. Redirecting to <url>` with a
// `text/plain; charset=UTF-8` body; we reproduce it so oracle replays
// stay byte-aligned with the rest of the redirect family.
const location = `${basepath}/${encoded}?auth=0`;
const body = `Found. Redirecting to ${location}`;
return {
kind: 'redirect',
status: 302,
body,
headers: {
Location: location,
'Content-Type': 'text/plain; charset=UTF-8',
'Content-Length': String(body.length),
Vary: 'Accept',
},
};
}
export interface BuildTemplateFormRedirectOpts {
readonly basepath?: string;
readonly template: string;
/** Injectable for deterministic tests. Defaults to `generateRoomId`. */
readonly idGen?: () => string;
/**
* Signals whether Phase 5 Room CRUD is ready. When `false` (default),
* we return the stub. When `true`, we produce the redirect — the
* route layer is expected to have already cloned the snapshot via
* `env.ROOM.get(…).fetch('/_do/clone')` before calling this builder.
*/
readonly phase5Ready?: boolean;
}
export type TemplateFormResult =
| {
readonly status: 302;
readonly body: string;
readonly headers: Readonly<Record<string, string>>;
}
| {
readonly status: typeof TEMPLATE_FORM_STUB_STATUS;
readonly body: string;
readonly headers: Readonly<Record<string, string>>;
};
/**
* Legacy-shape redirect for `/:template/form` → 302 `/<template>_<uuid>/app`.
* The new room id is the template name plus an underscore plus a fresh
* 12-char id — preserving the legacy `_formdata` naming convention used
* by the `submitform` WS command (see §7 item 22).
*
* Behavior controlled by `phase5Ready`:
* - unset / false → 503 stub explaining the dependency
* - true → 302 redirect with Express-style body
*/
export function buildTemplateFormRedirect(
opts: BuildTemplateFormRedirectOpts,
): TemplateFormResult {
const basepath = opts.basepath ?? '';
const template = encodeRoom(opts.template);
const id = (opts.idGen ?? generateRoomId)();
const newRoom = `${template}_${id}`;
if (!opts.phase5Ready) {
const body =
'Template duplication is not yet available — Phase 5 (Room CRUD) ' +
'has not landed. Once DO-to-DO fetches are wired, this endpoint ' +
'will 302 to /' +
newRoom +
'/app.';
return {
status: TEMPLATE_FORM_STUB_STATUS,
body,
headers: {
'Content-Type': 'text/plain; charset=UTF-8',
'Content-Length': String(body.length),
},
};
}
const location = `${basepath}/${newRoom}/app`;
const body = `Found. Redirecting to ${location}`;
return {
status: 302,
body,
headers: {
Location: location,
'Content-Type': 'text/plain; charset=UTF-8',
'Content-Length': String(body.length),
Vary: 'Accept',
},
};
}