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)
449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
/**
|
|
* @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);
|
|
}
|
|
}
|
|
|
|
/** 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> => {
|
|
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 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);
|
|
}
|
|
|
|
// 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-]+)$/);
|
|
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 });
|
|
};
|
|
|
|
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 {
|
|
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 action = render ? "ACTION" : "/";
|
|
const body = `<!doctype html>
|
|
<html><head><meta charset="utf-8"><title>${esc(title)}</title></head>
|
|
<body>
|
|
<h1>mint a webapp instance</h1>
|
|
${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>
|
|
</body></html>`;
|
|
return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } });
|
|
}
|
|
|
|
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,
|
|
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 = 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 });
|
|
}
|
|
|
|
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 {
|
|
/** 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. (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;
|
|
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('"', """);
|
|
}
|