Files
captoys/webapp/src/index.ts
T
dckc cbf1435571 webapp: mint form servable by a proxying worker
wrangler's multi-config dev binds aux workers but doesn't serve their
HTTP, so diigo-cache relays webapp's own mint form:

- WebAppFactory gains fetch(): the HTTP surface over the binding
- GET /form?render=1 leaves the form action as a placeholder the proxy
  points at its relay; ?title= sets the page title
- a dedicated cookie field (sugar for the cookie header; must agree
  with the JSON if both are given)

Generated-by: opencode (GLM)
2026-10-07 04:19:54 -05:00

449 lines
16 KiB
TypeScript

/**
* @file DO-backed fluent web client factory: site-agnostic, deploy it once;
* instances are attenuated handles whose ids are the authority.
* @see {@link WebAppFactory}
*/
// DurableObjectNamespace/DurableObjectId/DurableObjectStub are ambient types
// in worker-configuration.d.ts (not exports of "cloudflare:workers")
import { RpcTarget, DurableObject, WorkerEntrypoint } from "cloudflare:workers";
export type Query = Record<string, string | number>;
type Headers = Record<string, string>;
/**
* init for a new instance — fixed at creation. An instance is immutable
* authority: no re-config methods, only attenuation via the fluent chain.
*/
export interface WebAppInit {
url: string;
headers?: Headers;
/** min spacing between outbound calls, ms (0 = unthrottled) */
msPerCall?: number;
}
/** structural type of a stub handle: chain to narrow, then fire */
export interface FluentGet {
pathjoin(ref: string): FluentGet;
query(params: Query): FluentGet;
withHeaders(h: Headers): FluentGet;
withHeader(key: string, value: string): FluentGet;
cookies(cookie: string): FluentGet;
get(): Promise<string>;
post(body: string): Promise<string>;
}
/**
* one attenuated handle to one instance's URL. Extends RpcTarget, so a stub
* of this (returned by DO methods) promise-pipelines: a whole
* `pathjoin().query().get()` chain is a single round trip, with only the
* terminal `get`/`post` doing I/O.
*
* Unions are flower-style, after [Flower, the label-based network backplane
* from the Nuxi/CloudABI world](https://github.com/NuxiNL/flower) (v0.11,
* Jan 2019): label sets may merge only if no shared key carries a
* contradicting value — a later grant cannot quietly contradict an earlier
* one; contradictions throw with the key name and no values.
*
* Error hygiene: Error messages must not contain any header or param values
* — they can cross RPC as exceptions and end up in logs. URLs appear only
* with their query string stripped. In due course: @endo/errors so error
* details can travel off-band from the message.
*
* `#url`/`#headers` are private by policy: on an RpcTarget a public property
* is readable by remote holders (a property read is an argument-less method
* call), and query strings can carry credentials.
*/
export class WebApp extends RpcTarget implements FluentGet {
#url: string;
#headers: Headers;
#send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>;
constructor(
url: string,
headers: Headers,
send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise<string>,
) {
super();
this.#url = url;
this.#headers = headers;
this.#send = send;
}
/** Join a relative path, merging the base's query.
* The new path must stay within the old path (query excluded from the check)
* — the attenuation guard that keeps a remote holder of this stub inside
* the subtree it was granted. */
pathjoin(ref: string): WebApp {
const here = new URL(this.#url);
const there = new URL(ref, here);
const hereNoQuery = new URL(here.href);
hereNoQuery.search = "";
const thereNoQuery = new URL(there.href);
thereNoQuery.search = "";
if (!thereNoQuery.href.startsWith(hereNoQuery.href)) {
throw new Error(`pathjoin: ${ref} not within ${hereNoQuery.href}`);
}
const baseParams: Query = Object.fromEntries(here.searchParams);
return new WebApp(unionQuery(there.href, baseParams), this.#headers, this.#send);
}
/** Union headers, flower-style (see {@link WebApp}): a shared key with a
* different value throws; the same value is fine. */
withHeaders(h: Headers): WebApp {
return new WebApp(this.#url, unionHeaders(this.#headers, h), this.#send);
}
/** one key, flower-style (see {@link WebApp}) */
withHeader(key: string, value: string): WebApp {
return this.withHeaders({ [key]: value });
}
cookies(cookie: string): WebApp {
return this.withHeader("cookie", cookie);
}
/** Union params into the existing query, flower-style (see {@link WebApp}). */
query(params: Query): WebApp {
return new WebApp(unionQuery(this.#url, params), this.#headers, this.#send);
}
async get(): Promise<string> {
return this.#send(this.#url, this.#headers, "GET");
}
async post(body: string): Promise<string> {
return this.#send(this.#url, this.#headers, "POST", body);
}
}
/** of all those waiting on pause, only 1 gets to go next; a DO instance has
* one gate for all its handles */
class Gate {
#step = 0;
#pause?: Promise<void>;
#delay: () => Promise<void>;
constructor(delay: () => Promise<void>) {
this.#delay = delay;
}
async waitMyTurn(): Promise<void> {
let before;
do {
before = this.#step;
if (this.#pause) {
await this.#pause;
}
} while (before !== this.#step);
this.#step += 1;
this.#pause = this.#delay();
}
}
/**
* The DO behind every instance: owns the url/headers and the outbound fetch
* authority; every stub call lands here. Caller params cannot ride in
* through the constructor — the runtime fills `(ctx, env)` with bindings
* only — so init arrives via the one-shot `configure()` and persists in DO
* storage, surviving eviction (instance memory evaporates when the DO naps;
* storage is where "this instance means this URL" durably lives).
*
* @see https://developers.cloudflare.com/durable-objects/ — newUniqueId is
* unforgeable (128-bit, inderivable); idFromName by contrast is ambient:
* any worker with the namespace binding can recompute it
*/
export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
#ready?: Promise<void>;
/** one-shot configuration; a different-url re-configure is refused */
async configure(init: WebAppInit): Promise<void> {
if (this.#ready) {
// racing calls join the first configure
await this.#ready;
return;
}
this.#ready = (async () => {
new URL(init.url); // throws on garbage
const existing = await this.ctx.storage.get<WebAppInit>("init");
if (existing) {
if (existing.url !== init.url) {
throw new Error("already configured with a different url");
}
return; // idempotent for an identical re-configure
}
await this.ctx.storage.put("init", init);
})();
await this.#ready;
}
/** load init from storage; rejects while unconfigured */
async #load(): Promise<WebAppInit> {
const init = await this.ctx.storage.get<WebAppInit>("init");
if (!init) {
throw new Error("not configured: this id was never set up");
}
return init;
}
/** the fluent stub handed to callers, wired to a fresh gate per
* activation (handles within one activation share it) */
async handle(): Promise<FluentGet> {
const init = await this.#load();
const gate = new Gate(() => sleep(init.msPerCall ?? 0));
const send = async (
url: string,
headers: Headers,
method: "GET" | "POST",
body?: string,
) => {
await gate.waitMyTurn();
const res = await fetch(url, { method, headers, body });
const noQuery = new URL(url);
noQuery.search = "";
if (!res.ok) {
// query stripped: param values must not appear in Error messages
throw new Error(`${res.status} ${res.statusText} on ${noQuery.href}`);
}
return res.text();
};
return new WebApp(init.url, init.headers ?? {}, send);
}
}
/** the worker's whole HTTP surface (the default export and the
* WebAppFactory entrypoint share it) */
const defaultFetch = async (
request: Request,
env: { WEBAPP_DO: DurableObjectNamespace<WebAppDo> },
): Promise<Response> => {
const url = new URL(request.url);
// mint form: site-agnostic; secret headers (cookie, authorization) are
// not prefilled from the query string — the form leaves them blank
if (url.pathname === "/" && request.method === "GET") {
return mintFormPage(url.searchParams);
}
// mint: browser form POST (form-encoded) or JSON (service-style)
if (url.pathname === "/" && request.method === "POST") {
return mint(request, env.WEBAPP_DO);
}
// the mint form, renderable for a proxying worker: ?render=1 keeps the
// HTML identical but leaves the form's action at a relative `ACTION`
// placeholder — the proxying worker substitutes its own relay path.
// (Secret header keys still render blank.)
if (url.pathname === "/form" && request.method === "GET") {
return mintFormPage(url.searchParams);
}
// the id is the capability: dereference it over plain HTTP.
// POST /at/<id> with JSON {path?, query?, method?, body?}
const at = url.pathname.match(/^\/at\/([A-Za-z0-9-]+)$/);
if (at && request.method === "POST") {
const app = env.WEBAPP_DO.get(env.WEBAPP_DO.idFromString(at[1])).handle();
return drive(await app, request);
}
return new Response("not found", { status: 404 });
};
export default { fetch: defaultFetch };
/** GET / mint form, or (via /form) the same HTML with the action left as
* a placeholder for a proxying worker to substitute */
function mintFormPage(params: URLSearchParams): Response {
const render = params.get("render") === "1";
const url = params.get("url") ?? "";
const msPerCall = params.get("msPerCall") ?? "500";
const title = params.get("title") ?? "webapp: mint an instance";
// headers come prefilled as JSON; secret keys are blanked for the form
let headersJson = "";
try {
const given = JSON.parse(params.get("headers") ?? "{}") as Record<string, string>;
headersJson = JSON.stringify(
Object.fromEntries(
Object.entries(given).map(([k, v]) =>
SECRET_KEYS.has(k.toLowerCase()) ? [k, ""] : [k, v],
),
),
);
} catch {
// ignore malformed prefill
}
const action = render ? "ACTION" : "/";
const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>${esc(title)}</title></head>
<body>
<h1>mint a webapp instance</h1>
${render ? "<p>served by the referring worker; fields are webapp's init params (the action points back there)</p>" : ""}
<form method="post" action="${action}">
<p><label>base URL <input name="url" size="60" value="${esc(url)}"></label></p>
<p><label>headers (JSON; secret values stay server-side, leave blank to keep blank):
<textarea name="headers" rows="6" cols="60">${esc(headersJson)}</textarea></label></p>
<p><label>cookie <input type="password" name="cookie" size="80" autocomplete="off"></label>
<em>— the common secret header, sugar for adding <code>"cookie"</code> to the JSON yourself; if both are set they must agree</em></p>
<p><label>min ms between calls <input name="msPerCall" type="number" value="${esc(msPerCall)}"></label></p>
<p><button type="submit">mint</button></p>
</form>
</body></html>`;
return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } });
}
const SECRET_KEYS = new Set(["cookie", "authorization", "proxy-authorization"]);
/** merge the form's dedicated `cookie` field into the headers (the sugar
* input; if the JSON also carries `cookie`, both values must agree) */
function withCookie(init: WebAppInit, cookie: string): WebAppInit {
if (!cookie) return init;
if (init.headers?.cookie && init.headers.cookie !== cookie) {
// key name only: values must not appear in error messages
throw new Error("cookie: contradicts the JSON headers");
}
return { ...init, headers: { ...(init.headers ?? {}), cookie } };
}
/** mint a fresh instance from form-encoded or JSON input */
async function mint(
request: Request,
ns: DurableObjectNamespace<WebAppDo>,
): Promise<Response> {
const ct = request.headers.get("content-type") ?? "";
let init: WebAppInit;
try {
if (ct.includes("application/json")) {
init = (await request.json()) as WebAppInit;
} else {
const form = await request.formData();
init = withCookie(
{
url: String(form.get("url") ?? ""),
headers: JSON.parse(String(form.get("headers") || "{}")) as Record<string, string>,
msPerCall: Number(form.get("msPerCall") ?? 0),
},
String(form.get("cookie") || ""),
);
}
} catch (err) {
return Response.json({ error: `bad input: ${String(err)}` }, { status: 400 });
}
try {
new URL(init.url);
} catch {
return Response.json({ error: "bad url" }, { status: 400 });
}
const id = ns.newUniqueId();
const instance = ns.get(id);
await instance.configure(init);
const app = await instance.handle();
// note: id intentionally NOT logged — it is a bearer capability
console.log({ event: "mint", msPerCall: init.msPerCall });
return Response.json({ id: id.toString() }, { headers: { "x-webapp-id": id.toString() } });
}
/** drive a handle from the /at/<id> JSON protocol (also used by the
* /at playground form) */
async function drive(app: FluentGet, request: Request): Promise<Response> {
let call: { path?: string; query?: Query; method?: "GET" | "POST"; body?: string };
try {
call = (await request.json()) as typeof call;
} catch (err) {
return Response.json({ error: `bad input: ${String(err)}` }, { status: 400 });
}
try {
let handle = app;
if (call.path) handle = handle.pathjoin(call.path);
if (call.query) handle = handle.query(call.query);
const text =
call.method === "POST"
? await handle.post(call.body ?? "")
: await handle.get();
return Response.json({ status: 200, text });
} catch (err) {
// error messages are already value-free (see WebApp docs)
return Response.json({ error: String(err) }, { status: 200 });
}
}
function unionHeaders(base: Headers, more: Headers): Headers {
const merged = { ...base };
for (const [key, value] of Object.entries(more)) {
if (key in merged && merged[key] !== value) {
// key name only: header values must not appear in Error messages
throw new Error(`headers: contradicts on ${key}`);
}
merged[key] = value;
}
return merged;
}
function unionQuery(base: string, params: Query): string {
const there = new URL(base);
for (const [key, value] of Object.entries(params)) {
const v = `${value}`;
if (there.searchParams.has(key) && there.searchParams.get(key) !== v) {
// key name only: param values must not appear in Error messages
throw new Error(`query: contradicts on ${key}`);
}
there.searchParams.set(key, v);
}
return there.href;
}
/**
* the RPC service of the webapp worker. Flow:
*
* const { id, app } = await env.WEBAPP.newWebApp({
* url: "https://api.github.com", headers: { authorization: `Bearer ${t}` },
* msPerCall: 250,
* });
* // hold id (persist it, hand it to workers you trust), then e.g.:
* const text = await app.query({ per_page: 5 }).get();
* // later, from another worker (same account) holding the id:
* const app2 = env.WEBAPP.at(id);
*/
export class WebAppFactory extends WorkerEntrypoint {
/** the same HTTP surface as the default export, over the entrypoint
* (for service-binding callers that proxy pages, e.g. diigo-cache's
* /_connect → this form) */
async fetch(request: Request): Promise<Response> {
const env = this.env as { WEBAPP_DO: DurableObjectNamespace<WebAppDo> };
return defaultFetch(request, env);
}
/** mint a fresh instance: unforgeable uniqueId + configured fluent stub.
* The caller decides who else ever learns the id. (If the caller only
* wanted the id, it must dispose() the app stub — workerd warns about
* undisposed stubs; see the `at()` alternative + `/at/<id>` route for
* id-only flows.) */
async newWebApp(init: WebAppInit): Promise<{ id: string; app: FluentGet }> {
new URL(init.url); // fail before minting on garbage
const ns = this.env.WEBAPP_DO;
const id = ns.newUniqueId();
const instance = ns.get(id);
await instance.configure(init);
const app = await instance.handle();
return { id: id.toString(), app };
}
/** reconnect by a previously-obtained id */
async at(id: string): Promise<FluentGet> {
const ns = this.env.WEBAPP_DO;
return ns.get(ns.idFromString(id)).handle();
}
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
function esc(s: string): string {
return s
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}