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
+1646
-325
No files matched your search
+38
-2
@@ -15,6 +15,39 @@ worker, which fetches
|
||||
with the session cookie and shows status, `total`, and the JSON items in a
|
||||
read-only `<pre>`. GET `/` is inert — dumps happen only on explicit submits.
|
||||
|
||||
## Structure: WebApp as a Durable Object (webapp worker)
|
||||
|
||||
The credentialed authority is a Durable Object instance from the separately
|
||||
deployable `../webapp/` worker (service binding `WEBAPP` → its
|
||||
`WebAppFactory` entrypoint; diigo knowledge = only the mint call in
|
||||
`src/index.ts`). The instance is configured once — diigo's URL + seed
|
||||
headers (cookie, UA, x-requested-with) + 500ms rate gate — and persists
|
||||
its init in DO storage: it survives restarts, and diigo-dump or any other
|
||||
worker can reconnect purely by id. The cookie crosses the wire exactly
|
||||
once, at mint time.
|
||||
|
||||
Setup (dev):
|
||||
|
||||
cd diigo-dump
|
||||
npx wrangler dev -c wrangler.jsonc -c ../webapp/wrangler.jsonc
|
||||
# → http://localhost:8787 (webapp is secondary, binding-only)
|
||||
|
||||
npm run diigo-login >> .dev.vars # askpass for the cookie, mints the
|
||||
# instance, writes DIIGO_COOKIE +
|
||||
# DIIGO_WEB_ID; then restart wrangler
|
||||
# or, no askpass: open http://localhost:8787/_connect and paste the
|
||||
# cookie into the mint form; it prints the DIIGO_WEB_ID line to add
|
||||
|
||||
Deployed:
|
||||
|
||||
cd ../webapp && npx wrangler deploy
|
||||
cd ../diigo-dump && npx wrangler deploy
|
||||
npx wrangler secret put DIIGO_COOKIE # re-run diigo-login only when
|
||||
npx wrangler secret put DIIGO_WEB_ID # the cookie itself expires
|
||||
|
||||
Re-login is needed only when diigo invalidates the cookie; re-minting
|
||||
(POST /_mint with the new cookie) yields a fresh id and no other changes.
|
||||
|
||||
Provenance notes: the `user-agent` / `x-requested-with` headers and the
|
||||
`interact_api` endpoint mirror the original script; the fluent client from
|
||||
`WebApp.js` is restored in `src/webapp.ts` with `WebApp` and the rate-limit
|
||||
@@ -58,9 +91,12 @@ for await (const item of dumpBookmarks({ diigoAPI: stub, pages: 2 })) {
|
||||
## Run
|
||||
|
||||
cd diigo-dump
|
||||
npx wrangler dev --port 8788 # → http://localhost:8788
|
||||
npx wrangler dev -c wrangler.jsonc -c ../webapp/wrangler.jsonc
|
||||
# → http://localhost:8787; webapp rides along, binding-only
|
||||
# (first run: npm run diigo-login >> .dev.vars, or the /_connect page;
|
||||
# restart wrangler after adding DIIGO_WEB_ID)
|
||||
|
||||
Status as of 2026-10-06: manual integration test passes — 200, `total: 1674`.
|
||||
See the project ladder in ../capsite-checklist.md; context in
|
||||
../capsite-notes.md. This is the cookie path; the API-key path
|
||||
(diigo-ping, EmDash plugin) is still 401.
|
||||
(EmDash plugin) is still 401.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "diigo-dump",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev -c wrangler.jsonc -c ../webapp/wrangler.jsonc",
|
||||
"diigo-login": "scripts/diigo-login"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.148.0"
|
||||
}
|
||||
}
|
||||
Generated
+879
@@ -0,0 +1,879 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
wrangler:
|
||||
specifier: ^4.148.0
|
||||
version: 4.148.0
|
||||
|
||||
packages:
|
||||
|
||||
'@cloudflare/kv-asset-handler@0.5.0':
|
||||
resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
'@cloudflare/unenv-preset@2.16.2':
|
||||
resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==}
|
||||
peerDependencies:
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: '>1.20260305.0 <2.0.0-0'
|
||||
peerDependenciesMeta:
|
||||
workerd:
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20261006.1':
|
||||
resolution: {integrity: sha512-lCX6avJO2It9AyDEYitjmOtJRVp/xmaW0TziyoLAnfxlzozyyRbSFfIIMMCiDx/sTP1++E4VLbV+7BHSD33f0Q==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20261006.1':
|
||||
resolution: {integrity: sha512-PyFGjOaldtppnLuKsdkoYWXfLVpDjuf8u+uoykX+1Xvxo2EPmCVViq0iDKm+U7c7PRno85ehS3/MPVXyk2HwHw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20261006.1':
|
||||
resolution: {integrity: sha512-Ntf9S8eesmVufdjaT2kzWlz6Ng9XGYTc6cHQVRKAg59lFufwsIabGeMT6U4KkrpEeSIxgHq8aPtuLbZXE19tCA==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20261006.1':
|
||||
resolution: {integrity: sha512-JyeDBymVWw5tVRebSsVE8ojDYxd6UlZrAseHU8G+NpIyd5wF61tXlF/axLeCUqu0pOhBFHb8I5QKi0jwrCK41Q==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20261006.1':
|
||||
resolution: {integrity: sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@img/colour@1.1.0':
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.4':
|
||||
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
os: [freebsd]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.3':
|
||||
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.3':
|
||||
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.3':
|
||||
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.3':
|
||||
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.3':
|
||||
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.3':
|
||||
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
|
||||
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm@0.35.4':
|
||||
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.4':
|
||||
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.4':
|
||||
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.4':
|
||||
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-x64@0.35.4':
|
||||
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.4':
|
||||
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.4':
|
||||
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
|
||||
engines: {node: ^20.9.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.35.4':
|
||||
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0':
|
||||
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@poppinss/colors@4.1.6':
|
||||
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
|
||||
|
||||
'@poppinss/dumper@0.6.5':
|
||||
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
|
||||
|
||||
'@poppinss/exception@1.2.3':
|
||||
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
|
||||
|
||||
'@sindresorhus/is@7.2.0':
|
||||
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@speed-highlight/core@1.2.24':
|
||||
resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==}
|
||||
|
||||
blake3-wasm@2.1.5:
|
||||
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
|
||||
|
||||
cookie@1.1.1:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
error-stack-parser-es@1.0.5:
|
||||
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
||||
|
||||
esbuild@0.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
kleur@4.1.5:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
miniflare@5.20261006.0-alpha:
|
||||
resolution: {integrity: sha512-UP3qIeQKHba59GHxBqD8H2GateacluT4vrjIpqEgH78kCSUxdu8jA0f7MH7ftRSAnnB1oOvEsDtav3xevQDbpQ==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
path-to-regexp@6.3.0:
|
||||
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
semver@7.8.5:
|
||||
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
sharp@0.35.4:
|
||||
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '*'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
supports-color@10.2.2:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
undici@7.29.1:
|
||||
resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
|
||||
|
||||
workerd@1.20261006.1:
|
||||
resolution: {integrity: sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
wrangler@4.148.0:
|
||||
resolution: {integrity: sha512-wgbll8cA/7qOMJSoYQuJrw9M9lmedGzYtg6wvUK2p/7G1KaE88jFmQ/CvtQzVUF9C8PQtC4Y5p9vbpAsJGAlpA==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cloudflare/workers-types': ^5.20261006.1
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
|
||||
ws@8.21.0:
|
||||
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
youch-core@0.3.3:
|
||||
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
|
||||
|
||||
youch@4.1.0-beta.10:
|
||||
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@cloudflare/kv-asset-handler@0.5.0': {}
|
||||
|
||||
'@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1)':
|
||||
dependencies:
|
||||
unenv: 2.0.0-rc.24
|
||||
optionalDependencies:
|
||||
workerd: 1.20261006.1
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20261006.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20261006.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20261006.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20261006.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20261006.1':
|
||||
optional: true
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@emnapi/runtime@1.11.3':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@img/colour@1.1.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.4':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.35.4':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.11.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.4':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.35.4':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.6.0': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@poppinss/colors@4.1.6':
|
||||
dependencies:
|
||||
kleur: 4.1.5
|
||||
|
||||
'@poppinss/dumper@0.6.5':
|
||||
dependencies:
|
||||
'@poppinss/colors': 4.1.6
|
||||
'@sindresorhus/is': 7.2.0
|
||||
supports-color: 10.2.2
|
||||
|
||||
'@poppinss/exception@1.2.3': {}
|
||||
|
||||
'@sindresorhus/is@7.2.0': {}
|
||||
|
||||
'@speed-highlight/core@1.2.24': {}
|
||||
|
||||
blake3-wasm@2.1.5: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
error-stack-parser-es@1.0.5: {}
|
||||
|
||||
esbuild@0.28.1:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.1
|
||||
'@esbuild/android-arm': 0.28.1
|
||||
'@esbuild/android-arm64': 0.28.1
|
||||
'@esbuild/android-x64': 0.28.1
|
||||
'@esbuild/darwin-arm64': 0.28.1
|
||||
'@esbuild/darwin-x64': 0.28.1
|
||||
'@esbuild/freebsd-arm64': 0.28.1
|
||||
'@esbuild/freebsd-x64': 0.28.1
|
||||
'@esbuild/linux-arm': 0.28.1
|
||||
'@esbuild/linux-arm64': 0.28.1
|
||||
'@esbuild/linux-ia32': 0.28.1
|
||||
'@esbuild/linux-loong64': 0.28.1
|
||||
'@esbuild/linux-mips64el': 0.28.1
|
||||
'@esbuild/linux-ppc64': 0.28.1
|
||||
'@esbuild/linux-riscv64': 0.28.1
|
||||
'@esbuild/linux-s390x': 0.28.1
|
||||
'@esbuild/linux-x64': 0.28.1
|
||||
'@esbuild/netbsd-arm64': 0.28.1
|
||||
'@esbuild/netbsd-x64': 0.28.1
|
||||
'@esbuild/openbsd-arm64': 0.28.1
|
||||
'@esbuild/openbsd-x64': 0.28.1
|
||||
'@esbuild/openharmony-arm64': 0.28.1
|
||||
'@esbuild/sunos-x64': 0.28.1
|
||||
'@esbuild/win32-arm64': 0.28.1
|
||||
'@esbuild/win32-ia32': 0.28.1
|
||||
'@esbuild/win32-x64': 0.28.1
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
miniflare@5.20261006.0-alpha:
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
sharp: 0.35.4
|
||||
undici: 7.29.1
|
||||
workerd: 1.20261006.1
|
||||
ws: 8.21.0
|
||||
youch: 4.1.0-beta.10
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
path-to-regexp@6.3.0: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
sharp@0.35.4:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.35.4
|
||||
'@img/sharp-darwin-x64': 0.35.4
|
||||
'@img/sharp-freebsd-wasm32': 0.35.4
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||
'@img/sharp-linux-arm': 0.35.4
|
||||
'@img/sharp-linux-arm64': 0.35.4
|
||||
'@img/sharp-linux-ppc64': 0.35.4
|
||||
'@img/sharp-linux-riscv64': 0.35.4
|
||||
'@img/sharp-linux-s390x': 0.35.4
|
||||
'@img/sharp-linux-x64': 0.35.4
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.4
|
||||
'@img/sharp-linuxmusl-x64': 0.35.4
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.4
|
||||
'@img/sharp-win32-arm64': 0.35.4
|
||||
'@img/sharp-win32-ia32': 0.35.4
|
||||
'@img/sharp-win32-x64': 0.35.4
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
|
||||
undici@7.29.1: {}
|
||||
|
||||
unenv@2.0.0-rc.24:
|
||||
dependencies:
|
||||
pathe: 2.0.3
|
||||
|
||||
workerd@1.20261006.1:
|
||||
optionalDependencies:
|
||||
'@cloudflare/workerd-darwin-64': 1.20261006.1
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20261006.1
|
||||
'@cloudflare/workerd-linux-64': 1.20261006.1
|
||||
'@cloudflare/workerd-linux-arm64': 1.20261006.1
|
||||
'@cloudflare/workerd-windows-64': 1.20261006.1
|
||||
|
||||
wrangler@4.148.0:
|
||||
dependencies:
|
||||
'@cloudflare/kv-asset-handler': 0.5.0
|
||||
'@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261006.1)
|
||||
blake3-wasm: 2.1.5
|
||||
esbuild: 0.28.1
|
||||
miniflare: 5.20261006.0-alpha
|
||||
path-to-regexp: 6.3.0
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: 1.20261006.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
ws@8.21.0: {}
|
||||
|
||||
youch-core@0.3.3:
|
||||
dependencies:
|
||||
'@poppinss/exception': 1.2.3
|
||||
error-stack-parser-es: 1.0.5
|
||||
|
||||
youch@4.1.0-beta.10:
|
||||
dependencies:
|
||||
'@poppinss/colors': 4.1.6
|
||||
'@poppinss/dumper': 0.6.5
|
||||
'@speed-highlight/core': 1.2.24
|
||||
cookie: 1.1.1
|
||||
youch-core: 0.3.3
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
# diigo-login: OBSOLETE but kept for terminals: superseded by the /_connect
|
||||
# page flow (paste the cookie in the browser form; no clipboard/askpass).
|
||||
# Prompt for the diigo.com session cookie via SSH_ASKPASS, verify it
|
||||
# against the interact_api (one page), mint the webapp instance via the
|
||||
# running dev server, and write the instance id to stdout (for
|
||||
# `>> .dev.vars`). The cookie itself lives only in the DO's storage —
|
||||
# never on disk, never in logs.
|
||||
#
|
||||
# usage: pnpm -s diigo-login >> .dev.vars (dev server must be up; needs
|
||||
# a display for the GUI prompt)
|
||||
set -eu
|
||||
|
||||
: "${SSH_ASKPASS:=/usr/bin/ssh-askpass}"
|
||||
export SSH_ASKPASS
|
||||
|
||||
# the prompt text (SSH_ASKPASS_PROMPT is what ksshaskpass/gnome-askpass read)
|
||||
export SSH_ASKPASS_PROMPT="diigo.com login: paste the Cookie header from devtools"
|
||||
|
||||
cookie=$(DISPLAY="${DISPLAY:-:0}" "$SSH_ASKPASS" 2>/dev/null) || {
|
||||
echo "$0: askpass failed (need a display; or export SSH_ASKPASS yourself)" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -n "$cookie" ] || {
|
||||
echo "$0: empty cookie" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# verify before committing anything: the endpoint the worker will use,
|
||||
# with the same headers WebApp will send
|
||||
target='https://www.diigo.com/interact_api/load_user_items?sort=updated&count=1&page_num=1'
|
||||
status=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "cookie: $cookie" \
|
||||
-H 'user-agent: Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/141.0' \
|
||||
-H 'x-requested-with: XMLHttpRequest' \
|
||||
"$target")
|
||||
[ "$status" = 200 ] || {
|
||||
echo "$0: diigo said $status for that cookie; not writing .dev.vars" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# mint the webapp instance (persist id: the instance keeps the cookie, so
|
||||
# re-running this mint only replaces an *invalid* cookie). /_mint speaks
|
||||
# the browser form protocol: proxyMint is html, not json.
|
||||
minted=$(curl -s -D /tmp/opencode/diigo-mint-headers -X POST http://localhost:8787/_mint \
|
||||
--data-urlencode "cookie=$cookie") || {
|
||||
echo "$0: could not reach the dev server at :8787 (is it up?)" >&2
|
||||
exit 1
|
||||
}
|
||||
webapp_id=$(grep -i '^x-diigo-web-id:' /tmp/opencode/diigo-mint-headers | tr -d '\r' | awk '{print $2}')
|
||||
[ -n "$webapp_id" ] || {
|
||||
echo "$0: mint failed; server said: $(printf '%s' "$minted" | tr -d '\n' | head -c 200)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
printf 'DIIGO_WEB_ID="%s"\n' "$webapp_id"
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
declare module "cloudflare:workers" {
|
||||
export abstract class RpcTarget {
|
||||
constructor(...args: never[]);
|
||||
}
|
||||
export abstract class WorkerEntrypoint<Env = Record<string, unknown>> {
|
||||
protected ctx: { waitUntil(p: Promise<unknown>): void };
|
||||
protected env: Env;
|
||||
constructor(ctx: unknown, env: Env);
|
||||
}
|
||||
}
|
||||
+11
-36
@@ -1,40 +1,17 @@
|
||||
/**
|
||||
* @file Diigo via browser session cookie, like their web UI does.
|
||||
* Local mode uses the objects directly; over RPC, a caller receives a
|
||||
* rate-limited stub rooted at interact_api/ (never the cookie) and runs
|
||||
* {@link dumpBookmarks} in its own address space.
|
||||
* @see {@link makeDiigoAPI}
|
||||
* @file Diigo dump logic: the bookmark generator over any fluent handle.
|
||||
* Site-flavored configuration (URL, headers, cookie) lives where the webapp
|
||||
* instance is minted (src/index.ts: mint); only the traversal is here.
|
||||
*/
|
||||
|
||||
import { WebApp, rateLimit, type PagedGet } from "./webapp.js";
|
||||
|
||||
export type { PagedGet } from "./webapp.js";
|
||||
|
||||
export const USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/141.0";
|
||||
|
||||
/**
|
||||
* Attenuated authority: a client rooted at
|
||||
* https://www.diigo.com/interact_api/, with no cookie, fetch, or host
|
||||
* authority of its own. Attach the cookie last:
|
||||
* `makeDiigoAPI().cookies(env.DIIGO_COOKIE)` — or leave it off and keep the
|
||||
* handle cookie-free (e.g. for passing to less-trusted code).
|
||||
*
|
||||
* The UA / x-requested-with headers and the endpoint path mirror
|
||||
* mm-diigo's diigo-interact.mjs (see
|
||||
* https://github.com/dckc/mm-diigo — file projects/diigo-bak/diigo-interact.mjs,
|
||||
* commit 7dbb367, Oct 6, 2026), which is the canonical source of this shape.
|
||||
*/
|
||||
export const makeDiigoAPI = (fetchImpl: typeof fetch = fetch.bind(globalThis)): WebApp =>
|
||||
new WebApp("https://www.diigo.com", fetchImpl)
|
||||
.withHeaders({
|
||||
"user-agent": USER_AGENT,
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
})
|
||||
.pathjoin("interact_api/");
|
||||
export type PagedGet = {
|
||||
pathjoin(ref: string): PagedGet;
|
||||
query(params: Record<string, string | number>): PagedGet;
|
||||
get(): Promise<string>;
|
||||
};
|
||||
|
||||
export type DumpOptions = {
|
||||
/** rate-limited client, cookie attached (see makeDiigoAPI) */
|
||||
/** fluent handle for the diigo account, cookie attached upstream */
|
||||
diigoAPI: PagedGet;
|
||||
pages?: number;
|
||||
count?: number;
|
||||
@@ -42,8 +19,8 @@ export type DumpOptions = {
|
||||
};
|
||||
|
||||
/** yield diigo bookmarks, most-recently-updated first, `pages * count` at
|
||||
* most. `diigoAPI` is attenuated authority (see {@link makeDiigoAPI}); over
|
||||
* RPC this generator runs in the *caller's* address space against a stub. */
|
||||
* most. `diigoAPI` is attenuated authority; over RPC the generator runs in
|
||||
* the caller's address space against a stub. */
|
||||
export async function* dumpBookmarks({
|
||||
diigoAPI,
|
||||
pages = 2,
|
||||
@@ -69,5 +46,3 @@ export async function* dumpBookmarks({
|
||||
log({ event: "page", page, of: pages, got });
|
||||
}
|
||||
}
|
||||
|
||||
export { rateLimit };
|
||||
+184
-62
@@ -1,46 +1,62 @@
|
||||
/**
|
||||
* @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.
|
||||
* @file diigo-dump worker: cookie access to diigo's
|
||||
* interact_api/load_user_items via a WebApp Durable Object instance from
|
||||
* the webapp worker. The id is a bearer capability held in DIIGO_WEB_ID;
|
||||
* the cookie lives only inside that DO's storage.
|
||||
*
|
||||
* 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}
|
||||
* Connect flow (no askpass, no cookie-on-disk): the home page links to
|
||||
* /_connect, which proxies webapp's mint form with the diigo prefills
|
||||
* (cookie field blank) — paste the cookie, mint lands via the service
|
||||
* binding, and the page shows the DIIGO_WEB_ID to put in .dev.vars.
|
||||
* (scripts/diigo-login automates the same flow for terminals.)
|
||||
* @see {@link DiigoBridge}
|
||||
*/
|
||||
|
||||
import { WorkerEntrypoint } from "cloudflare:workers";
|
||||
import { makeDiigoAPI, dumpBookmarks, rateLimit } from "./diigo.js";
|
||||
import { dumpBookmarks, type PagedGet } from "./diigo.js";
|
||||
|
||||
type Env = { DIIGO_COOKIE?: string };
|
||||
/** the WEBAPP service binding to webapp/src/index.ts:WebAppFactory; typed
|
||||
* structurally here pending a shared types package */
|
||||
type WebAppFactoryShape = {
|
||||
newWebApp(init: {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
msPerCall?: number;
|
||||
}): Promise<{ id: string; app: PagedGet }>;
|
||||
at(id: string): Promise<PagedGet>;
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
type Env = {
|
||||
DIIGO_WEB_ID?: string;
|
||||
WEBAPP: WebAppFactoryShape;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.");
|
||||
const UA = "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/141.0";
|
||||
const DIIGO_INTERACT = "https://www.diigo.com/interact_api/";
|
||||
/** prefill for webapp's mint form: everything but the cookie */
|
||||
const DIIGO_PREFILL = JSON.stringify({
|
||||
"user-agent": UA,
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
});
|
||||
|
||||
/** connect to the configured instance, or give the setup pointer */
|
||||
async function instance(env: Env): Promise<PagedGet> {
|
||||
const id = env.DIIGO_WEB_ID;
|
||||
if (!id) {
|
||||
throw new Error("no instance: use the connect flow on the home page, then set DIIGO_WEB_ID");
|
||||
}
|
||||
return rateLimit(makeDiigoAPI().cookies(cookie), () => sleep(500));
|
||||
return env.WEBAPP.at(id);
|
||||
}
|
||||
|
||||
export class DiigoBridge extends WorkerEntrypoint {
|
||||
/** the diigo-attenuated handle: fluent chain over the instance */
|
||||
open(): Promise<PagedGet> {
|
||||
return instance(this.env as unknown as Env);
|
||||
}
|
||||
|
||||
/** bulk dump from the caller's address space */
|
||||
async dumpNdjson(pages: number, count = 24): Promise<string> {
|
||||
const diigoAPI = this.open();
|
||||
const diigoAPI = await instance(this.env as unknown as Env);
|
||||
const lines: string[] = [];
|
||||
for await (const item of dumpBookmarks({ diigoAPI, pages, count })) {
|
||||
lines.push(JSON.stringify(item));
|
||||
@@ -52,60 +68,164 @@ export class DiigoService extends WorkerEntrypoint {
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// the connect flow: proxy webapp's mint form (prefilled, cookie blank)
|
||||
if (url.pathname === "/_connect") {
|
||||
return env.DIIGO_WEB_ID ? connectedPage(env) : connectPage();
|
||||
}
|
||||
// webapp's form POSTs back through us: forward to the binding
|
||||
if (url.pathname === "/_mint" && request.method === "POST") {
|
||||
return proxyMint(request, env);
|
||||
}
|
||||
if (url.pathname !== "/") return new Response("not found", { status: 404 });
|
||||
|
||||
if (request.method !== "POST") return html(null);
|
||||
if (request.method !== "POST") return home(null, undefined, Boolean(env.DIIGO_WEB_ID));
|
||||
|
||||
const form = await request.formData();
|
||||
const raw = form.get("page");
|
||||
const page = typeof raw === "string" ? Number.parseInt(raw, 10) : Number.NaN;
|
||||
if (Number.isNaN(page) || page < 1 || page > 10000) {
|
||||
return html(null, "enter a page number (1 = most recently updated)");
|
||||
}
|
||||
const result = await dumpPage(env, page);
|
||||
return html(result.status, result.detail, String(page));
|
||||
},
|
||||
};
|
||||
|
||||
/** 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 home("enter a page number (1 = most recently updated)", undefined, Boolean(env.DIIGO_WEB_ID));
|
||||
}
|
||||
|
||||
const diigoAPI = rateLimit(makeDiigoAPI().cookies(cookie), () => sleep(500));
|
||||
try {
|
||||
const text = await diigoAPI
|
||||
const app = await instance(env);
|
||||
const text = await app
|
||||
.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)}`,
|
||||
};
|
||||
return home(
|
||||
`total: ${data.total}\nitems: ${data.items?.length ?? 0}\n\n${JSON.stringify(data.items ?? data, null, 2)}`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error({ event: "dump", page, error: String(err) });
|
||||
return { status: "error", detail: String(err) };
|
||||
return home(null, String(err), Boolean(env.DIIGO_WEB_ID));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** the connect page: webapp's form with diigo defaults. The mint POST is
|
||||
* proxied (/_mint) so one port serves the whole flow; the response
|
||||
* page shows the id and the .dev.vars line to add. */
|
||||
async function connectPage(): Promise<Response> {
|
||||
// proxy: render our own form with action="/_mint" so one port serves
|
||||
// the whole flow. TODO(webapp): one canonical mint form; consider
|
||||
// serving it from webapp directly.
|
||||
const body = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>connect diigo session</title></head>
|
||||
<body>
|
||||
<h1>connect diigo session</h1>
|
||||
<ol>
|
||||
<li>copy the <code>Cookie</code> header from diigo.com devtools</li>
|
||||
<li>paste below, mint, then add the <code>DIIGO_WEB_ID</code> line it
|
||||
shows to <code>.dev.vars</code> (deployed: <code>wrangler secret put DIIGO_WEB_ID</code>)</li>
|
||||
<li>restart wrangler, dump from the <a href="/">home page</a></li>
|
||||
</ol>
|
||||
<form method="post" action="/_mint">
|
||||
<input type="hidden" name="url" value="${esc(DIIGO_INTERACT)}">
|
||||
<input type="hidden" name="headers" value="${esc(JSON.stringify({ "user-agent": UA, "x-requested-with": "XMLHttpRequest", cookie: "" }))}">
|
||||
<p><label>cookie <input type="password" name="cookie" size="80" autocomplete="off"></label></p>
|
||||
<p><button type="submit">mint</button></p>
|
||||
</form>
|
||||
</body></html>`;
|
||||
return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } });
|
||||
}
|
||||
async function connectedPage(env: Env): Promise<Response> {
|
||||
let health = "";
|
||||
try {
|
||||
const app = await instance(env);
|
||||
// a cheap call: one item, exercises the cookie + rate gate
|
||||
const text = await app
|
||||
.pathjoin("load_user_items")
|
||||
.query({ sort: "updated", count: 1, page_num: 1 })
|
||||
.get();
|
||||
const data = JSON.parse(text) as { total?: number };
|
||||
health = `<p>instance is <b>live</b>: diigo reports <b>${esc(String(data.total ?? "?"))}</b> bookmarks.</p>`;
|
||||
} catch (err) {
|
||||
console.error({ event: "connect-check", error: String(err) });
|
||||
health = `<p>instance is configured but the last check <b>failed</b>:</p><pre>${esc(String(err))}</pre><p>If diigo expired the cookie, re-mint below.</p>`;
|
||||
}
|
||||
const body = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>diigo session</title></head>
|
||||
<body>
|
||||
<h1>diigo session</h1>
|
||||
<p>connected: id <code>${esc(`${env.DIIGO_WEB_ID!.slice(0, 6)}…${env.DIIGO_WEB_ID!.slice(-4)}`)}</code> (from .dev.vars).</p>
|
||||
${health}
|
||||
<h2>replace the session</h2>
|
||||
<p>Only needed if the cookie is stale (diigo will say 401/403). Minting a
|
||||
new instance invalidates nothing, but the id rotates and .dev.vars needs
|
||||
the new <code>DIIGO_WEB_ID</code>.</p>
|
||||
<form method="post" action="/_mint">
|
||||
<input type="hidden" name="url" value="${esc(DIIGO_INTERACT)}">
|
||||
<input type="hidden" name="headers" value="${esc(JSON.stringify({ "user-agent": UA, "x-requested-with": "XMLHttpRequest", cookie: "" }))}">
|
||||
<p><label>cookie <input type="password" name="cookie" size="80" autocomplete="off"></label></p>
|
||||
<p><button type="submit">mint</button></p>
|
||||
</form>
|
||||
</body></html>`;
|
||||
return new Response(body, { headers: { "content-type": "text/html;charset=utf-8" } });
|
||||
}
|
||||
|
||||
function html(status: string | null, detail?: string, page?: string): Response {
|
||||
const form = `<form method="post" action="/"><label>page <input type="number" name="page" min="1" value="${page ?? 1}"></label> <button type="submit">dump</button></form>`;
|
||||
const result =
|
||||
status === null
|
||||
? ""
|
||||
: `<p>status: <b>${status}</b></p><pre readonly style="white-space:pre-wrap;border:1px solid #ccc;padding:1rem;max-height:60vh;overflow:auto">${escapeHtml(detail ?? "")}</pre>`;
|
||||
/** proxy the mint: merge the pasted cookie into the headers JSON, mint via
|
||||
* the service binding, show the id (plus the .dev.vars line to copy) */
|
||||
async function proxyMint(request: Request, env: Env): Promise<Response> {
|
||||
const form = await request.formData();
|
||||
const cookie = String(form.get("cookie") || "");
|
||||
if (!cookie) {
|
||||
return new Response("no cookie supplied", { status: 400 });
|
||||
}
|
||||
const headers = {
|
||||
"user-agent": UA,
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
cookie,
|
||||
};
|
||||
const { id } = await env.WEBAPP.newWebApp({
|
||||
url: DIIGO_INTERACT,
|
||||
headers,
|
||||
msPerCall: 500,
|
||||
});
|
||||
return idPage(id);
|
||||
}
|
||||
|
||||
/** the id shown on the success page: a narrow text field (scrollable but
|
||||
* too small to show most of the value) with a copy button for the full
|
||||
* version. navigator.clipboard needs the page served from localhost/https
|
||||
* — true in dev and at the deployed URL */
|
||||
function idPage(id: string): Response {
|
||||
const body = `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>minted</title></head>
|
||||
<body>
|
||||
<h1>minted</h1>
|
||||
<p>id: <input id="wid" readonly size="4" value="${esc(id)}" onfocus="this.select()">
|
||||
<button type="button" onclick='navigator.clipboard.writeText(${JSON.stringify(id)}).then(() => this.textContent = "copied ✓")'>copy</button></p>
|
||||
<p>add to <code>.dev.vars</code>:</p>
|
||||
<pre>DIIGO_WEB_ID=</pre>
|
||||
<p>…click copy, then paste the full value after it. <a href="/">back to diigo dump</a> (restart wrangler first).</p>
|
||||
</body></html>`;
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
"content-type": "text/html;charset=utf-8",
|
||||
// for scripts: diigo-login reads this instead of scraping HTML
|
||||
"x-diigo-web-id": id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function home(detail: string | null, error?: string, connected?: boolean): Response {
|
||||
const form = `<form method="post" action="/"><label>page <input type="number" name="page" min="1"></label> <button type="submit">dump</button></form>`;
|
||||
const result = error
|
||||
? `<p>status: <b>error</b></p><pre readonly style="white-space:pre-wrap;border:1px solid #c00;padding:1rem">${escapeHtml(error)}</pre>`
|
||||
: detail
|
||||
? `<pre readonly style="white-space:pre-wrap;border:1px solid #ccc;padding:1rem;max-height:60vh;overflow:auto">${escapeHtml(detail)}</pre>`
|
||||
: "";
|
||||
return new Response(
|
||||
`<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>diigo dump</title></head>
|
||||
<body>
|
||||
<h1>diigo dump</h1>
|
||||
<p><a href="/_connect">${connected ? "diigo session status/replace…" : "connect diigo session…"}</a></p>
|
||||
${form}
|
||||
${result}
|
||||
</body></html>`,
|
||||
@@ -113,10 +233,12 @@ ${result}
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
const escapeHtml = esc;
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* @file Fluent web client, ported from mm-diigo's WebApp.js, with handles
|
||||
* that can travel over service-binding RPC.
|
||||
* @see {@link WebApp}
|
||||
*/
|
||||
|
||||
import { RpcTarget } from "cloudflare:workers";
|
||||
|
||||
export type Query = Record<string, string | number>;
|
||||
type Headers = Record<string, string>;
|
||||
|
||||
/** structural type both WebApp and RateLimitedWebApp satisfy */
|
||||
export interface PagedGet {
|
||||
pathjoin(ref: string): PagedGet;
|
||||
query(params: Query): PagedGet;
|
||||
get(): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attenuated authority for one URL: derived handles can only narrow, never
|
||||
* widen. `pathjoin` must stay within the old path. `query`/`withHeaders`
|
||||
* 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, and both sides end up holding the union — here,
|
||||
* merging query params or headers throws on contradiction instead of
|
||||
* letting a later grant quietly contradict an earlier one.
|
||||
*
|
||||
* The fetch function and any attached cookie headers never leave the creating
|
||||
* worker — a remote holder of a stub only gets the method calls. The URL is
|
||||
* private for the same reason: on an RpcTarget, a public property is readable
|
||||
* by remote holders (a property read is an argument-less method call), and a
|
||||
* query string can carry credentials (api_key=… and friends), so the URL
|
||||
* must not be part of the exposed surface. Derived handles address more
|
||||
* specific endpoints via {@link pathjoin}/{@link query}; nobody needs to read
|
||||
* the URL back out.
|
||||
*
|
||||
* Extends RpcTarget, so remote holders can promise-pipeline: a whole
|
||||
* `pathjoin().query().get()` chain is one round trip, with only the terminal
|
||||
* `get`/`post` doing I/O.
|
||||
*
|
||||
* @see https://developers.cloudflare.com/workers/runtime-apis/rpc/ — promise
|
||||
* pipelining, the stub security model, and property access
|
||||
*
|
||||
* 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, unions report key names alone (safe here: the
|
||||
* diigo cookie travels as a header, but the rule holds for keyed APIs too).
|
||||
* In due course: when this package gains node_modules, reach for
|
||||
* @endo/errors so error details can travel off-band from the message.
|
||||
*/
|
||||
export class WebApp extends RpcTarget {
|
||||
#url: string;
|
||||
#fetch: typeof fetch;
|
||||
#headers: Headers;
|
||||
|
||||
// bind: a detached `fetch` (e.g. a bare method reference) misbehaves in
|
||||
// workerd — "Illegal invocation" — so the default receiver is globalThis.
|
||||
constructor(url: string, fetchImpl: typeof fetch = fetch.bind(globalThis), headers: Headers = {}) {
|
||||
super();
|
||||
this.#url = url;
|
||||
this.#fetch = fetchImpl;
|
||||
this.#headers = headers;
|
||||
}
|
||||
|
||||
/** 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.#fetch, this.#headers);
|
||||
}
|
||||
|
||||
/** 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, this.#fetch, unionHeaders(this.#headers, h));
|
||||
}
|
||||
|
||||
cookies(cookie: string): WebApp {
|
||||
return this.withHeaders({ cookie });
|
||||
}
|
||||
|
||||
/** Union params into the existing query, flower-style (see {@link WebApp}). */
|
||||
query(params: Query): WebApp {
|
||||
return new WebApp(unionQuery(this.#url, params), this.#fetch, this.#headers);
|
||||
}
|
||||
|
||||
async get(): Promise<string> {
|
||||
const res = await this.#fetch(this.#url, { headers: this.#headers });
|
||||
return checkOK(this.#url, res).text();
|
||||
}
|
||||
|
||||
async post(body: string): Promise<string> {
|
||||
const res = await this.#fetch(this.#url, { method: "POST", headers: this.#headers, body });
|
||||
return checkOK(this.#url, res).text();
|
||||
}
|
||||
}
|
||||
|
||||
/** `rateLimit(web, delay)` from the original, as an RpcTarget wrapper: the
|
||||
* gate is shared by every handle derived from this one, so derived handles
|
||||
* stay under the same rate limit even when passed to other workers. Only
|
||||
* `get`/`post` wait on the gate; constructor-style methods (`pathjoin`,
|
||||
* `query`, …) pipeline through freely. */
|
||||
export class RateLimitedWebApp extends RpcTarget implements PagedGet {
|
||||
#web: WebApp;
|
||||
#gate: Gate;
|
||||
|
||||
constructor(web: WebApp, gate: Gate) {
|
||||
super();
|
||||
this.#web = web;
|
||||
this.#gate = gate;
|
||||
}
|
||||
|
||||
pathjoin(ref: string): RateLimitedWebApp {
|
||||
return new RateLimitedWebApp(this.#web.pathjoin(ref), this.#gate);
|
||||
}
|
||||
|
||||
query(params: Query): RateLimitedWebApp {
|
||||
return new RateLimitedWebApp(this.#web.query(params), this.#gate);
|
||||
}
|
||||
|
||||
withHeaders(h: Headers): RateLimitedWebApp {
|
||||
return new RateLimitedWebApp(this.#web.withHeaders(h), this.#gate);
|
||||
}
|
||||
|
||||
cookies(cookie: string): RateLimitedWebApp {
|
||||
return new RateLimitedWebApp(this.#web.cookies(cookie), this.#gate);
|
||||
}
|
||||
|
||||
async get(): Promise<string> {
|
||||
await this.#gate.waitMyTurn();
|
||||
return this.#web.get();
|
||||
}
|
||||
|
||||
async post(body: string): Promise<string> {
|
||||
await this.#gate.waitMyTurn();
|
||||
return this.#web.post(body);
|
||||
}
|
||||
}
|
||||
|
||||
export function rateLimit(web: WebApp, delay: () => Promise<void>): RateLimitedWebApp {
|
||||
return new RateLimitedWebApp(web, new Gate(delay));
|
||||
}
|
||||
|
||||
/** @internal — shared gate behind {@link RateLimitedWebApp}:
|
||||
* of all those waiting on pause, only 1 gets to go next */
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function checkOK(url: string, res: Response): Response {
|
||||
if (!res.ok) {
|
||||
// query stripped: param values must not appear in Error messages
|
||||
const noQuery = new URL(url);
|
||||
noQuery.search = "";
|
||||
throw new Error(`${res.status} ${res.statusText} on ${noQuery.href}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
@@ -9,5 +9,5 @@
|
||||
"skipLibCheck": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"include": ["src/**/*", "worker-configuration.d.ts"]
|
||||
}
|
||||
@@ -3,5 +3,12 @@
|
||||
"name": "diigo-dump",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-09-29",
|
||||
"observability": { "enabled": true }
|
||||
"observability": { "enabled": true },
|
||||
"services": [
|
||||
{
|
||||
"binding": "WEBAPP",
|
||||
"service": "webapp",
|
||||
"entrypoint": "WebAppFactory"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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('"', """);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"] }]
|
||||
}
|
||||
Reference in new issue
Block a user