diff --git a/deploy/cloudflare/.gitignore b/deploy/cloudflare/.gitignore new file mode 100644 index 0000000000..bf2a55e0cd --- /dev/null +++ b/deploy/cloudflare/.gitignore @@ -0,0 +1,5 @@ +# Wrangler local state (miniflare D1/KV/DO/R2 sqlite files) — never commit. +.wrangler/ +# Local secrets for `wrangler dev`. +.dev.vars +node_modules/ diff --git a/deploy/cloudflare/README.md b/deploy/cloudflare/README.md new file mode 100644 index 0000000000..be6583a6db --- /dev/null +++ b/deploy/cloudflare/README.md @@ -0,0 +1,41 @@ +# Paperclip on Cloudflare + +Deploys a full Paperclip instance to Cloudflare Workers: the Worker proxies +your `*.workers.dev` origin (HTTP + WebSockets) into a +[Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) container +running `paperclipai` with embedded Postgres and the local-adapter agent CLIs +preinstalled. No custom domain required. + +```sh +pnpm install --ignore-workspace +npx wrangler login +npx wrangler deploy +``` + +> ⚠️ Container disk is **ephemeral** — for any data you want to keep, set an +> external database first (`npx wrangler secret put DATABASE_URL`) and +> optionally enable the R2 attachments mount (see `wrangler.jsonc`). + +Full operator guide (prerequisites, configuration, durability, costs, +troubleshooting): **[docs/deploy/cloudflare.md](../../docs/deploy/cloudflare.md)**. + +## Layout + +| Path | Purpose | +| --- | --- | +| `src/index.ts` | Worker: boots Paperclip in the sandbox, proxies HTTP + WS | +| `src/lib.ts` | Pure helpers (unit-tested) | +| `container/Dockerfile` | Sandbox image: paperclipai + agent CLIs | +| `container/start-paperclip.sh` | Onboard-once boot script (non-root) | +| `wrangler.jsonc` | Worker + container + Durable Object config | +| `test/` | Unit tests + cross-file config consistency checks | + +This package is intentionally **not** part of the pnpm workspace (same +pattern as `packages/plugins/sandbox-providers/*`) so its Cloudflare +toolchain never churns the root lockfile. Install with +`pnpm install --ignore-workspace`. + +```sh +pnpm test # vitest: lib + config invariants (Dockerfile↔SDK version pin) +pnpm typecheck +``` diff --git a/deploy/cloudflare/container/Dockerfile b/deploy/cloudflare/container/Dockerfile new file mode 100644 index 0000000000..327dde04e4 --- /dev/null +++ b/deploy/cloudflare/container/Dockerfile @@ -0,0 +1,49 @@ +# Paperclip on Cloudflare Sandbox containers. +# +# The base image tag MUST match the @cloudflare/sandbox version pinned in +# ../package.json — the SDK and the in-container runtime are versioned +# together. test/config.test.ts enforces this. +FROM docker.io/cloudflare/sandbox:0.12.4 + +# Quality-of-life tools for agent workloads (subset of the tools the root +# Dockerfile's production stage installs; the sandbox base already ships +# git, curl, python3 and node). +RUN apt-get update \ + && apt-get install -y --no-install-recommends jq ripgrep openssh-client \ + && rm -rf /var/lib/apt/lists/* + +# Paperclip (npm release) plus the local-adapter agent CLIs, mirroring the +# root Dockerfile's production stage. +RUN npm install -g \ + paperclipai@latest \ + @anthropic-ai/claude-code@latest \ + @openai/codex@latest \ + opencode-ai \ + @google/gemini-cli@latest + +# Paperclip runtime defaults (mirrors the root Dockerfile / quadlet unit). +ENV HOST=0.0.0.0 \ + PORT=3100 \ + PAPERCLIP_HOME=/paperclip \ + PAPERCLIP_DEPLOYMENT_MODE=authenticated \ + PAPERCLIP_DEPLOYMENT_EXPOSURE=private + +# Embedded Postgres refuses to run as root, so Paperclip gets its own user. +# The uid is pinned (must match PAPERCLIP_UID in src/lib.ts — enforced by +# test/config.test.ts) so the optional R2 attachments mount can be exposed +# as owned by this user. embedded-postgres also creates symlinks inside the +# package directory on first boot, hence the chown of the installed package. +RUN useradd -m -u 4100 -s /bin/bash paperclip \ + && mkdir -p /paperclip \ + && chown -R paperclip:paperclip /paperclip \ + && chown -R paperclip:paperclip /usr/local/lib/node_modules/paperclipai + +COPY start-paperclip.sh /opt/start-paperclip.sh +RUN chmod +x /opt/start-paperclip.sh + +# Local dev (wrangler dev) requires exposed ports to be declared. +EXPOSE 3100 + +# No USER directive on purpose (trivy DS-0002): the Cloudflare Sandbox +# runtime daemon in the base image must run as root; the Paperclip workload +# itself drops to the non-root `paperclip` user in start-paperclip.sh. diff --git a/deploy/cloudflare/container/start-paperclip.sh b/deploy/cloudflare/container/start-paperclip.sh new file mode 100644 index 0000000000..ddcc39b1aa --- /dev/null +++ b/deploy/cloudflare/container/start-paperclip.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Boots Paperclip as the non-root `paperclip` user (embedded Postgres refuses +# to run as root). Onboards non-interactively on first boot, then runs the +# server. Started by the Worker via sandbox.startProcess() with the runtime +# environment (PAPERCLIP_*, optional ANTHROPIC_API_KEY / DATABASE_URL) — +# runuser without --login preserves that environment for the child shell. +set -euo pipefail + +# Own the data dir, but skip the (optional) R2-mounted storage directory: +# s3fs rejects chown, which would abort the boot under `set -e`. The mount +# is already exposed with the paperclip uid via s3fs options (src/lib.ts). +find /paperclip -path /paperclip/instances/default/data/storage -prune \ + -o -exec chown paperclip:paperclip {} + + +exec runuser -u paperclip -- bash -c ' + set -euo pipefail + export HOME=/home/paperclip + if [ ! -f /paperclip/instances/default/config.json ]; then + paperclipai onboard --yes --bind lan --data-dir /paperclip + fi + exec paperclipai run --data-dir /paperclip +' diff --git a/deploy/cloudflare/package.json b/deploy/cloudflare/package.json new file mode 100644 index 0000000000..48b493baaf --- /dev/null +++ b/deploy/cloudflare/package.json @@ -0,0 +1,33 @@ +{ + "name": "@paperclipai/deploy-cloudflare", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Deploy Paperclip to Cloudflare Workers with a Sandbox container", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "deploy/cloudflare" + }, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@cloudflare/sandbox": "0.12.4" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260701.0", + "@types/node": "^22.10.0", + "typescript": "^5.7.3", + "vitest": "^4.1.10", + "wrangler": "^4.113.0" + } +} diff --git a/deploy/cloudflare/pnpm-lock.yaml b/deploy/cloudflare/pnpm-lock.yaml new file mode 100644 index 0000000000..516fd81055 --- /dev/null +++ b/deploy/cloudflare/pnpm-lock.yaml @@ -0,0 +1,1663 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@cloudflare/sandbox': + specifier: 0.12.4 + version: 0.12.4 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260701.0 + version: 4.20260702.1 + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)) + wrangler: + specifier: ^4.113.0 + version: 4.113.0(@cloudflare/workers-types@4.20260702.1) + +packages: + + '@cloudflare/containers@0.3.7': + resolution: {integrity: sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw==} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/sandbox@0.12.4': + resolution: {integrity: sha512-0gpkd+a58Q5aGjgXvLFZSdIDAZpvh89QlaiHBpTtkDZWqHPKfyISV76/pIUzzo9ACJzVIpUO8hNv2X+LFTTiRQ==} + peerDependencies: + '@openai/agents': ^0.3.3 + '@opencode-ai/sdk': ^1.1.40 + '@xterm/xterm': '>=5.0.0' + peerDependenciesMeta: + '@openai/agents': + optional: true + '@opencode-ai/sdk': + optional: true + '@xterm/xterm': + optional: true + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260721.1': + resolution: {integrity: sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260721.1': + resolution: {integrity: sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260721.1': + resolution: {integrity: sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260721.1': + resolution: {integrity: sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260721.1': + resolution: {integrity: sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@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.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.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.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@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==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + capnweb@0.8.0: + resolution: {integrity: sha512-BK/TuXUiyfLSKsmjojn70yN7oYG/JJzoURZ3tckjg5Zj2KcygPm0A5jyOlswK7SYB4f0Gh9tt+RZ132b80iLfA==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + 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==} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: 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] + + hono@4.12.31: + resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} + engines: {node: '>=16.9.0'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + miniflare@4.20260721.0: + resolution: {integrity: sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==} + engines: {node: '>=22.0.0'} + hasBin: true + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.21: + resolution: {integrity: sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260721.1: + resolution: {integrity: sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.113.0: + resolution: {integrity: sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260721.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/containers@0.3.7': {} + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/sandbox@0.12.4': + dependencies: + '@cloudflare/containers': 0.3.7 + aws4fetch: 1.0.20 + capnweb: 0.8.0 + hono: 4.12.31 + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260721.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260721.1 + + '@cloudflare/workerd-darwin-64@1.20260721.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260721.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260721.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260721.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260721.1': + optional: true + + '@cloudflare/workers-types@4.20260702.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + 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.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.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': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.17': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + + aws4fetch@1.0.20: {} + + blake3-wasm@2.1.5: {} + + capnweb@0.8.0: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + es-module-lexer@2.3.1: {} + + 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 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + hono@4.12.31: {} + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + miniflare@4.20260721.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.28.0 + workerd: 1.20260721.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + nanoid@3.3.16: {} + + obug@2.1.4: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.21: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + semver@7.8.5: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + supports-color@10.2.2: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici@7.28.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.21 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + esbuild: 0.28.1 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260721.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260721.1 + '@cloudflare/workerd-darwin-arm64': 1.20260721.1 + '@cloudflare/workerd-linux-64': 1.20260721.1 + '@cloudflare/workerd-linux-arm64': 1.20260721.1 + '@cloudflare/workerd-windows-64': 1.20260721.1 + + wrangler@4.113.0(@cloudflare/workers-types@4.20260702.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260721.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260721.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260721.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - 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.17 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/deploy/cloudflare/pnpm-workspace.yaml b/deploy/cloudflare/pnpm-workspace.yaml new file mode 100644 index 0000000000..df3ab17e7c --- /dev/null +++ b/deploy/cloudflare/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +# Marks deploy/cloudflare as its own single-package workspace so pnpm does +# not attach it to the repo root workspace (same isolation rationale as +# packages/plugins/sandbox-providers/*): the Cloudflare toolchain +# (wrangler/workerd) never churns the root pnpm-lock.yaml. +# sharp stays false: it is an optional wrangler dependency (static-asset +# image processing) with no prebuilt binary on some platforms, and its +# source build fails without node-addon-api. Wrangler works without it. +allowBuilds: + esbuild: true + sharp: false + workerd: true diff --git a/deploy/cloudflare/src/index.ts b/deploy/cloudflare/src/index.ts new file mode 100644 index 0000000000..5e0b9f54ca --- /dev/null +++ b/deploy/cloudflare/src/index.ts @@ -0,0 +1,130 @@ +/** + * Cloudflare Worker that serves a full Paperclip instance from a Cloudflare + * Sandbox container on the Worker's own origin (works on *.workers.dev — no + * custom domain required). + * + * Request flow: + * 1. Ensure the Paperclip boot process is running in the sandbox + * (idempotent; memoized per isolate, re-checked after any failure). + * 2. WebSocket upgrades -> sandbox.wsConnect(request, 3100) + * Everything else -> sandbox.containerFetch(request, 3100) + * 3. While the container provisions / Paperclip onboards, serve a + * self-refreshing 503 status page instead of a raw error. + * + * See docs/deploy/cloudflare.md for the operator guide. + */ +import { getSandbox, type Sandbox as SandboxType } from "@cloudflare/sandbox"; +import { + ARTIFACTS_BINDING, + PAPERCLIP_PORT, + SANDBOX_ID, + START_COMMAND, + STORAGE_MOUNT_PATH, + bootingResponse, + buildPaperclipEnv, + isMountAlreadyInUse, + isPaperclipRunning, + isTransientBootError, + isTransientBootMessage, + isWebSocketUpgrade, + storageMountOptions, +} from "./lib"; + +// Required by the Sandbox SDK: the Durable Object class backing the container, +// and the ContainerProxy entrypoint used for credential-less R2 bucket mounts +// (harmless when no bucket is configured). +export { ContainerProxy, Sandbox } from "@cloudflare/sandbox"; + +interface Env { + Sandbox: DurableObjectNamespace; + /** + * Optional R2 bucket for durable attachment storage — uncomment the + * r2_buckets block in wrangler.jsonc to enable. + */ + ARTIFACTS?: R2Bucket; + /** Optional override; defaults to the request origin (e.g. *.workers.dev). */ + PAPERCLIP_PUBLIC_URL?: string; + PAPERCLIP_DEPLOYMENT_MODE?: string; + PAPERCLIP_DEPLOYMENT_EXPOSURE?: string; + /** Secrets (wrangler secret put …); forwarded to the container when set. */ + ANTHROPIC_API_KEY?: string; + DATABASE_URL?: string; +} + +/** + * Per-isolate memo so steady-state requests skip the listProcesses round + * trip. Reset whenever proxying fails, which also heals container restarts + * (the boot process does not survive a sandbox sleep/wake cycle). + */ +let paperclipEnsured = false; + +async function ensurePaperclip(sandbox: SandboxType, env: Env, requestUrl: URL): Promise { + const processes = await sandbox.listProcesses(); + if (isPaperclipRunning(processes)) return; + + // Mount durable attachment storage before Paperclip boots so the very + // first upload already lands in R2. Credential-less: the SDK routes s3fs + // traffic through the Worker's R2 binding (requires the ContainerProxy + // export above). + if (env[ARTIFACTS_BINDING]) { + try { + await sandbox.mountBucket(ARTIFACTS_BINDING, STORAGE_MOUNT_PATH, storageMountOptions()); + } catch (error) { + if (!isMountAlreadyInUse(error)) throw error; + } + } + + await sandbox.startProcess(START_COMMAND, { + env: buildPaperclipEnv({ + publicUrl: env.PAPERCLIP_PUBLIC_URL || `https://${requestUrl.host}`, + deploymentMode: env.PAPERCLIP_DEPLOYMENT_MODE, + deploymentExposure: env.PAPERCLIP_DEPLOYMENT_EXPOSURE, + anthropicApiKey: env.ANTHROPIC_API_KEY, + databaseUrl: env.DATABASE_URL, + }), + }); +} + +/** + * Distinguish the Sandbox SDK's "still starting" 5xx responses from genuine + * Paperclip errors so operators see the status page, not a JSON stack trace. + */ +async function isProvisioningResponse(response: Response): Promise { + if (response.status < 500) return false; + if (!response.headers.get("content-type")?.includes("json")) return false; + const text = await response.clone().text().catch(() => ""); + return isTransientBootMessage(text); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const sandbox = getSandbox(env.Sandbox, SANDBOX_ID); + const url = new URL(request.url); + + try { + if (!paperclipEnsured) { + await ensurePaperclip(sandbox, env, url); + paperclipEnsured = true; + } + + if (isWebSocketUpgrade(request.headers)) { + return await sandbox.wsConnect(request, PAPERCLIP_PORT); + } + + const response = await sandbox.containerFetch(request, PAPERCLIP_PORT); + if (await isProvisioningResponse(response)) { + paperclipEnsured = false; + return bootingResponse(); + } + return response; + } catch (error) { + paperclipEnsured = false; + if (isTransientBootError(error)) { + return isWebSocketUpgrade(request.headers) + ? new Response("Paperclip is starting; retry shortly", { status: 503 }) + : bootingResponse(); + } + throw error; + } + }, +} satisfies ExportedHandler; diff --git a/deploy/cloudflare/src/lib.ts b/deploy/cloudflare/src/lib.ts new file mode 100644 index 0000000000..64beff22f1 --- /dev/null +++ b/deploy/cloudflare/src/lib.ts @@ -0,0 +1,180 @@ +/** + * Pure helpers for the Cloudflare Sandbox deployment Worker. + * + * Everything here is side-effect free so it can be unit tested without a + * Workers runtime (see ../test/lib.test.ts). + */ + +/** Port the Paperclip server listens on inside the sandbox container. */ +export const PAPERCLIP_PORT = 3100; + +/** + * Stable sandbox id. One deployment == one Paperclip control plane, so a + * fixed id always routes to the same Durable Object / container. + */ +export const SANDBOX_ID = "paperclip"; + +/** Boot script baked into the container image (container/Dockerfile). */ +export const START_COMMAND = "/opt/start-paperclip.sh"; + +/** + * Optional R2 binding name for durable attachment storage. When the binding + * exists, the Worker FUSE-mounts the bucket (credential-less, via the SDK's + * egress interception) at Paperclip's local-disk storage directory before + * boot, so uploaded files survive container recycling. + */ +export const ARTIFACTS_BINDING = "ARTIFACTS"; + +/** + * Paperclip's local_disk storage provider path (docs/deploy/storage.md) + * under PAPERCLIP_HOME=/paperclip. Only file uploads live here — never the + * Postgres data directory, which must not sit on a FUSE mount. + */ +export const STORAGE_MOUNT_PATH = "/paperclip/instances/default/data/storage"; + +/** + * Fixed uid/gid of the non-root `paperclip` user created in + * container/Dockerfile (useradd -u). Pinned so the s3fs mount can be owned + * by that user; test/config.test.ts enforces the pin matches the Dockerfile. + */ +export const PAPERCLIP_UID = 4100; + +/** + * s3fs options for the attachments mount: expose it as owned by the + * `paperclip` user (s3fs mounts as root and rejects chown) and allow other + * users to traverse it. The SDK's R2 defaults are applied on top. + */ +export function storageMountOptions(): { s3fsOptions: string[] } { + return { + s3fsOptions: [ + "allow_other", + `uid=${PAPERCLIP_UID}`, + `gid=${PAPERCLIP_UID}`, + "umask=0022", + ], + }; +} + +/** Benign when two isolates race to mount the same path — first one wins. */ +export function isMountAlreadyInUse(error: unknown): boolean { + return error instanceof Error && /already in use/i.test(error.message); +} + +/** Process states that mean "no longer serving" (safe to start a new one). */ +const DEAD_STATUSES = new Set(["completed", "failed", "killed", "stopped"]); + +export interface ProcessLike { + command?: string; + status?: string; +} + +/** True when the request is a WebSocket upgrade that must not be buffered. */ +export function isWebSocketUpgrade(headers: Headers): boolean { + return headers.get("Upgrade")?.toLowerCase() === "websocket"; +} + +/** True when a live Paperclip boot process already exists in the sandbox. */ +export function isPaperclipRunning(processes: ProcessLike[]): boolean { + return processes.some( + (p) => (p.command ?? "").includes(START_COMMAND) && !DEAD_STATUSES.has(p.status ?? "") + ); +} + +export interface PaperclipEnvOptions { + /** Public origin the instance is reachable at, e.g. https://x.workers.dev */ + publicUrl: string; + deploymentMode?: string; + deploymentExposure?: string; + anthropicApiKey?: string; + databaseUrl?: string; +} + +/** + * Environment passed to the Paperclip boot process. Secrets are only + * forwarded when actually configured so the container env stays minimal. + */ +export function buildPaperclipEnv(options: PaperclipEnvOptions): Record { + const env: Record = { + HOST: "0.0.0.0", + PORT: String(PAPERCLIP_PORT), + PAPERCLIP_HOME: "/paperclip", + PAPERCLIP_DEPLOYMENT_MODE: options.deploymentMode ?? "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: options.deploymentExposure ?? "private", + PAPERCLIP_PUBLIC_URL: options.publicUrl, + }; + if (options.anthropicApiKey) env.ANTHROPIC_API_KEY = options.anthropicApiKey; + if (options.databaseUrl) env.DATABASE_URL = options.databaseUrl; + return env; +} + +/** + * Matches the Sandbox SDK's own transient startup errors: the container VM is + * still provisioning, or the port is not accepting connections yet (Paperclip + * onboards its database on first boot, which takes a minute or two). + * Deliberately specific to SDK wording so genuine Paperclip 5xx responses are + * never mistaken for boot noise. + */ +const TRANSIENT_BOOT_PATTERNS = [ + /currently provisioning/i, + /no container instance/i, + /container.*(?:not running|starting|is starting)/i, + /connection refused/i, + /port.*not (?:ready|mapped|found)/i, + /network connection lost/i, + /timed out.*(?:port|start|container)/i, +]; + +export function isTransientBootMessage(message: string): boolean { + return TRANSIENT_BOOT_PATTERNS.some((pattern) => pattern.test(message)); +} + +export function isTransientBootError(error: unknown): boolean { + return error instanceof Error && isTransientBootMessage(error.message); +} + +/** + * Self-refreshing status page served while the container provisions and + * Paperclip onboards. Inline styles only — nothing else is reachable yet. + */ +export function bootingPage(): string { + return ` + + + + + +Paperclip is starting… + + + +
+
+

