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)
This commit is contained in:
dckc committed 2026-10-07 04:19:54 -05:00
1 parent 58be2ccaf8
commit cbf1435571
2 files changed
+64 -18

No files matched your search

+10 -7
View File
@@ -3,19 +3,21 @@
DO-backed fluent web client factory: site-agnostic. Deploy once; instances DO-backed fluent web client factory: site-agnostic. Deploy once; instances
are attenuated handles whose **ids are bearer capabilities** — anyone are attenuated handles whose **ids are bearer capabilities** — anyone
holding the id string can drive the instance; nobody without it can. 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 TODO: this proxy-page flow (see diigo-cache README) vs running webapp as
the primary worker (`wrangler dev -c ../webapp/wrangler.jsonc -c ../diigo-dump/wrangler.jsonc`) 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 so its own port serves the mint form directly; reconsider when the UI
grows past a form or two. grows past a form or two.
## HTTP surface ## HTTP surface
- `GET /` — the mint form. Prefill via query string: `?url=…&headers=<urlencoded JSON>&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=<urlencoded JSON>&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 /` — 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/<id>` — drive an instance by capability: JSON `{path?, query?, method?, body?}` → the fluent chain → `{status, text}` (or `{error}`; messages carry no header/param values). - `POST /at/<id>` — 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"`) ## 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; - `newWebApp({url, headers?, msPerCall?})` → `{id, app}` — the fluent stub;
promise pipelining makes a whole `pathjoin().query().get()` chain a promise pipelining makes a whole `pathjoin().query().get()` chain a
single round trip single round trip
@@ -24,8 +26,9 @@ grows past a form or two.
## Run ## Run
npx wrangler dev # → its own ready line (usually :8787 with the 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 Status: mint + drive exercised end-to-end in the combined dev run
combined dev run; see ../capsite-checklist.md — it is the single (through diigo-cache's create flow, 2026-10-07); not yet deployed. See
source for run/connect/deploy instructions). ../capsite-checklist.md — the single source for run/connect/deploy
instructions.
+54 -11
View File
@@ -211,17 +211,18 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
} }
} }
export default { /** the worker's whole HTTP surface (the default export and the
async fetch( * WebAppFactory entrypoint share it) */
const defaultFetch = async (
request: Request, request: Request,
env: { WEBAPP_DO: DurableObjectNamespace<WebAppDo> }, env: { WEBAPP_DO: DurableObjectNamespace<WebAppDo> },
): Promise<Response> { ): Promise<Response> => {
const url = new URL(request.url); const url = new URL(request.url);
// mint form: site-agnostic; secret headers (cookie, authorization) are // mint form: site-agnostic; secret headers (cookie, authorization) are
// not prefilled from the query string — the form leaves them blank // not prefilled from the query string — the form leaves them blank
if (url.pathname === "/" && request.method === "GET") { if (url.pathname === "/" && request.method === "GET") {
return mintForm(url.searchParams); return mintFormPage(url.searchParams);
} }
// mint: browser form POST (form-encoded) or JSON (service-style) // mint: browser form POST (form-encoded) or JSON (service-style)
@@ -229,6 +230,14 @@ export default {
return mint(request, env.WEBAPP_DO); 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. // the id is the capability: dereference it over plain HTTP.
// POST /at/<id> with JSON {path?, query?, method?, body?} // POST /at/<id> with JSON {path?, query?, method?, body?}
const at = url.pathname.match(/^\/at\/([A-Za-z0-9-]+)$/); const at = url.pathname.match(/^\/at\/([A-Za-z0-9-]+)$/);
@@ -238,12 +247,17 @@ export default {
} }
return new Response("not found", { status: 404 }); 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 url = params.get("url") ?? "";
const msPerCall = params.get("msPerCall") ?? "500"; 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 // headers come prefilled as JSON; secret keys are blanked for the form
let headersJson = ""; let headersJson = "";
try { try {
@@ -258,14 +272,18 @@ function mintForm(params: URLSearchParams): Response {
} catch { } catch {
// ignore malformed prefill // ignore malformed prefill
} }
const action = render ? "ACTION" : "/";
const body = `<!doctype html> const body = `<!doctype html>
<html><head><meta charset="utf-8"><title>webapp: mint an instance</title></head> <html><head><meta charset="utf-8"><title>${esc(title)}</title></head>
<body> <body>
<h1>mint a webapp instance</h1> <h1>mint a webapp instance</h1>
<form method="post" action="/"> ${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>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): <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> <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><label>min ms between calls <input name="msPerCall" type="number" value="${esc(msPerCall)}"></label></p>
<p><button type="submit">mint</button></p> <p><button type="submit">mint</button></p>
</form> </form>
@@ -275,6 +293,17 @@ function mintForm(params: URLSearchParams): Response {
const SECRET_KEYS = new Set(["cookie", "authorization", "proxy-authorization"]); 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 */ /** mint a fresh instance from form-encoded or JSON input */
async function mint( async function mint(
request: Request, request: Request,
@@ -287,11 +316,14 @@ async function mint(
init = (await request.json()) as WebAppInit; init = (await request.json()) as WebAppInit;
} else { } else {
const form = await request.formData(); const form = await request.formData();
init = { init = withCookie(
{
url: String(form.get("url") ?? ""), url: String(form.get("url") ?? ""),
headers: JSON.parse(String(form.get("headers") || "{}")) as Record<string, string>, headers: JSON.parse(String(form.get("headers") || "{}")) as Record<string, string>,
msPerCall: Number(form.get("msPerCall") ?? 0), msPerCall: Number(form.get("msPerCall") ?? 0),
}; },
String(form.get("cookie") || ""),
);
} }
} catch (err) { } catch (err) {
return Response.json({ error: `bad input: ${String(err)}` }, { status: 400 }); 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); * const app2 = env.WEBAPP.at(id);
*/ */
export class WebAppFactory extends WorkerEntrypoint { 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. /** 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/<id>` route for
* id-only flows.) */
async newWebApp(init: WebAppInit): Promise<{ id: string; app: FluentGet }> { async newWebApp(init: WebAppInit): Promise<{ id: string; app: FluentGet }> {
new URL(init.url); // fail before minting on garbage new URL(init.url); // fail before minting on garbage
const ns = this.env.WEBAPP_DO; const ns = this.env.WEBAPP_DO;