From cbf143557126de476d4c6190a16bbf045033f6fc Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Wed, 7 Oct 2026 03:29:47 -0500 Subject: [PATCH] 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) --- webapp/README.md | 17 ++++--- webapp/src/index.ts | 109 ++++++++++++++++++++++++++++++-------------- 2 files changed, 86 insertions(+), 40 deletions(-) diff --git a/webapp/README.md b/webapp/README.md index 0243ea5..8d26f56 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -3,19 +3,21 @@ DO-backed fluent web client factory: site-agnostic. Deploy once; instances are attenuated handles whose **ids are bearer capabilities** — anyone holding the id string can drive the instance; nobody without it can. -TODO: this proxy-page flow (see diigo-dump README) vs running webapp as -the primary worker (`wrangler dev -c ../webapp/wrangler.jsonc -c ../diigo-dump/wrangler.jsonc`) +TODO: this proxy-page flow (see diigo-cache README) vs running webapp as +the primary worker (`wrangler dev -c ../webapp/wrangler.jsonc -c ../diigo-cache/wrangler.jsonc`) so its own port serves the mint form directly; reconsider when the UI grows past a form or two. ## HTTP surface -- `GET /` — the mint form. Prefill via query string: `?url=…&headers=&msPerCall=…`; secret header keys (cookie, authorization, proxy-authorization) render **blank** — paste them at mint time; never echoed back. +- `GET /` — the mint form. Prefill via query string: `?url=…&headers=&msPerCall=…&title=…`; secret header keys (cookie, authorization, proxy-authorization) render **blank** — paste them at mint time; never echoed back. A dedicated `cookie` field is sugar for the `cookie` header (if the JSON also has one, they must agree). +- `GET /form?render=1&…` — the same form for a proxying worker (diigo-cache's `/_connect`): the action is left as an `ACTION` placeholder for the proxy to point at its own relay. - `POST /` — mint. Form-encoded (that page) or JSON `{url, headers, msPerCall}` (service-style). Responds `{id}` (and `x-webapp-id`). The id is *not* logged: possession is authority. - `POST /at/` — drive an instance by capability: JSON `{path?, query?, method?, body?}` → the fluent chain → `{status, text}` (or `{error}`; messages carry no header/param values). ## RPC surface (same-account workers; binding `entrypoint: "WebAppFactory"`) +- `fetch(request)` — the HTTP surface above, over the binding - `newWebApp({url, headers?, msPerCall?})` → `{id, app}` — the fluent stub; promise pipelining makes a whole `pathjoin().query().get()` chain a single round trip @@ -24,8 +26,9 @@ grows past a form or two. ## Run npx wrangler dev # → its own ready line (usually :8787 with the - # two-config diigo-dump run; standalone, :8788) + # two-config diigo-cache run; standalone, :8788) -Status: typechecks; mint + drive not yet exercised end-to-end (needs the -combined dev run; see ../capsite-checklist.md — it is the single -source for run/connect/deploy instructions). +Status: mint + drive exercised end-to-end in the combined dev run +(through diigo-cache's create flow, 2026-10-07); not yet deployed. See +../capsite-checklist.md — the single source for run/connect/deploy +instructions. diff --git a/webapp/src/index.ts b/webapp/src/index.ts index 361115b..ee10b10 100644 --- a/webapp/src/index.ts +++ b/webapp/src/index.ts @@ -211,39 +211,53 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> { } } -export default { - async fetch( - request: Request, - env: { WEBAPP_DO: DurableObjectNamespace }, - ): Promise { - const url = new URL(request.url); +/** 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 mintForm(url.searchParams); - } + // 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); - } + // mint: browser form POST (form-encoded) or JSON (service-style) + if (url.pathname === "/" && request.method === "POST") { + return mint(request, env.WEBAPP_DO); + } - // 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); - } + // 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); + } - return new Response("not found", { status: 404 }); - }, + // 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 }); }; -function mintForm(params: URLSearchParams): Response { +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 { @@ -258,14 +272,18 @@ function mintForm(params: URLSearchParams): Response { } catch { // ignore malformed prefill } + const action = render ? "ACTION" : "/"; const body = ` -webapp: mint an instance +${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

@@ -275,6 +293,17 @@ function mintForm(params: URLSearchParams): Response { 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, @@ -287,11 +316,14 @@ async function mint( init = (await request.json()) as WebAppInit; } else { const form = await request.formData(); - init = { - url: String(form.get("url") ?? ""), - headers: JSON.parse(String(form.get("headers") || "{}")) as Record, - msPerCall: Number(form.get("msPerCall") ?? 0), - }; + 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 }); @@ -374,8 +406,19 @@ function unionQuery(base: string, params: Query): string { * 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. */ + * 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;