Paperclip is starting

+

The sandbox container is provisioning and Paperclip is onboarding its database.

+

First boot takes a minute or two. This page refreshes automatically.

+
+ +`; +} + +/** 503 + Retry-After so health checkers and browsers both behave. */ +export function bootingResponse(): Response { + return new Response(bootingPage(), { + status: 503, + headers: { + "content-type": "text/html; charset=utf-8", + "retry-after": "15", + "cache-control": "no-store", + }, + }); +} diff --git a/deploy/cloudflare/test/config.test.ts b/deploy/cloudflare/test/config.test.ts new file mode 100644 index 0000000000..2137348834 --- /dev/null +++ b/deploy/cloudflare/test/config.test.ts @@ -0,0 +1,90 @@ +/** + * Consistency checks across wrangler.jsonc, the container Dockerfile and + * package.json. These encode the deployment's cross-file invariants — most + * importantly the Sandbox SDK requirement that the base image tag match the + * @cloudflare/sandbox package version exactly. + */ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { PAPERCLIP_PORT, PAPERCLIP_UID, STORAGE_MOUNT_PATH } from "../src/lib"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function read(path: string): string { + return readFileSync(join(root, path), "utf8"); +} + +/** Minimal JSONC parser: strips // and /* *\/ comments outside strings. */ +function parseJsonc(text: string): any { + const stripped = text.replace( + /"(?:[^"\\]|\\.)*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, + (match) => (match.startsWith('"') ? match : "") + ); + return JSON.parse(stripped); +} + +const wrangler = parseJsonc(read("wrangler.jsonc")); +const pkg = JSON.parse(read("package.json")); +const dockerfile = read("container/Dockerfile"); + +describe("wrangler.jsonc", () => { + it("wires the Sandbox container, DO binding and migration to one class", () => { + const containerClass = wrangler.containers[0].class_name; + expect(containerClass).toBe("Sandbox"); + expect(wrangler.durable_objects.bindings[0].class_name).toBe(containerClass); + expect(wrangler.migrations[0].new_sqlite_classes).toContain(containerClass); + }); + + it("uses the container Dockerfile as the image", () => { + expect(wrangler.containers[0].image).toBe("./container/Dockerfile"); + }); + + it("enables nodejs_compat (required by the Sandbox SDK)", () => { + expect(wrangler.compatibility_flags).toContain("nodejs_compat"); + }); + + it("carries no account- or zone-specific configuration", () => { + const raw = read("wrangler.jsonc"); + expect(wrangler.account_id).toBeUndefined(); + expect(wrangler.routes).toBeUndefined(); + expect(raw).not.toMatch(/account_id/); + }); + + it("defaults to private exposure (embedded Postgres requirement)", () => { + expect(wrangler.vars.PAPERCLIP_DEPLOYMENT_EXPOSURE).toBe("private"); + }); +}); + +describe("container image", () => { + it("pins the base image to the exact @cloudflare/sandbox version", () => { + const sdkVersion = pkg.dependencies["@cloudflare/sandbox"]; + // Must be an exact pin — the SDK and in-container runtime version together. + expect(sdkVersion).toMatch(/^\d+\.\d+\.\d+$/); + expect(dockerfile).toContain(`FROM docker.io/cloudflare/sandbox:${sdkVersion}`); + }); + + it("exposes the Paperclip port for local dev", () => { + expect(dockerfile).toContain(`EXPOSE ${PAPERCLIP_PORT}`); + }); + + it("installs Paperclip and the agent CLIs", () => { + expect(dockerfile).toContain("paperclipai@latest"); + expect(dockerfile).toContain("@anthropic-ai/claude-code"); + }); + + it("pins the paperclip uid the R2 mount options rely on", () => { + expect(dockerfile).toContain(`useradd -m -u ${PAPERCLIP_UID} `); + }); +}); + +describe("boot script", () => { + const script = read("container/start-paperclip.sh"); + + it("excludes the R2 storage mount from the ownership pass", () => { + // s3fs rejects chown; a bare `chown -R /paperclip` would abort the boot. + expect(script).toContain(`-path ${STORAGE_MOUNT_PATH} -prune`); + expect(script).not.toMatch(/chown -R paperclip:paperclip \/paperclip\s*$/m); + }); +}); diff --git a/deploy/cloudflare/test/lib.test.ts b/deploy/cloudflare/test/lib.test.ts new file mode 100644 index 0000000000..3db3b41b1f --- /dev/null +++ b/deploy/cloudflare/test/lib.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + PAPERCLIP_PORT, + PAPERCLIP_UID, + START_COMMAND, + STORAGE_MOUNT_PATH, + bootingPage, + bootingResponse, + buildPaperclipEnv, + isMountAlreadyInUse, + isPaperclipRunning, + isTransientBootError, + isTransientBootMessage, + isWebSocketUpgrade, + storageMountOptions, +} from "../src/lib"; + +describe("isWebSocketUpgrade", () => { + it("detects a standard upgrade request", () => { + const headers = new Headers({ Upgrade: "websocket", Connection: "Upgrade" }); + expect(isWebSocketUpgrade(headers)).toBe(true); + }); + + it("is case-insensitive", () => { + expect(isWebSocketUpgrade(new Headers({ Upgrade: "WebSocket" }))).toBe(true); + }); + + it("rejects plain requests and non-websocket upgrades", () => { + expect(isWebSocketUpgrade(new Headers())).toBe(false); + expect(isWebSocketUpgrade(new Headers({ Upgrade: "h2c" }))).toBe(false); + }); +}); + +describe("isPaperclipRunning", () => { + it("finds a live boot process", () => { + expect( + isPaperclipRunning([{ command: START_COMMAND, status: "running" }]) + ).toBe(true); + expect( + isPaperclipRunning([{ command: `bash ${START_COMMAND}`, status: "starting" }]) + ).toBe(true); + }); + + it("ignores dead processes so a restart can happen", () => { + for (const status of ["completed", "failed", "killed", "stopped"]) { + expect(isPaperclipRunning([{ command: START_COMMAND, status }])).toBe(false); + } + }); + + it("ignores unrelated processes and empty lists", () => { + expect(isPaperclipRunning([])).toBe(false); + expect(isPaperclipRunning([{ command: "sleep 1", status: "running" }])).toBe(false); + expect(isPaperclipRunning([{}])).toBe(false); + }); +}); + +describe("buildPaperclipEnv", () => { + it("produces the baseline environment", () => { + const env = buildPaperclipEnv({ publicUrl: "https://example.workers.dev" }); + expect(env).toEqual({ + HOST: "0.0.0.0", + PORT: String(PAPERCLIP_PORT), + PAPERCLIP_HOME: "/paperclip", + PAPERCLIP_DEPLOYMENT_MODE: "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "private", + PAPERCLIP_PUBLIC_URL: "https://example.workers.dev", + }); + }); + + it("only forwards secrets that are actually set", () => { + const bare = buildPaperclipEnv({ publicUrl: "https://x.dev" }); + expect(bare).not.toHaveProperty("ANTHROPIC_API_KEY"); + expect(bare).not.toHaveProperty("DATABASE_URL"); + + const withSecrets = buildPaperclipEnv({ + publicUrl: "https://x.dev", + anthropicApiKey: "test-key", + databaseUrl: "postgres://example", + }); + expect(withSecrets.ANTHROPIC_API_KEY).toBe("test-key"); + expect(withSecrets.DATABASE_URL).toBe("postgres://example"); + }); + + it("honors mode/exposure overrides", () => { + const env = buildPaperclipEnv({ + publicUrl: "https://x.dev", + deploymentMode: "authenticated", + deploymentExposure: "public", + }); + expect(env.PAPERCLIP_DEPLOYMENT_EXPOSURE).toBe("public"); + }); +}); + +describe("transient boot detection", () => { + it("matches the Sandbox SDK's startup errors", () => { + for (const message of [ + "Container is currently provisioning. This can take several minutes on first deployment.", + "no container instance available", + "connection refused", + "Port 3100 not ready", + "Network connection lost", + ]) { + expect(isTransientBootMessage(message), message).toBe(true); + expect(isTransientBootError(new Error(message)), message).toBe(true); + } + }); + + it("does not swallow genuine application errors", () => { + for (const message of [ + "Internal Server Error", + "database migration failed", + "TypeError: cannot read properties of undefined", + ]) { + expect(isTransientBootMessage(message), message).toBe(false); + } + expect(isTransientBootError("connection refused")).toBe(false); // non-Error + }); +}); + +describe("storage mount", () => { + it("targets Paperclip's local_disk storage path, never the DB dir", () => { + expect(STORAGE_MOUNT_PATH).toBe("/paperclip/instances/default/data/storage"); + expect(STORAGE_MOUNT_PATH).not.toContain("postgres"); + }); + + it("exposes the mount as the paperclip user", () => { + const { s3fsOptions } = storageMountOptions(); + expect(s3fsOptions).toContain(`uid=${PAPERCLIP_UID}`); + expect(s3fsOptions).toContain(`gid=${PAPERCLIP_UID}`); + expect(s3fsOptions).toContain("allow_other"); + }); + + it("recognizes the benign already-mounted race", () => { + expect(isMountAlreadyInUse(new Error("Mount path already in use: /x"))).toBe(true); + expect(isMountAlreadyInUse(new Error("S3FS mount command failed"))).toBe(false); + expect(isMountAlreadyInUse("already in use")).toBe(false); // non-Error + }); +}); + +describe("booting page", () => { + it("self-refreshes and explains what is happening", () => { + const html = bootingPage(); + expect(html).toContain('http-equiv="refresh"'); + expect(html).toContain("Paperclip is starting"); + }); + + it("responds 503 with Retry-After and no caching", () => { + const response = bootingResponse(); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("15"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("content-type")).toContain("text/html"); + }); +}); diff --git a/deploy/cloudflare/tsconfig.json b/deploy/cloudflare/tsconfig.json new file mode 100644 index 0000000000..27c4518429 --- /dev/null +++ b/deploy/cloudflare/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2022"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/deploy/cloudflare/vitest.config.ts b/deploy/cloudflare/vitest.config.ts new file mode 100644 index 0000000000..ed8bf7739b --- /dev/null +++ b/deploy/cloudflare/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc new file mode 100644 index 0000000000..638714bc72 --- /dev/null +++ b/deploy/cloudflare/wrangler.jsonc @@ -0,0 +1,43 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "paperclip-sandbox", + "main": "src/index.ts", + "compatibility_date": "2026-07-01", + "compatibility_flags": ["nodejs_compat"], + "containers": [ + { + "class_name": "Sandbox", + "image": "./container/Dockerfile", + // Paperclip control plane + embedded Postgres + agent CLI processes. + // Shrink at your own risk; embedded Postgres alone wants real memory. + "instance_type": { "vcpu": 2, "memory_mib": 8192, "disk_mb": 10240 }, + // One control plane per deployment — a fixed sandbox id routes every + // request to the same instance, so extra instances would sit idle. + "max_instances": 1, + "name": "paperclip-sandbox" + } + ], + "durable_objects": { + "bindings": [{ "class_name": "Sandbox", "name": "Sandbox" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Sandbox"] }], + // Optional: durable attachment storage. Paperclip's upload directory is + // FUSE-mounted from this bucket (credential-less, via the Worker binding), + // so attachments survive container recycling. To enable: + // 1. npx wrangler r2 bucket create paperclip-attachments + // 2. Uncomment the block below and redeploy. + // See docs/deploy/cloudflare.md#durable-attachments-via-r2-optional. + // "r2_buckets": [ + // { "binding": "ARTIFACTS", "bucket_name": "paperclip-attachments" } + // ], + "vars": { + // Defaults follow docs/deploy/deployment-modes.md. Embedded Postgres + // currently requires "private" exposure; switch to "public" only with an + // external DATABASE_URL (see docs/deploy/cloudflare.md#data-durability). + "PAPERCLIP_DEPLOYMENT_MODE": "authenticated", + "PAPERCLIP_DEPLOYMENT_EXPOSURE": "private", + // Leave empty to default to the request origin (your workers.dev URL). + "PAPERCLIP_PUBLIC_URL": "" + }, + "observability": { "enabled": true } +} diff --git a/docs/deploy/cloudflare.md b/docs/deploy/cloudflare.md new file mode 100644 index 0000000000..7b2b8f6fd0 --- /dev/null +++ b/docs/deploy/cloudflare.md @@ -0,0 +1,144 @@ +--- +title: Cloudflare +summary: Run Paperclip on Cloudflare Workers with a Sandbox container +--- + +Deploy a full Paperclip instance to Cloudflare: a Worker proxies your +`*.workers.dev` origin into a [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) +container that runs `paperclipai` (npm release) with embedded Postgres and the +local-adapter agent CLIs (Claude Code, Codex, OpenCode, Gemini) preinstalled. + +No custom domain is required — HTTP and WebSocket traffic are proxied on the +Worker's own origin. + +## ⚠️ Data Durability + +**Sandbox container disk is ephemeral.** When the container sleeps after +inactivity or is replaced by a deploy, the embedded Postgres data directory is +wiped — companies, agents, issues, everything. + +Treat the default configuration as an **evaluation deployment**. For anything +you want to keep, point Paperclip at an external Postgres before onboarding: + +```sh +npx wrangler secret put DATABASE_URL +# e.g. postgres://USER:PASSWORD@HOST:5432/paperclip (Neon, Supabase, RDS, …) +``` + +The Worker forwards `DATABASE_URL` into the container and Paperclip uses it +instead of embedded Postgres. + +## Durable Attachments via R2 (Optional) + +Uploaded files (issue attachments, images) can survive container recycling +without any Paperclip configuration: the Worker FUSE-mounts an R2 bucket at +Paperclip's [local-disk storage directory](/deploy/storage) before boot, +credential-less, through the Worker's own R2 binding. + +```sh +npx wrangler r2 bucket create paperclip-attachments +# then uncomment the r2_buckets block in wrangler.jsonc and redeploy +``` + +Notes: + +- Only the attachments directory is mounted. The Postgres data directory + stays on container disk **by design** — databases must not run on FUSE + mounts. Durable attachments complement, not replace, `DATABASE_URL`. +- In `wrangler dev` the SDK syncs the directory through the R2 binding + instead of s3fs; behavior is equivalent for testing. +- Alternatively, Paperclip's own `s3` storage provider can talk to R2 + directly via its S3-compatible API (`paperclipai configure --section + storage`) — the mount is just the zero-config path. + +## Prerequisites + +- A Cloudflare account on the **Workers Paid** plan (containers are not + available on the free tier) +- [Docker](https://docs.docker.com/get-docker/) running locally (wrangler + builds the container image and, for `wrangler dev`, runs it) +- Node.js 20+ and pnpm + +## Deploy + +```sh +cd deploy/cloudflare +pnpm install --ignore-workspace +npx wrangler login +npx wrangler deploy + +# optional: give in-container agents an API key +npx wrangler secret put ANTHROPIC_API_KEY +``` + +Then open the printed `https://paperclip-sandbox..workers.dev` +URL. The **first request** provisions the container and onboards Paperclip +(a minute or two) — you'll see a self-refreshing status page until the app is +up. Paperclip boots in `authenticated` mode with a pending bootstrap invite; +the first login claims the operator account, so open the URL yourself promptly +after deploying. + +## Configuration + +Set via `vars` in `wrangler.jsonc` or `wrangler secret put`: + +| Name | Kind | Default | Purpose | +| --- | --- | --- | --- | +| `PAPERCLIP_PUBLIC_URL` | var | request origin | Public URL Paperclip advertises | +| `PAPERCLIP_DEPLOYMENT_MODE` | var | `authenticated` | See [Deployment Modes](/deploy/deployment-modes) | +| `PAPERCLIP_DEPLOYMENT_EXPOSURE` | var | `private` | Embedded Postgres currently requires `private`; use `public` only with an external `DATABASE_URL` | +| `ANTHROPIC_API_KEY` | secret | — | Forwarded to in-container agent CLIs | +| `DATABASE_URL` | secret | — | External Postgres (strongly recommended, see above) | +| `ARTIFACTS` | R2 binding | — | Durable attachment storage (see above) | + +Container sizing lives in `wrangler.jsonc` (`instance_type`, default +2 vCPU / 8 GiB / 10 GB). Embedded Postgres plus concurrent agent processes +want real memory; shrink with care. + +## How It Works + +- `src/index.ts` — Worker entry. On each request it idempotently ensures the + Paperclip boot process is running (`sandbox.startProcess`), then proxies: + WebSocket upgrades via `sandbox.wsConnect(request, 3100)`, everything else + via `sandbox.containerFetch(request, 3100)`. Same-origin proxying keeps + Paperclip's cookies and live-update WebSockets on one host. +- `container/Dockerfile` — extends `cloudflare/sandbox` (tag pinned to the + `@cloudflare/sandbox` package version; enforced by `test/config.test.ts`), + installs `paperclipai` and the agent CLIs. +- `container/start-paperclip.sh` — onboards once + (`paperclipai onboard --yes --bind lan`), then `paperclipai run`, as a + non-root user (embedded Postgres refuses root). + +## Local Development + +```sh +cd deploy/cloudflare +pnpm install --ignore-workspace +pnpm dev # wrangler dev — builds and runs the container via Docker +``` + +Run the unit and config-consistency tests: + +```sh +pnpm test +pnpm typecheck +``` + +## Troubleshooting + +- **Status page loops for more than ~5 minutes** — check `npx wrangler tail` + for container/start errors. First-ever deploys can also spend a few minutes + provisioning container capacity. +- **`Container is currently provisioning`** in logs is normal on first boot. +- **Everything reset after idling** — that's the ephemerality caveat above; + configure `DATABASE_URL`. +- **Inspect the container directly** — `npx wrangler dev` locally, then + `docker exec` into the running container; or add temporary debug output to + `start-paperclip.sh`. + +## Cost Notes + +You pay for Worker requests plus container runtime (vCPU-seconds, memory, +disk) while the sandbox is awake. The container sleeps after inactivity; +with embedded Postgres that also means data loss (see above), which is the +other reason to use an external database. diff --git a/docs/docs.json b/docs/docs.json index 5787bda055..5c8fb9e543 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -82,6 +82,7 @@ "deploy/local-development", "deploy/tailscale-private-access", "deploy/docker", + "deploy/cloudflare", "deploy/deployment-modes", "deploy/database", "deploy/secrets",