commit e1f288c1fda2fd5f9d61c5aa846da6f1a6151a2a
parent 2714869140a12212ddd7a4ecb98f139c28086d47
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 6 Jul 2026 10:35:55 -0400
Add r2-proxy Worker: serve R2 archives on free workers.dev (no custom domain)
Adds a self-contained Cloudflare Worker (r2-proxy/) that serves oversize archive
zips from R2 on a free *.workers.dev subdomain — no domain purchase, no WHOIS —
giving instance operators the same cost defenses (edge caching + rate limiting)
a custom domain would, for privacy/expense reasons.
One Worker serves every site: the R2 key is namespaced by site id, so it's a
pure passthrough (path <siteId>/archives/<file>.zip -> bucket key), deployed once.
Features: Cache API edge caching honoring the object Cache-Control, native
free per-IP rate-limit binding, path allow-listing to */archives/*.zip only,
and Range/resumable-download support. Validated: tsc clean against
@cloudflare/workers-types and 'wrangler deploy --dry-run' bundles with both
bindings recognized.
Point the editor's Archive overflow public URL at the workers.dev URL; uploads
and manifest are unchanged. DEPLOY_CLOUDFLARE.md now documents both the Worker
(recommended, no-domain) and custom-domain paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
10 files changed, 1210 insertions(+), 46 deletions(-)
diff --git a/DEPLOY_CLOUDFLARE.md b/DEPLOY_CLOUDFLARE.md
@@ -57,17 +57,24 @@ wrangler r2 bucket create my-archives-bucket
### 2. Expose it publicly
R2 buckets are private by default, and the Downloads page links to plain
-`https://…/<file>.zip` URLs, so the bucket needs public read access. **Prefer a custom
-domain** — it's the foundation for every cost-defense below:
-
-- **Custom domain (recommended):** bucket **Settings → Public access → Custom Domains
- → Connect Domain**, e.g. `archives.example.com` (the domain must be on your
- Cloudflare account). This routes downloads through Cloudflare's CDN, WAF, caching,
- and rate limiting.
-- **`r2.dev` subdomain (quick, but avoid for production):** bucket **Settings → Public
- access → Allow Access**. You get a URL like `https://pub-abc123.r2.dev`. Cloudflare
- throttles `r2.dev` and it gives you **none** of the CDN / WAF / rate-limiting
- controls below. Fine for a quick test; not for a real instance.
+`https://…/<file>.zip` URLs, so the bucket needs public read access. There are three
+ways to expose it; the first needs **no domain purchase** and is the recommended one:
+
+- **A Worker on `*.workers.dev` (recommended — no domain, no WHOIS):** deploy the
+ bundled `r2-proxy/` Worker to a free `<name>.workers.dev` subdomain (just like Pages'
+ `*.pages.dev`). It streams objects from the bucket and gives you edge caching **and**
+ tunable rate limiting in code — the same cost defenses a custom domain would, without
+ owning a domain. One Worker serves **every** site. See
+ [Serving via a Worker on workers.dev](#option-a--serving-via-a-worker-on-workersdev-no-domain)
+ below. Set the editor's public URL to `https://<name>.workers.dev`.
+- **Custom domain:** bucket **Settings → Public access → Custom Domains → Connect
+ Domain**, e.g. `archives.example.com` (the domain must be on your Cloudflare account).
+ Routes downloads through Cloudflare's CDN, WAF, caching, and dashboard rate-limiting
+ rules. See [Securing via a custom domain](#option-b--securing-via-a-custom-domain).
+- **`r2.dev` subdomain (quick test only):** bucket **Settings → Public access → Allow
+ Access**. You get `https://pub-abc123.r2.dev` — free and domain-free, but Cloudflare
+ throttles `r2.dev` and gives you **no** cache / rate-limit control of your own. Fine
+ for a smoke test; use the Worker for a real instance.
### 3. Authenticate `wrangler`
@@ -101,36 +108,76 @@ for bandwidth/egress. An attacker looping downloads of a 480 MB zip cannot run u
bandwidth bill. The *only* metered cost from reads is **Class B operations** (10M free
per month, then $0.36/M) — and the defenses below make even that hard to reach.
-Set these up once, in order of impact:
-
-### 1. Serve through a custom domain, not `r2.dev` — the master switch
-
-Everything else in this section requires it. A custom domain puts downloads behind
-Cloudflare's CDN and unlocks WAF, caching, rate limiting, and bot controls. See
-[step 2 above](#2-expose-it-publicly).
-
-### 2. Edge caching (already configured in code)
-
-Every archive is uploaded with `Cache-Control: public, max-age=3600` (set in
-`editor/app/deploy/buildDeployCore.ts`, constant `ARCHIVE_CACHE_CONTROL`). Served
-through a custom domain, Cloudflare's CDN caches each archive at the edge, so repeated
-downloads of the same file are served from cache and **never hit R2** — they don't
-count as Class B ops and cost nothing. This is the single most effective cost defense:
-a flood against one file mostly just warms one cache entry.
-
-To make caching aggressive, add a **Cache Rule** (dashboard: **Caching → Cache Rules →
-Create**):
+You get these controls one of two ways — **a Worker on `workers.dev`** (no domain) or
+**a custom domain**. Pick one; both are covered below. The Worker path is recommended
+if you don't want to own a domain.
+
+## Option A — Serving via a Worker on workers.dev (no domain)
+
+The bundled **`r2-proxy/`** Worker is a small, self-contained project that binds the R2
+bucket and serves archive objects on a free `<name>.workers.dev` subdomain — the same
+domain-free model as Pages' `*.pages.dev`. It's a **pure passthrough**: the request path
+`<siteId>/archives/<file>.zip` maps straight to the bucket key, so **one Worker serves
+every site** (deploy it once, not per site — that's the whole point of keying objects by
+site id). What it gives you, all on the free tier:
+
+- **Edge caching** — full downloads are cached with the Cache API, so repeat pulls skip
+ R2 (no billable Class B op). It honors the `Cache-Control` we set on each object.
+- **Rate limiting** — Cloudflare's native, free rate-limit binding caps requests per
+ client IP + file (dashboard rate-limit rules need a paid zone; this doesn't).
+- **Path allow-listing** — it only serves `*/archives/*.zip`, never arbitrary keys.
+- **Range / resumable downloads** — honors `Range` requests so big zips can resume.
+
+**Deploy it (once for the whole instance):**
+
+1. Edit `r2-proxy/wrangler.toml` and set `bucket_name` to the **same bucket** you use in
+ the editor's Settings. Optionally rename the Worker (`name`) and tune the rate limit
+ (`limit` / `period`).
+2. From the repo root:
+ ```sh
+ cd r2-proxy
+ pnpm install # first time only
+ pnpm run deploy # = wrangler deploy, reusing your host wrangler auth
+ ```
+ wrangler prints the deployed URL, e.g. `https://ytdlp-archive-proxy.<you>.workers.dev`.
+ (An "unsafe fields are experimental" warning for the rate-limit binding is expected.)
+3. In the editor's **Settings**, set **Archive overflow public URL** to that
+ `workers.dev` URL. Re-deploy a site and its Downloads links resolve through the Worker.
+
+The Worker code lives in `r2-proxy/src/index.ts` — the caching and rate-limit logic are
+small and commented if you want to adjust them.
+
+> **Free-tier limit:** Workers Free allows **100,000 requests/day** (resets daily). Far
+> more than a downloads endpoint needs; if you ever exceed it, requests get a `429`
+> (fail closed — no surprise bill) until the next day, or upgrade to Workers Paid ($5/mo).
+
+The archive `Cache-Control` is also set at upload time (`Cache-Control: public,
+max-age=3600`, constant `ARCHIVE_CACHE_CONTROL` in
+`editor/app/deploy/buildDeployCore.ts`); the Worker reads it back when caching. Archive
+filenames are stable and overwritten in place on re-deploy, so this 1-hour bound is what
+keeps a re-uploaded archive from being served stale for long — raise it if your archives
+rarely change.
+
+## Option B — Securing via a custom domain
+
+If you'd rather use a custom domain (its own upsides: dashboard WAF, managed bot rules,
+and rate-limiting rules without touching code), connect it per
+[step 2 above](#2-expose-it-publicly) and add these, in order of impact:
+
+### 1. Edge caching
+
+Served through a custom domain, Cloudflare's CDN caches each archive at the edge (it
+honors the `Cache-Control: public, max-age=3600` we set at upload), so repeated
+downloads of the same file are served from cache and **never hit R2**. To make caching
+aggressive, add a **Cache Rule** (dashboard: **Caching → Cache Rules → Create**):
- **When:** `URI Path` contains `/archives/`
- **Then:** *Eligible for cache*, **Edge TTL → Override → 1 day** (or longer).
-Archive filenames are stable and overwritten in place on re-deploy, so if you raise the
-TTL a lot, **purge the cache on deploy** (dashboard **Caching → Purge**, or
-`wrangler`/API) so updated archives aren't served stale. The 1-hour `Cache-Control`
-default already bounds staleness without a purge; tune `ARCHIVE_CACHE_CONTROL` up if
-your archives rarely change.
+If you raise the TTL a lot, **purge the cache on deploy** (dashboard **Caching → Purge**,
+or `wrangler`/API) so a re-uploaded archive isn't served stale.
-### 3. Rate limiting — the hard backstop
+### 2. Rate limiting — the hard backstop
A Rate Limiting rule caps how fast any single client can pull archives, stopping a
flood that misses cache. Dashboard: **Security → WAF → Rate limiting rules → Create**
@@ -143,13 +190,13 @@ flood that misses cache. Dashboard: **Security → WAF → Rate limiting rules
Tune the threshold to real usage — legitimate users download a handful of files, not
dozens per minute.
-### 4. Bot Fight Mode + WAF managed rules
+### 3. Bot Fight Mode + WAF managed rules
Dashboard: **Security → Bots → Bot Fight Mode** (free). Blocks the low-effort scripted
abuse that makes up most of this traffic. The free **WAF managed ruleset** adds a
baseline of protection at no cost.
-### 5. Hotlink protection (optional)
+### 4. Hotlink protection (optional)
Stops other sites embedding your archives and spending your ops budget serving their
audience. A WAF custom rule (**Security → WAF → Custom rules**):
@@ -160,18 +207,19 @@ audience. A WAF custom rule (**Security → WAF → Custom rules**):
(Allow an empty `Referer` so direct clicks and privacy-conscious browsers still work.)
-### 6. Billing / usage alerts
+## Billing / usage alerts (either option)
R2 has no hard spend cap, but Cloudflare **Notifications** (dashboard: **Notifications
→ Add**) can email you when R2 storage or Class A/B operations cross a threshold —
cheap insurance so nothing surprises you.
-### What to skip
+## What to skip
-**Signed URLs / token-gated downloads via a Worker** are the heavyweight option. Given
-egress is free and caching neutralizes the ops cost, they're overkill for *cost*
-defense — they add a Worker, key management, and friction for legitimate users. Only
-reach for them if you want *access control* (private archives), not cost control.
+**Signed URLs / token-gated downloads** are the heavyweight option — they add key
+management and friction for legitimate users. Given egress is free and caching
+neutralizes the ops cost, they're overkill for *cost* defense (the `r2-proxy` Worker is
+a plain passthrough, not an access gate). Only reach for signed URLs if you want
+*access control* (private archives), not cost control.
---
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -5,6 +5,7 @@
- **The monitor widget gains a needs-work channel list, more interaction buttons, and an in-place settings gear.** Three additions, all driveable from the widget builder. **(1) A "Needs work" list** (URL flag `act=1`) — a compact, per-channel worklist of videos to download (`↓ N`) or transcribe (`✎ N`), reusing the same `loadActionableSummary` that powers the `/actionable` page via a new `/api/widget/actionable` route; it polls on a 15s floor (the backlog changes on job completions, not seconds) and caps at 6 channels with a `+N more` line. **(2) More interactions** behind the existing `controls=1` switch: each needs-work row gains the same per-channel **Download missing** / **Transcribe pending** buttons as the actionable page (reusing `InlineActionButton`), and the controls row adds **Retry all failed** alongside Pause/Resume + Drain. **(3) An in-place settings gear** (on by default; URL flag `gear=0` to hide, or a **Show settings gear** builder checkbox) — clicking it opens the builder's own form *inside the widget window*, so a pinned widget can be reconfigured live without opening the builder page; edits apply immediately and mirror into the address bar via `history.replaceState`, so a reload preserves them and the link stays copyable. The builder form is extracted into a shared `WidgetConfigForm` used by both the builder and the overlay, and the widget's poller now fetches immediately on (re)subscribe instead of after one interval, so newly-enabled sections render at once. Existing links render unchanged (the two new flags default to their old behavior; the gear is the one new default-visible affordance and is read-only — it mutates no server state). See `editor/app/widget/lib/config.ts`, the new `editor/app/widget/components/WidgetConfigForm.tsx` and `editor/app/api/widget/actionable/route.ts`, `editor/app/widget/components/{MonitorWidget,WidgetControls}.tsx`, `editor/app/widget/builder/components/WidgetBuilder.tsx`, and `editor/e2e/widget.spec.ts`.
- **Every site build now bundles downloadable per-channel transcript & live-chat archive zips.** The archive builders (per-channel `<slug>.zip` / `<slug>.live_chat.zip`) previously only ran as standalone actions that wrote to a non-served directory; now `compose-site` generates them for the site's own channels straight into the served `public/archives/` and writes a `manifest.json` (sizes + counts) that the site's new **Downloads** page reads. **`zip` is now the default archive format** everywhere (was `tar.gz`), and the Build page's format help text tracks the selected format. Generation is **on by default with three opt-out levels**: a global **Generate archive zips on build** toggle in Settings, a per-site **Generate archive zips** toggle (plus an optional **Archive size cap (MB)**) on the site's page, and a per-build **Skip archive zips** checkbox on the Build and Build & Deploy controls (`BUILD_ARCHIVES=0`). See `common/bin/compose-site.ts` (`composeArchives`), `common/controller/archive{Transcripts,LiveChat}.ts` (new `outDir` option), `common/lib/archiveOptions.ts` (default + manifest types), `common/lib/{site,settings}.ts` (opt-out flags), and `editor/app/{deploy/buildDeployCore.ts,build/buildAction.ts,deploy/components/Build{Export,Deploy}Button.tsx,sites/components/SiteForm.tsx,settings/components/SettingsForm.tsx}`.
- **Oversize archives now overflow to Cloudflare R2 instead of being dropped.** A single file over 25 MB breaks a Cloudflare Pages deploy, so a channel zip over the cap (default 25 MB; `0` = no cap) used to be removed from what's served and flagged `oversize`. Now, when **Archive overflow storage** is configured in Settings (an R2 **bucket** + its **public URL**), `compose-site` stages each oversize archive to `export/.r2-staging/<siteId>/` and records its future public URL in the manifest; the deploy step then uploads it via `wrangler r2 object put <bucket>/<siteId>/archives/<file>` (reusing the host's wrangler auth) **before** the Pages deploy, so the Downloads page links straight to R2. With no bucket configured the old drop-and-flag behavior is unchanged (uploads run only in the editor's Deploy / Build & deploy actions, not a raw `pnpm deploy`). The **combined "whole site" archives were removed** — they duplicated the per-channel content and were always the first to blow the cap. Each R2 upload also now sets `Cache-Control: public, max-age=3600` so a Cloudflare custom domain caches downloads at the edge — the main defense against download-abuse cost (R2 egress is free; only origin reads are billable, and cached hits skip the origin). New **[DEPLOY_CLOUDFLARE.md](../DEPLOY_CLOUDFLARE.md)** documents the full R2 setup plus the Cloudflare custom-domain / caching / rate-limiting / bot config for instance operators. See `common/lib/settings.ts` (`archiveStorage`), `common/bin/compose-site.ts` (staging + manifest `url`), `common/lib/archiveOptions.ts` (`ArchiveManifestEntry.url`), and `editor/app/deploy/buildDeployCore.ts` (`runArchiveUploadIntoLog`, `ARCHIVE_CACHE_CONTROL`) / `deploy/deployAction.ts` / `build/buildAction.ts`.
+- **Archive downloads can now be served securely without owning a domain (`r2-proxy/` Worker).** Serving oversize R2 archives with cost/abuse protection previously implied a Cloudflare **custom domain** (for CDN caching + rate-limiting rules). New self-contained Cloudflare Worker at `r2-proxy/` serves the bucket on a free `*.workers.dev` subdomain instead — the same domain-free model as Pages' `*.pages.dev`, addressing the privacy/expense of registering a domain. It's a pure passthrough (request path `<siteId>/archives/<file>.zip` → bucket key), so **one Worker serves every site** — deploy it once, not per site. It adds edge caching (Cache API, honoring the object's `Cache-Control`), native per-IP rate limiting (free binding), path allow-listing (`*/archives/*.zip` only), and `Range`/resumable-download support. Point the editor's **Archive overflow public URL** at the `workers.dev` URL and nothing else changes (uploads/manifest are identical). `DEPLOY_CLOUDFLARE.md` now documents both paths (Worker vs custom domain), with the Worker as the recommended no-domain option. See `r2-proxy/{src/index.ts,wrangler.toml,package.json,README.md}` and `editor/app/settings/components/SettingsForm.tsx` (public-URL hint).
- **The Duplicates page is now a per-site toggle and hides itself when empty.** Each site's editor page gains a **Show the Duplicates page** checkbox (on by default). `compose-site` writes the site-filtered `duplicates.json` only when the toggle is on *and* there's at least one in-scope cluster, and the export Header keys its Duplicates nav link off a new `hasDuplicates()` — so the link and page disappear both when a site opts out and when it simply has no detected duplicates. See `common/lib/site.ts` (`duplicates` flag), `common/bin/compose-site.ts` (gated write), `export/app/lib/duplicates.ts` (new), `export/app/components/Header.tsx`, and `editor/app/sites/{components/SiteForm.tsx,actions.ts}`.
- **You can now change a channel's slug (its id) — deliberately, from the Danger zone.** A channel's slug *is* its on-disk directory name (`transcripts/channels/<slug>/`), so it used to be fixed at creation ("Slug is fixed once a channel is created"). A new **Rename** form in the channel's Danger zone lifts that: enter a new slug and **type the current slug to confirm** (same friction as delete), and the rename is blocked while the channel has running/queued jobs (the in-memory registry keys by slug). Because the slug is a directory name, the rename does a **full migration** of every slug-keyed store so nothing silently breaks: it moves the channel dir (config, data, playlist, snapshot, shards, failed lists) **and** the saved-video store dir — rewriting each `saved-video.json` pointer's absolute `dir` so persisted source videos still resolve — then retargets every site.json membership, the sync scheduler's per-channel backoff state, and any job bookmarks. The two filesystem moves run first and roll back on failure; the metadata updates that follow are atomic and best-effort (surfaced as warnings). Renaming **changes the channel's public URL** (the old one 404s), which the form warns about. The slug grammar is also now validated on create. See `common/controller/renameChannel.ts`, `common/controller/channels.ts` (`isValidChannelSlug`), `common/lib/savedVideo-server.ts` (`rewriteSavedVideoDir`), `common/jobs/bookmarks.ts` (`renameChannelInBookmarks`), `editor/app/channels/{actions.ts,components/RenameChannelForm.tsx,[slug]/page.tsx}`, and `editor/e2e/channel-rename.spec.ts`.
- **New Queue diagnostics page (`/jobs/queue`): see & force-release stuck jobs.** The job system has two sources of truth that can drift — the registry owns each job's `status`, the scheduler owns the running SLOT per queue. A cancel that never finalizes (a child that ignored SIGTERM, a crashed finalizer) leaves a job "cancelled" in the registry while the scheduler still marks its slot running, silently blocking every job behind it on that queue — and the Active Jobs page hides it (it filters to running/queued). The new **Queue** page reconciles the two: it builds from the **scheduler** as the source of truth for slots, cross-checks each against its registry record, and flags a running head as **stuck** when the record is terminal-but-holding-slot, evicted, or (softer) a live job idle past 10 minutes. It **auto-heals** the hard cases on every view/poll (frees terminal/evicted slots), shows a health strip (active queues, running, queued, **stuck**, workers), per-queue cards with the held-for duration / PID (`kill -9` hint) / last log line, and a **Force-release** button per slot (SIGKILLs the child and frees the slot unconditionally) plus a **Reap all stuck** action. Force-release is also available on any running job in Active Jobs, and Active Jobs links to Queue with a stuck-count badge. See `common/jobs/registry.ts` (`forceRelease`), `editor/app/jobs/queue/*`, `editor/app/jobs/{actions.ts,components/ForceReleaseJobButton.tsx}`, and `editor/e2e/queue.spec.ts`.
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -208,8 +208,10 @@ export function SettingsForm({ initial, apps }: Props) {
className="rounded border border-border bg-card px-2 py-1 text-sm"
/>
<span className="text-xs text-muted-foreground">
- Public base URL the bucket is served from (its <code>r2.dev</code>
- subdomain or a custom domain). The Downloads page links to{" "}
+ Public base URL the bucket is served from — a{" "}
+ <code>workers.dev</code> proxy (see <code>r2-proxy/</code>), a custom
+ domain, or the bucket’s <code>r2.dev</code> subdomain. The
+ Downloads page links to{" "}
<code><base>/<siteId>/archives/<file>.zip</code>.
Required for overflow uploads to work.
</span>
diff --git a/r2-proxy/README.md b/r2-proxy/README.md
@@ -0,0 +1,34 @@
+# r2-proxy
+
+A tiny Cloudflare Worker that serves the oversize archive zips stored in R2 on a free
+`*.workers.dev` subdomain — no custom domain required. It adds edge caching and
+per-client rate limiting (both free-tier) on top of the bucket.
+
+**One Worker serves every export site.** Archive objects are keyed by site id
+(`<siteId>/archives/<file>.zip`), so this is a pure passthrough — deploy it **once** for
+the whole instance, not per site.
+
+## Deploy
+
+```sh
+# 1. Set bucket_name in wrangler.toml to the bucket you use in the editor's
+# "Archive overflow storage" setting (and tune the rate limit if you like).
+cd r2-proxy
+pnpm install # first time only
+pnpm run deploy # wrangler deploy — reuses your host wrangler auth
+```
+
+Then set the editor's **Settings → Archive overflow public URL** to the deployed
+`https://<name>.<you>.workers.dev` URL.
+
+Full setup and the alternative custom-domain path are documented in
+[../DEPLOY_CLOUDFLARE.md](../DEPLOY_CLOUDFLARE.md).
+
+## Scripts
+
+- `pnpm run deploy` — deploy to `workers.dev`
+- `pnpm run dev` — run locally with `wrangler dev`
+- `pnpm run typecheck` — `tsc --noEmit`
+
+This project is intentionally **outside** the pnpm workspace (it has its own
+`node_modules`) so its Cloudflare Worker types don't leak into the apps' typecheck.
diff --git a/r2-proxy/package.json b/r2-proxy/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "ytdlp-archive-proxy",
+ "private": true,
+ "version": "0.0.0",
+ "description": "Cloudflare Worker that serves oversize R2 archive zips on a free workers.dev subdomain, with edge caching + rate limiting. Deploy once for the whole instance.",
+ "scripts": {
+ "deploy": "wrangler deploy",
+ "dev": "wrangler dev",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^4.20250601.0",
+ "typescript": "^5.6.0",
+ "wrangler": "^4.107.0"
+ }
+}
diff --git a/r2-proxy/pnpm-lock.yaml b/r2-proxy/pnpm-lock.yaml
@@ -0,0 +1,888 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ devDependencies:
+ '@cloudflare/workers-types':
+ specifier: ^4.20250601.0
+ version: 4.20260702.1
+ typescript:
+ specifier: ^5.6.0
+ version: 5.9.3
+ wrangler:
+ specifier: ^4.107.0
+ version: 4.107.0(@cloudflare/workers-types@4.20260702.1)
+
+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.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.20260701.1':
+ resolution: {integrity: sha512-Zd9Y1bah6DwwBN2RW8vJohffQrIUazb8UXnqSNecOxM+jJLhUuvv5IOG8dbHcV83TyZAubea6gsQXo2yH1lDdw==}
+ engines: {node: '>=16'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@cloudflare/workerd-darwin-arm64@1.20260701.1':
+ resolution: {integrity: sha512-yBLsjS1qCWqFyCY37qRUrYfzHHvMGvjh8zRKJ6MvUivYDhkZTzqduppK38FoqYvayLJ5KbcxH7zo5rkxGqbsaA==}
+ engines: {node: '>=16'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@cloudflare/workerd-linux-64@1.20260701.1':
+ resolution: {integrity: sha512-vMfqSIMfoo4xmZXEuUVqLpSFS921YKjiR9q7kDXPi6Vld1PK74UHg9LZuBavT2KSyemHUCTpj9y/4JSYOEyQbQ==}
+ engines: {node: '>=16'}
+ cpu: [x64]
+ os: [linux]
+
+ '@cloudflare/workerd-linux-arm64@1.20260701.1':
+ resolution: {integrity: sha512-HRfwbKU2pK44V2NhoM0+iH0JJSj7nQ9Wv13ifIiGYCmTtDL8/zKtEhX7kQ3D4Vy/Cpjhttl0FkfqXj1aqLDPPg==}
+ engines: {node: '>=16'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@cloudflare/workerd-windows-64@1.20260701.1':
+ resolution: {integrity: sha512-ngxCiIN9s/fM2o1IBMD0o1/mcXrv2NJVdyznh51UH8sQuvrTrXvV2nM0Uj/qU2wMwF6prgNBcdcd7AZeZGiBQA==}
+ 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/runtime@1.11.2':
+ resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
+
+ '@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==}
+
+ '@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.17':
+ resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==}
+
+ 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@4.20260701.0:
+ resolution: {integrity: sha512-L6eAAi6IKtyb/7J6L+YsH2vb1yBrJWKRXI293JYDiMl70+6nncdAgigex58w6WBd+CwvdMsqOyNyGs95Op5gWQ==}
+ engines: {node: '>=22.0.0'}
+ hasBin: true
+
+ 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.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
+ 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==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ 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==}
+
+ workerd@1.20260701.1:
+ resolution: {integrity: sha512-uF813NG09JwNRRUfJ0zBomyTslSPM810dMj9LVvkQ7RAkLrQLzAlPU8Xh/3dIqZDo2bfd7tChbf2PtqLRARRJQ==}
+ engines: {node: '>=16'}
+ hasBin: true
+
+ wrangler@4.107.0:
+ resolution: {integrity: sha512-fw69ThymNitZ0oIEBU2yNeq3kK59UKz/jyA3udwRrQIAIsxX57q5qLOpPTN7qc5t8n9pnUeofe0uxtMuhQZW8w==}
+ engines: {node: '>=22.0.0'}
+ hasBin: true
+ peerDependencies:
+ '@cloudflare/workers-types': ^4.20260701.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.1(unenv@2.0.0-rc.24)(workerd@1.20260701.1)':
+ dependencies:
+ unenv: 2.0.0-rc.24
+ optionalDependencies:
+ workerd: 1.20260701.1
+
+ '@cloudflare/workerd-darwin-64@1.20260701.1':
+ optional: true
+
+ '@cloudflare/workerd-darwin-arm64@1.20260701.1':
+ optional: true
+
+ '@cloudflare/workerd-linux-64@1.20260701.1':
+ optional: true
+
+ '@cloudflare/workerd-linux-arm64@1.20260701.1':
+ optional: true
+
+ '@cloudflare/workerd-windows-64@1.20260701.1':
+ optional: true
+
+ '@cloudflare/workers-types@4.20260702.1': {}
+
+ '@cspotcode/source-map-support@0.8.1':
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.9
+
+ '@emnapi/runtime@1.11.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
+
+ '@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.17': {}
+
+ 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@4.20260701.0:
+ dependencies:
+ '@cspotcode/source-map-support': 0.8.1
+ sharp: 0.34.5
+ undici: 7.28.0
+ workerd: 1.20260701.1
+ ws: 8.21.0
+ youch: 4.1.0-beta.10
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ path-to-regexp@6.3.0: {}
+
+ pathe@2.0.3: {}
+
+ 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
+
+ supports-color@10.2.2: {}
+
+ tslib@2.8.1:
+ optional: true
+
+ typescript@5.9.3: {}
+
+ undici@7.28.0: {}
+
+ unenv@2.0.0-rc.24:
+ dependencies:
+ pathe: 2.0.3
+
+ workerd@1.20260701.1:
+ optionalDependencies:
+ '@cloudflare/workerd-darwin-64': 1.20260701.1
+ '@cloudflare/workerd-darwin-arm64': 1.20260701.1
+ '@cloudflare/workerd-linux-64': 1.20260701.1
+ '@cloudflare/workerd-linux-arm64': 1.20260701.1
+ '@cloudflare/workerd-windows-64': 1.20260701.1
+
+ wrangler@4.107.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.20260701.1)
+ blake3-wasm: 2.1.5
+ esbuild: 0.28.1
+ miniflare: 4.20260701.0
+ path-to-regexp: 6.3.0
+ unenv: 2.0.0-rc.24
+ workerd: 1.20260701.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/r2-proxy/pnpm-workspace.yaml b/r2-proxy/pnpm-workspace.yaml
@@ -0,0 +1,7 @@
+# Present so this Worker installs as its own pnpm project, isolated from the
+# repo's root workspace (its Cloudflare Worker types must not leak into the apps'
+# typecheck). Allow the toolchain's native build scripts to run non-interactively.
+allowBuilds:
+ esbuild: true
+ sharp: true
+ workerd: true
diff --git a/r2-proxy/src/index.ts b/r2-proxy/src/index.ts
@@ -0,0 +1,126 @@
+/// <reference types="@cloudflare/workers-types" />
+
+// Cloudflare Worker: public read proxy for the oversize archive zips stored in R2.
+//
+// ONE Worker serves EVERY export site. The R2 key is namespaced by site id
+// (`<siteId>/archives/<file>.zip`), so this is a pure passthrough that maps the
+// request path straight to the bucket key — there is nothing per-site about it.
+// Deploy it ONCE to a free `<name>.workers.dev` subdomain (no custom domain, no
+// domain purchase, no WHOIS), then point every site's "Archive overflow public
+// URL" at that single subdomain. See ../DEPLOY_CLOUDFLARE.md.
+//
+// Why a Worker instead of the raw r2.dev URL: it gives us tunable, in-code rate
+// limiting (the cost/abuse backstop — Cloudflare's dashboard rate-limit rules
+// require a zone/custom domain) plus our own edge caching, all on the free tier.
+
+interface RateLimiter {
+ limit(options: { key: string }): Promise<{ success: boolean }>;
+}
+
+export interface Env {
+ BUCKET: R2Bucket;
+ RATE_LIMITER: RateLimiter;
+}
+
+// Only ever serve archive zips: "<siteId>/archives/<file>.zip". Anything else
+// 404s, so the proxy can't be turned into a general read oracle over the bucket.
+const KEY_RE = /^[A-Za-z0-9._-]+\/archives\/[A-Za-z0-9._-]+\.zip$/;
+
+export default {
+ async fetch(
+ request: Request,
+ env: Env,
+ ctx: ExecutionContext,
+ ): Promise<Response> {
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ return new Response("Method not allowed", {
+ status: 405,
+ headers: { allow: "GET, HEAD" },
+ });
+ }
+
+ const url = new URL(request.url);
+ const key = decodeURIComponent(url.pathname.replace(/^\/+/, ""));
+ if (!KEY_RE.test(key)) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ // Per-IP + per-file rate limit (per edge location) — the abuse/cost backstop.
+ const ip = request.headers.get("cf-connecting-ip") ?? "anon";
+ const { success } = await env.RATE_LIMITER.limit({ key: `${ip}:${key}` });
+ if (!success) {
+ return new Response("Too many requests", {
+ status: 429,
+ headers: { "retry-after": "60" },
+ });
+ }
+
+ const isRange = request.headers.has("range");
+ const cache = caches.default;
+ const cacheKey = new Request(url.toString(), { method: "GET" });
+
+ // Full (non-range) requests can be served from — and stored in — the edge
+ // cache, so repeat downloads skip R2 entirely (no billable Class B op).
+ if (!isRange) {
+ const cached = await cache.match(cacheKey);
+ if (cached) {
+ return request.method === "HEAD"
+ ? new Response(null, { status: cached.status, headers: cached.headers })
+ : cached;
+ }
+ }
+
+ const object = await env.BUCKET.get(key, {
+ range: request.headers,
+ onlyIf: request.headers,
+ });
+ if (object === null) {
+ return new Response("Not found", { status: 404 });
+ }
+
+ const headers = new Headers();
+ object.writeHttpMetadata(headers);
+ headers.set("etag", object.httpEtag);
+ headers.set("accept-ranges", "bytes");
+ if (!headers.has("cache-control")) {
+ headers.set("cache-control", "public, max-age=3600");
+ }
+ if (!headers.has("content-type")) {
+ headers.set("content-type", "application/zip");
+ }
+
+ // No body ⇒ an onlyIf precondition matched (e.g. If-None-Match) ⇒ 304.
+ const body = "body" in object ? (object as R2ObjectBody).body : null;
+ if (!body) {
+ return new Response(null, { status: 304, headers });
+ }
+
+ let status = 200;
+ const range = (object as R2ObjectBody).range as
+ | { offset?: number; length?: number }
+ | undefined;
+ if (isRange && range && typeof range.offset === "number") {
+ const offset = range.offset;
+ const length =
+ typeof range.length === "number" ? range.length : object.size - offset;
+ headers.set("content-range", `bytes ${offset}-${offset + length - 1}/${object.size}`);
+ headers.set("content-length", String(length));
+ status = 206;
+ } else {
+ headers.set("content-length", String(object.size));
+ }
+
+ const response = new Response(request.method === "HEAD" ? null : body, {
+ status,
+ headers,
+ });
+
+ // Cache full 200 GET responses at the edge (best-effort — Cloudflare skips
+ // ones that are too large or otherwise non-cacheable). Never cache 206.
+ if (status === 200 && request.method === "GET") {
+ ctx.waitUntil(cache.put(cacheKey, response.clone()).catch(() => {}));
+ }
+
+ return response;
+ },
+};
diff --git a/r2-proxy/tsconfig.json b/r2-proxy/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022"],
+ "types": ["@cloudflare/workers-types"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/r2-proxy/wrangler.toml b/r2-proxy/wrangler.toml
@@ -0,0 +1,28 @@
+# Cloudflare Worker that serves oversize archive zips from R2 on a free
+# *.workers.dev subdomain. Deploy it ONCE for the whole instance (NOT per site):
+#
+# cd r2-proxy && pnpm dlx wrangler deploy
+#
+# One bucket + one Worker serves every export site (keys are namespaced by site
+# id). See ../DEPLOY_CLOUDFLARE.md for the full setup.
+
+name = "ytdlp-archive-proxy"
+main = "src/index.ts"
+compatibility_date = "2025-06-01"
+workers_dev = true
+
+# The R2 bucket holding oversize archives — set bucket_name to the SAME bucket
+# you entered in the editor's "Archive overflow storage" setting.
+[[r2_buckets]]
+binding = "BUCKET"
+bucket_name = "your-archives-bucket"
+
+# Native per-edge rate limiting (free, no extra infrastructure). 60 requests per
+# 60s per client IP + file. Tune `limit` to taste; `period` must be 10 or 60.
+# (wrangler prints an "unsafe fields are experimental" warning for this binding —
+# that is expected; the Rate Limiting binding lives under `unsafe` for now.)
+[[unsafe.bindings]]
+name = "RATE_LIMITER"
+type = "ratelimit"
+namespace_id = "1001"
+simple = { limit = 60, period = 60 }