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
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=<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 /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"`)
- `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.
+54 -11
View File
@@ -211,17 +211,18 @@ export class WebAppDo extends DurableObject<{ WEBAPP?: never }> {
}
}
export default {
async fetch(
/** 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> {
): 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 mintForm(url.searchParams);
return mintFormPage(url.searchParams);
}
// mint: browser form POST (form-encoded) or JSON (service-style)
@@ -229,6 +230,14 @@ export default {
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-]+)$/);
@@ -238,12 +247,17 @@ export default {
}
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 = `<!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>
<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>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>
@@ -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 = {
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 });
@@ -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<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. */
* 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;