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:
1 parent
309f003fa3
commit
3f8e77eaa2
14 files changed
+1650
-329
No files matched your search
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
Reference in new issue
Block a user