Every stored shared bookmark on one page, in the public page's microformats. Without JS that's the whole list, and the search form asks the server's filtered page. With JS, the newest 5 show and search hides and shows entries in place. The browser runs the server's own matcher: query.ts becomes query.js (JSDoc types, checkJs), which the worker imports as code and serves as text at /_query.js via a `?raw` import and a wrangler Text rule. `shown()` picks what the page shows; the page's script reads items back from each h-entry. Tests: shown(); /all lists all 270 stored shared items, none hidden; /_query.js is the module the worker runs; and client-side search over /all's parsed microformats finds what the server's ?q= finds. The script itself was checked once in headless Chromium (5 of 54 shown by default; ?q=fragments shows 8), not in CI. Generated-by: Claude Opus 5.5
7.7 KiB
Contributing to diigo-cache
diigo-cache is a Cloudflare Worker cache over the webapp factory: session-cookie reads of diigo bookmarks, cached in Durable-Object SQLite storage. Evolved from the diigo-dump worker (provenance: mm-diigo projects/diigo-bak/); the static API-key path is dead (see the capsite checklist, goal 2 preamble).
What it holds
- The create flow, on one port:
/_connectrenders webapp's own mint form (diigo-prefilled, cookie blank) — wrangler multi-config dev doesn't serve aux workers' HTTP, so/_mintrelays the form opaquely into the WEBAPP binding (the cookie crosses; nothing on this side opens or logs it). Mint →/_wire(webapp id pre-filled, mode public|private) → the created page, where the user saves the ids. - Two independent page caches (
src/cache.ts), one behavior: a page hit is served from storage with no diigo call; a miss asks the webapp instance in the cache's wiring and etches the page.DiigoCache(bindingCACHE_DO): every item. Addressed by its cache id.PublicCache(bindingPUBLIC_DO): shared items only — private rows are dropped before etching (publicOnly), so its storage holds nothing private. Same cookie webapp as the private cache. Public page n is diigo page n, filtered (may show fewer thanPAGE_SIZE, 100: diigo's cap)./public/<username>resolves through theidFromName("index")registry, and reads throughviewer(), a read-only facet: it shows stored pages and fill progress, and can't fetch from diigo or write.
- Storage: SQL lives in
src/cache-read.sql(selects only) andsrc/cache-write.sql;src/store.tsturns them into a reader and a writer facet, so the cache logic holds no SQL text. - Pages: home's dump form (cache id + page number, POST) shows raw dump rows;
/public/<username>?page=nshows an HTML list with a page form (JSON forAccept: application/json). Both saycache: HITorMISS; every page read also logs{event: "cache", kind, page, outcome}. - Filters on the public page (
src/query.js):days,after,before,in(2026,2026-03,2026-Q1,2026-W41),by=updated|created, andq(words,"phrases",#tag,-negation, gmail/Slack/GitHub date operators). UTC, half-open windows. The same fields work as a dev-side spec (toQuery({ days: 7 })). A filter queries the storeditemstable throughviewer(), never diigo: results are as fresh as the cache, and JSON sayscompletewhen every diigo page is stored. - All bookmarks, searched client-side:
/public/<username>/alllists every stored shared bookmark in the same microformats. Without JS that's the whole list, and its search form asks the server. With JS (src/all-page.ts), the newest 5 show and search hides and shows entries in place, by the server's own matcher:src/query.jsis plain JS with JSDoc types, so the worker imports it as code and also serves it, as text (?raw, a wrangler Text rule), at/_query.js. - All bookmarks, searched client-side:
/public/<username>/alllists every stored shared bookmark in the same microformats. Without JS that's the whole list, and its search form asks the server. With JS (src/all-page.ts), the newest 5 show and search hides and shows entries in place, by the server's own matcher:src/query.jsis plain JS with JSDoc types, so the worker imports it as code and also serves it, as text (?raw, a wrangler Text rule), at/_query.js. - Publish later:
POST /_publish(cache id in the body) wires a public companion to a private-only cache. - The webkey page and Cap'n Web:
/cache/#<cache id>is a skeleton page, the same for every key; its script reads the key from the fragment and trades it in-band for a capability over Cap'n Web at/api(CacheApi.cache(key)→ aCacheCapfacet: fill, status, the public companion;src/api.ts). The worker serves capnweb's browser build itself at/_capnweb.js. - Fill job: walks every diigo page into a cache in the background — DO alarms, a few pages per step, progress stored in the DO (
startFill/fillStatus/stopFill), driven from the webkey page; the public page shows progress. "Invalidate the last N days" there drops the cached pages holding anything updated that recently (invalidateRecent), so the next fill refetches them. - The
DiigoBridgeentrypoint for other workers (its cache-id source is the goal-2 wiring TODO).
State surface
Ids are webkeys (the pattern): 256-bit bearer secrets that never ride a query string or path. The webkey page carries the cache id in the URL fragment (never sent to the server) and passes it in-band over Cap'n Web; forms carry ids in POST bodies; logs and pages show only a fingerprint (a prefix of the id's one-way hash).
- The cache id: the cache's read capability (bearer; not logged). Held by the user — saved to the password manager under a petname they choose (the username field; never sent to the server) — and passed per POST.
- The webapp instance id: the diigo-read capability; lives in each cache DO's wiring, arrives from the mint step.
- The cookie: lives only in webapp's DO storage; crosses the wire once, at mint time, as an opaque relay.
- The public cache: no capability —
/public/<username>is public by design (a guessable URL; hiding the link doesn't hide the page).
Stored state is meant to be durable: see the breaking-change rule in capsite AGENTS.md.
Deployment concern: rate-limit /api
Public pages only read storage, so strangers can't make the cache spend your diigo session. /api is keyed (a cache id is needed), but Cap'n Web pipelining makes calls cheap to send. Before exposing a deployment:
- rate-limit
/api(and/public/*, which reads storage) — on Cloudflare, the Workers rate-limit binding (keyed by client and/or username); on other workerd hosts, a rate limit in the proxy in front; - run the fill job (goal 1e): public pages show only what's stored.
RPC use (from another worker, same account)
// wrangler.jsonc of the caller
"services": [{ "binding": "DIIGO", "service": "diigo-cache", "entrypoint": "DiigoBridge" }]
// cache ids ride goal-2's wiring; see the capsite checklist
const ndjson = await env.DIIGO.dumpNdjson(4); // 4 pages of PAGE_SIZE
Test
pnpm types # regenerate worker-configuration.d.ts (after wrangler.jsonc changes)
pnpm lint # fails if those types are stale; tsc over src/ and tests/
pnpm test # vitest
Tests run the real worker and both DOs in the vitest pool, with WEBAPP bound to a fake webapp serving diigo-shaped items (wrangler.tests.jsonc).
Run
The README gets a cache going in dev. The same create flow from a terminal:
wid=$(curl -s -X POST http://localhost:8787/_mint --data-urlencode \
'url=https://www.diigo.com/interact_api/' --data-urlencode \
'headers={"user-agent":"Mozilla/5.0","x-requested-with":"XMLHttpRequest","cookie":"<paste>"}' \
--data-urlencode 'msPerCall=500' -D - -o /dev/null | grep -i x-webapp-id)
curl -s -X POST http://localhost:8787/_wire -H 'content-type: application/json' \
-d "{\"webappId\":\"<from $wid>\",\"mode\":\"private\"}"
Check a filled cache against a diigo RSS export: node scripts/compare-export.ts <cache.sqlite> <export.xml> <out-dir>.
Deploy
Not deployed yet; the order matters (goal 5 in the capsite checklist): webapp first (cd ../webapp && npx wrangler deploy), then diigo-cache, after rate-limiting /api (above).