/** * @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; type Headers = Record; /** * 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; post(body: string): Promise; } /** * 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; constructor( url: string, headers: Headers, send: (url: string, headers: Headers, method: "GET" | "POST", body?: string) => Promise, ) { 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 { return this.#send(this.#url, this.#headers, "GET"); } async post(body: string): Promise { 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; #delay: () => Promise; constructor(delay: () => Promise) { this.#delay = delay; } async waitMyTurn(): Promise { 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; /** one-shot configuration; a different-url re-configure is refused */ async configure(init: WebAppInit): Promise { 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("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 { const init = await this.ctx.storage.get("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 { 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 }, ): Promise => { 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/ 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; 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 = ` ${esc(title)}

mint a webapp instance

${render ? "

served by the referring worker; fields are webapp's init params (the action points back there)

" : ""}

— the common secret header, sugar for adding "cookie" to the JSON yourself; if both are set they must agree

`; 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, ): Promise { 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, 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/ JSON protocol (also used by the * /at playground form) */ async function drive(app: FluentGet, request: Request): Promise { 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 { const env = this.env as { WEBAPP_DO: DurableObjectNamespace }; 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/` 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 { const ns = this.env.WEBAPP_DO; return ns.get(ns.idFromString(id)).handle(); } } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } function esc(s: string): string { return s .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); }