feat: webapp DO factory worker; diigo-dump as its consumer

webapp/ (new, site-agnostic, independently deployable):
- WebApp: fluent client (pathjoin/query/withHeaders/cookies/get/post)
  as RpcTarget; flower-style unions cite NuxiNL/flower v0.11 (Jan 2019);
  url/headers private (RpcTarget property reads are RPC); Error messages
  carry no header/param values; query-stripped URLs only
- WebAppDo: per-instance authority in DO storage (survives eviction);
  one-shot configure(), refuses different-url re-configure
- WebAppFactory: newWebApp(init) -> {id, app}, at(id); ids are bearer
  capabilities and are not logged
- HTTP surface: GET / mint form (secret header keys render blank),
  POST / mint (form or JSON), POST /at/<id> drive-by-capability
- types via wrangler types (worker-configuration.d.ts, gitignored)

diigo-dump/: now a webapp consumer. The diigo knowledge is only the
mint call: url=interact_api/, UA + x-requested-with, cookie, 500ms gate.
- src/webapp.ts, src/diigo-web.ts deleted (webapp's are canonical);
  diigo.ts slimmed to dumpBookmarks + PagedGet
- /_connect is state-aware: connect form pre-mint; health check
  (count=1 probe) + re-mint path post-mint; home-page link relabeled
- mint success page: narrow field + copy button (attribute-quoting
  fixed; id in x-diigo-web-id header for scripts)
- scripts/diigo-login: askpass flow, marked OBSOLETE (page flow is
  the primary); speaks the form protocol; writes only DIIGO_WEB_ID —
  the cookie lives in DO storage, never on disk
- package.json: dev = wrangler dev -c wrangler.jsonc -c ../webapp/...
  (verified: Ready on :8787, GET / 200, /_connect 200);
  wrangler ^4.148.0 devDependency after node_modules was cleaned

TODO(webapp) in webapp/README.md: proxy-page vs primary-worker mint
form; reconsider as the UI grows.

Generated-by: opencode (GLM)
This commit is contained in:
Connolly committed 2026-10-06 23:30:27 -05:00
1 parent 309f003fa3
commit 3f8e77eaa2
14 files changed
+1650 -329

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# webapp
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`)
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.
- `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"`)
- `newWebApp({url, headers?, msPerCall?})` → `{id, app}` — the fluent stub;
promise pipelining makes a whole `pathjoin().query().get()` chain a
single round trip
- `at(id)` — the fluent stub for a previously-minted instance
## Run
npx wrangler dev # → http://localhost:8788 (its own wrangler dir)
Status: typechecks; mint + drive not yet exercised end-to-end (needs the
combined dev run; see ../diigo-dump/README.md).
+405
View File
@@ -0,0 +1,405 @@
/**
* @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);
}
}
export default {
async fetch(
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 mintForm(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 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 });
},
};
function mintForm(params: URLSearchParams): Response {
const url = params.get("url") ?? "";
const msPerCall = params.get("msPerCall") ?? "500";
// 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 body = `<!doctype html>
<html><head><meta charset="utf-8"><title>webapp: mint an instance</title></head>
<body>
<h1>mint a webapp instance</h1>
<form method="post" 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>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"]);
/** 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 = {
url: String(form.get("url") ?? ""),
headers: JSON.parse(String(form.get("headers") || "{}")) as Record<string, string>,
msPerCall: Number(form.get("msPerCall") ?? 0),
};
}
} 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 {
/** mint a fresh instance: unforgeable uniqueId + configured fluent stub.
* The caller decides who else ever learns the id. */
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;");
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": []
},
"include": ["src/**/*", "worker-configuration.d.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "webapp",
"main": "src/index.ts",
"compatibility_date": "2026-09-29",
"observability": { "enabled": true },
"durable_objects": {
"bindings": [{ "name": "WEBAPP_DO", "class_name": "WebAppDo" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["WebAppDo"] }]
}