feat(diigo-dump): fluent WebApp over RPC + bound fetch + logged errors

Restore the fluent client from mm-diigo's WebApp.js as RpcTarget classes
(src/webapp.ts): WebApp with pathjoin/query/withHeaders/cookies/get/post,
flower-style unions (NuxiNL/flower v0.11, Jan 2019 — merge only if no
shared key contradicts), pathjoin containment guard; RateLimitedWebApp
shares one gate across all derived handles, enforced at get/post only.
src/diigo.ts ports makeDiigoAPI and the dumpBookmarks generator.

RPC surface: DiigoService (WorkerEntrypoint) with open() handing a
rate-limited stub whose cookie is attached server-side only, and
dumpNdjson. Promise pipelining makes a whole chain a single round trip.

Divergences from the original for the RPC setting:
- url is a private field: a public property on an RpcTarget is readable
  by stub holders, and query strings can carry credentials
- Error messages carry no header/param values; URLs have query stripped
  (structured details via @endo/errors deferred until node_modules)
- fetch default is fetch.bind(globalThis): a detached fetch throws
  'Illegal invocation' in workerd (found live, fixed, verified 200)
- dumpPage/open log failures via console.error for observability

README documents config, RPC use, and each divergence.

Generated-by: opencode (GLM)
This commit is contained in:
Connolly committed 2026-10-06 22:13:58 -05:00
1 parent 4a8828086e
commit 309f003fa3
6 files changed
+404 -35

No files matched your search

+64 -32
View File
@@ -1,14 +1,54 @@
// diigo-dump as a worker: one page-number field + button.
// Port of mm-diigo/projects/diigo-bak/diigo-dump.mjs —
// browser-session-cookie access to diigo's interact_api/load_user_items
// (the API-key path is bkmkeep.py / the EmDash plugin; this one is the cookie path).
// Secret: DIIGO_COOKIE (copy the "Cookie" header from diigo.com devtools).
/**
* @file diigo-dump worker: one page-number field + button, plus the
* {@link DiigoService} RPC entrypoint. Browser-session-cookie access to
* diigo's interact_api/load_user_items — the API-key path is bkmkeep.py /
* the EmDash plugin; this one is the cookie path.
*
* Secret: DIIGO_COOKIE (copy the "Cookie" header from diigo.com devtools).
* The fluent WebApp client lives in ./webapp.ts and ./diigo.ts; the cookie
* is attached only here, inside this worker's address space.
* Failures reach both surfaces: console.error for observability (wrapped
* workers never see exceptions the fetch handler renders itself), plus the
* rendered status/detail on the page.
* @see {@link DiigoService}
*/
const DIIGO_BASE = "https://www.diigo.com/interact_api/";
const COUNT = 24;
import { WorkerEntrypoint } from "cloudflare:workers";
import { makeDiigoAPI, dumpBookmarks, rateLimit } from "./diigo.js";
type Env = { DIIGO_COOKIE?: string };
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/**
* RPC entrypoint for other workers on the account. Public methods shape the
* ABI: `open()` hands out a rate-limited stub with the cookie attached
* server-side (the caller can pathjoin/query/get but never sees the cookie
* or fetch authority); `dumpNdjson` runs a whole dump here and returns ndjson.
*
* @see https://developers.cloudflare.com/workers/runtime-apis/rpc/ — stub
* lifecycle; use `using` / `Symbol.dispose` on the caller side
*/
export class DiigoService extends WorkerEntrypoint {
open(): ReturnType<typeof rateLimit> {
const { DIIGO_COOKIE: cookie } = this.env as Env;
if (!cookie) {
console.error({ event: "open", error: "Missing DIIGO_COOKIE in the diigo-dump worker." });
throw new Error("Missing DIIGO_COOKIE in the diigo-dump worker.");
}
return rateLimit(makeDiigoAPI().cookies(cookie), () => sleep(500));
}
async dumpNdjson(pages: number, count = 24): Promise<string> {
const diigoAPI = this.open();
const lines: string[] = [];
for await (const item of dumpBookmarks({ diigoAPI, pages, count })) {
lines.push(JSON.stringify(item));
}
return lines.join("\n");
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
@@ -27,38 +67,30 @@ export default {
},
};
/** the HTTP form handler's dump path: one page via the same fluent chain an
* RPC caller would drive (see {@link DiigoService.open}); shows raw JSON. */
async function dumpPage(env: Env, page: number): Promise<{ status: string; detail: string }> {
const cookie = env.DIIGO_COOKIE;
if (!cookie) {
return { status: "no-creds", detail: "Missing DIIGO_COOKIE.\nSet it: npx wrangler secret put DIIGO_COOKIE (or .dev.vars locally)." };
return {
status: "no-creds",
detail: "Missing DIIGO_COOKIE.\nSet it: npx wrangler secret put DIIGO_COOKIE (or .dev.vars locally).",
};
}
const target = new URL(DIIGO_BASE + "load_user_items");
target.searchParams.set("sort", "updated");
target.searchParams.set("count", String(COUNT));
target.searchParams.set("page_num", String(page));
const diigoAPI = rateLimit(makeDiigoAPI().cookies(cookie), () => sleep(500));
try {
const response = await fetch(target.toString(), {
headers: {
cookie,
// same attenuated-request shape diigo-dump.mjs / diigo-interact.mjs use
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/141.0",
"x-requested-with": "XMLHttpRequest",
},
});
const text = await response.text();
let detail = text;
if (response.ok) {
try {
const data = JSON.parse(text) as { total?: number; items?: unknown[] };
detail = `total: ${data.total}\nitems: ${data.items?.length ?? 0}\n\n${JSON.stringify(data.items ?? data, null, 2)}`;
} catch {
// non-JSON; show raw
}
}
return { status: String(response.status), detail };
const text = await diigoAPI
.pathjoin("load_user_items")
.query({ sort: "updated", count: 24, page_num: page })
.get();
const data = JSON.parse(text) as { total?: number; items?: Record<string, unknown>[] };
return {
status: "200",
detail: `total: ${data.total}\nitems: ${data.items?.length ?? 0}\n\n${JSON.stringify(data.items ?? data, null, 2)}`,
};
} catch (err) {
console.error({ event: "dump", page, error: String(err) });
return { status: "error", detail: String(err) };
}
}