summaryrefslogtreecommitdiff
path: root/workers
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@vyos.io>2026-09-25 17:11:19 +0200
committerGitHub <noreply@github.com>2026-09-25 16:11:19 +0100
commita783e56b774795c1f4bd8a6088dfcce96a307c5f (patch)
treedcd0c88ff02969351840c8222ea97c72d9258e18 /workers
parent4c8d1d1a0f23e2106a97f4b672607121ff03a091 (diff)
downloadvyos-documentation-a783e56b774795c1f4bd8a6088dfcce96a307c5f.tar.gz
vyos-documentation-a783e56b774795c1f4bd8a6088dfcce96a307c5f.zip
ci: port the Cloudflare Workers docs pipeline to circinus (slug 1.5) (#2208)
* ci: port Cloudflare Workers docs pipeline files to circinus (verbatim) Copies the branch-agnostic half of the docs.vyos.io Cloudflare Workers pipeline from `rolling` at 8cb568bf, byte-identical: - .github/workflows/docs-build.yml - workers/ (entire tree) - scripts/docs_gates/ - docker/im-convert.sh - docs/_static/js/version-picker.js, js/pagefind-wrapper.js, css/version-picker.css (new files, no circinus counterpart) - docs/_templates/breadcrumbs.html, searchbox.html (new files) docs-build.yml already triggers on push to [rolling, circinus, sagitta] and resolves `circinus` -> worker vyos-docs-v15-en / slug 1.5 from workers/matrix.json; the files simply did not exist on this branch, so slug 1.5 still serves the bootstrap placeholder. workers/versions.json + workers/matrix.json are deliberately identical across all three branches and must be kept in sync. Advances: IS-572 * ci: wire circinus docs build into the Cloudflare Workers pipeline Hand-merges the CF-specific hunks onto circinus's own docker/Dockerfile and docs/conf.py rather than clobbering them with rolling's versions β€” circinus keeps its own content-driven history in both files. docker/Dockerfile: - imagemagick + librsvg2-bin (sphinx.ext.imgconverter backend) and poppler-utils (pdfinfo, used by docs-build.yml's PDF page-count completeness check), installed --no-install-recommends - install docker/im-convert.sh as /usr/local/bin/im-convert docs/conf.py: - enable sphinx.ext.imgconverter + image_converter = 'im-convert' so the LaTeX/PDF builder stops silently dropping .webp/.svg images - register js/version-picker.js + css/version-picker.css unconditionally (degrades silently on ReadTheDocs) - _vyos_cf_build gate off the raw DOCS_VERSION_SLUG env var; only CF builds load js/pagefind-wrapper.js, and html_context['vyos_cf_build'] lets _templates/searchbox.html fall back to the stock Sphinx searchbox via the "!" bang-include on RTD The RTD path stays the default in both files: circinus continues building on ReadTheDocs until RTD sunset, and every CF feature activates only when DOCS_VERSION_SLUG is present. .readthedocs.yml is untouched. circinus keeps its own version/release/html_title/source_suffix and its hardcoded html_baseurl (its CF slug is also `1.5`, so rolling's DOCS_VERSION_SLUG/READTHEDOCS_VERSION resolution block is a no-op here and was deliberately not ported). Advances: IS-572 * ci: IS-572: re-sync ported Cloudflare Workers pipeline files with rolling The category-1 files in this port are byte-identical copies from `rolling`. `rolling` has since moved: [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) merged as `3a1c6c30`, thirteen rounds of hardening on exactly these files. Re-take all 14 category-1 paths from `origin/rolling` via `git checkout origin/rolling -- <paths>`, so byte-identity holds by construction rather than by hand-editing: .github/workflows/docs-build.yml scripts/docs_gates/{gates,parity,smoke,test_gates,test_parity,test_smoke}.py workers/.gitignore workers/apex/src/{index,special,uagate}.ts workers/apex/test/{router,uagate}.test.ts workers/apex/ua-policy.json Thirteen of the fourteen carry [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) exactly β€” the pre-change tree was byte-identical to `3a1c6c30^` for those paths. `workers/.gitignore` additionally picks up the one-line `test-results/` entry from [vyos-documentation#2212](https://github.com/vyos/vyos-documentation/pull/2212); inert on circinus, since only the deliberately-unported `apex-deploy.yml` writes that directory. Deliberate exclusions are unchanged: `docs-canary-qa.yml` (cron runs on the default branch only, so it is not ported even though [vyos-documentation#2209](https://github.com/vyos/vyos-documentation/pull/2209) touched it on `rolling`), `apex-deploy.yml`, and the `docs-preview-*` workflows. `docs/conf.py` stays hand-merged and circinus-specific, with its ReadTheDocs fallback intact. πŸ€– Generated by [robots](https://vyos.io)
Diffstat (limited to 'workers')
-rw-r--r--workers/.gitignore4
-rw-r--r--workers/PLAN.md5
-rw-r--r--workers/apex/assets/404.html23
-rw-r--r--workers/apex/assets/503.html24
-rw-r--r--workers/apex/assets/apple-touch-icon.pngbin0 -> 4128 bytes
-rw-r--r--workers/apex/assets/favicon.icobin0 -> 766 bytes
-rw-r--r--workers/apex/assets/robots.txt3
-rw-r--r--workers/apex/assets/root.html30
-rw-r--r--workers/apex/src/dispatch.ts15
-rw-r--r--workers/apex/src/index.ts561
-rw-r--r--workers/apex/src/manifest.ts55
-rw-r--r--workers/apex/src/redirects.ts38
-rw-r--r--workers/apex/src/special.ts60
-rw-r--r--workers/apex/src/uagate.ts48
-rw-r--r--workers/apex/test/dispatch.test.ts29
-rw-r--r--workers/apex/test/manifest.test.ts135
-rw-r--r--workers/apex/test/redirects.test.ts54
-rw-r--r--workers/apex/test/router.test.ts1067
-rw-r--r--workers/apex/test/uagate.test.ts102
-rw-r--r--workers/apex/ua-policy.json5
-rw-r--r--workers/apex/wrangler.jsonc39
-rwxr-xr-xworkers/bootstrap.sh29
-rw-r--r--workers/branch/src/index.ts83
-rw-r--r--workers/branch/test/content.test.ts278
-rw-r--r--workers/branch/wrangler.legacy.jsonc16
-rw-r--r--workers/branch/wrangler.rolling.jsonc16
-rw-r--r--workers/branch/wrangler.v14.jsonc16
-rw-r--r--workers/branch/wrangler.v15.jsonc16
-rw-r--r--workers/matrix.json5
-rw-r--r--workers/package-lock.json2843
-rw-r--r--workers/package.json16
-rw-r--r--workers/picker-test/pagefind-wrapper.test.ts42
-rw-r--r--workers/picker-test/picker.test.ts156
-rw-r--r--workers/preview/src/index.ts61
-rw-r--r--workers/preview/test/preview.test.ts93
-rw-r--r--workers/preview/wrangler.jsonc11
-rw-r--r--workers/versions.json19
-rw-r--r--workers/vitest.config.ts14
38 files changed, 6011 insertions, 0 deletions
diff --git a/workers/.gitignore b/workers/.gitignore
new file mode 100644
index 00000000..4b240bc1
--- /dev/null
+++ b/workers/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.wrangler/
+dist/
+test-results/
diff --git a/workers/PLAN.md b/workers/PLAN.md
new file mode 100644
index 00000000..c64cd382
--- /dev/null
+++ b/workers/PLAN.md
@@ -0,0 +1,5 @@
+# Cloudflare account entitlements (docs.vyos.io)
+
+- Plan: Workers Paid (pay-as-you-go); zone Enterprise on vyos.io ← from Phase-0 item 0.2; update on change
+- Static-assets file cap in force: 100,000/version (Paid) β€” sanity gates assume this (scripts/docs_gates/limits.py)
+- Plan-dependent features NOT assumed by config: Logpush, Bot Management, Observability retention
diff --git a/workers/apex/assets/404.html b/workers/apex/assets/404.html
new file mode 100644
index 00000000..6beae739
--- /dev/null
+++ b/workers/apex/assets/404.html
@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Page not found β€” VyOS Documentation</title>
+<meta name="robots" content="noindex">
+<style>
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: #16181d; color: #e8e8e8; display: flex; min-height: 100vh;
+ margin: 0; align-items: center; justify-content: center; }
+ main { text-align: center; max-width: 32rem; padding: 2rem; }
+ h1 { font-size: 2.5rem; margin: 0 0 0.5rem; color: #FFAE12; }
+ p { line-height: 1.5; }
+ a { color: #FFAE12; }
+</style>
+</head>
+<body>
+<main>
+ <h1>404</h1>
+ <p>This page doesn't exist (or moved). Try the <a href="/en/rolling/">latest VyOS documentation</a>.</p>
+</main>
+</body>
+</html>
diff --git a/workers/apex/assets/503.html b/workers/apex/assets/503.html
new file mode 100644
index 00000000..60fdc6cf
--- /dev/null
+++ b/workers/apex/assets/503.html
@@ -0,0 +1,24 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Service unavailable β€” VyOS Documentation</title>
+<meta name="robots" content="noindex">
+<style>
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: #16181d; color: #e8e8e8; display: flex; min-height: 100vh;
+ margin: 0; align-items: center; justify-content: center; }
+ main { text-align: center; max-width: 32rem; padding: 2rem; }
+ h1 { font-size: 2.5rem; margin: 0 0 0.5rem; color: #FFAE12; }
+ p { line-height: 1.5; }
+ a { color: #FFAE12; }
+</style>
+</head>
+<body>
+<main>
+ <h1>503</h1>
+ <p>This version of the documentation is temporarily unavailable. Please try again shortly, or visit
+ <a href="https://vyos.io">vyos.io</a> in the meantime.</p>
+</main>
+</body>
+</html>
diff --git a/workers/apex/assets/apple-touch-icon.png b/workers/apex/assets/apple-touch-icon.png
new file mode 100644
index 00000000..82b8fb6b
--- /dev/null
+++ b/workers/apex/assets/apple-touch-icon.png
Binary files differ
diff --git a/workers/apex/assets/favicon.ico b/workers/apex/assets/favicon.ico
new file mode 100644
index 00000000..42e8969e
--- /dev/null
+++ b/workers/apex/assets/favicon.ico
Binary files differ
diff --git a/workers/apex/assets/robots.txt b/workers/apex/assets/robots.txt
new file mode 100644
index 00000000..6f35aa9f
--- /dev/null
+++ b/workers/apex/assets/robots.txt
@@ -0,0 +1,3 @@
+User-agent: *
+Allow: /
+Sitemap: https://docs.vyos.io/sitemap.xml
diff --git a/workers/apex/assets/root.html b/workers/apex/assets/root.html
new file mode 100644
index 00000000..4b266fbf
--- /dev/null
+++ b/workers/apex/assets/root.html
@@ -0,0 +1,30 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>VyOS Documentation</title>
+<meta http-equiv="refresh" content="0; url=/en/rolling/">
+<link rel="canonical" href="/en/rolling/">
+<style>
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: #16181d; color: #e8e8e8; display: flex; min-height: 100vh;
+ margin: 0; align-items: center; justify-content: center; }
+ main { text-align: center; max-width: 32rem; padding: 2rem; }
+ h1 { font-size: 1.75rem; margin: 0 0 0.5rem; color: #FFAE12; }
+ a { color: #FFAE12; }
+</style>
+</head>
+<body>
+<!--
+ Fallback static shell for "/" β€” the Worker normally issues a 301 to
+ /en/<default_version>/ (see specialPathFor in src/special.ts) before this
+ file is ever reached. Kept as a static asset so the root path degrades to
+ a working, on-brand page (client-side redirect) if it is ever served
+ directly from the ASSETS binding without passing through the Worker.
+-->
+<main>
+ <h1>VyOS Documentation</h1>
+ <p>Redirecting to the <a href="/en/rolling/">latest documentation</a>&hellip;</p>
+</main>
+</body>
+</html>
diff --git a/workers/apex/src/dispatch.ts b/workers/apex/src/dispatch.ts
new file mode 100644
index 00000000..9ad1f15d
--- /dev/null
+++ b/workers/apex/src/dispatch.ts
@@ -0,0 +1,15 @@
+export function resolveVersion(
+ pathname: string,
+ dispatch: Map<string, string>,
+): { slug: string; binding: string } | null {
+ const m = pathname.match(/^\/en\/([^/]+)\//);
+ if (!m) return null;
+ const binding = dispatch.get(m[1]);
+ return binding ? { slug: m[1], binding } : null;
+}
+
+export function bindingGuard(env: Record<string, unknown>, binding: string): Fetcher | null {
+ const b = env[binding];
+ if (b && typeof (b as Fetcher).fetch === "function") return b as Fetcher;
+ return null;
+}
diff --git a/workers/apex/src/index.ts b/workers/apex/src/index.ts
new file mode 100644
index 00000000..f97b4bc5
--- /dev/null
+++ b/workers/apex/src/index.ts
@@ -0,0 +1,561 @@
+import { loadManifest, buildDispatch } from "./manifest";
+import { resolveVersion, bindingGuard } from "./dispatch";
+import { redirectFor } from "./redirects";
+import { specialPathFor } from "./special";
+import { uaVerdict } from "./uagate";
+import policy from "../ua-policy.json";
+
+export interface ApexEnv extends Record<string, unknown> {
+ ASSETS: Fetcher;
+ APEX_BUILD_SHA: string;
+ DOCS_ENV: "production" | "canary";
+ DOCS_KB?: Fetcher;
+ // Β§5 apex PDF fallback β€” R2 bucket holding oversized legacy PDFs excluded from the
+ // content Worker's own asset tree (currently just the 1.3 PDF). Optional so the
+ // binding-guard path (503, not a crash) exercises on envs that omit it.
+ DOCS_PDFS?: R2Bucket;
+}
+
+const manifest = loadManifest();
+const dispatch = buildDispatch(manifest);
+
+// Security headers only β€” safe on content pass-through (never touches
+// Cache-Control or X-Docs-Build, which the content Worker owns).
+function securityHeaders(resp: Response): Response {
+ const out = new Response(resp.body, resp);
+ out.headers.set("X-Content-Type-Options", "nosniff");
+ out.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
+ out.headers.set("Content-Security-Policy-Report-Only", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'");
+ return out;
+}
+
+const DEFAULT_CACHE_CLASS = "public, max-age=0, s-maxage=300, must-revalidate";
+
+function apexHeaders(resp: Response, env: ApexEnv, cacheClass: string = DEFAULT_CACHE_CLASS): Response {
+ const out = securityHeaders(resp);
+ out.headers.set("X-Apex-Build", env.APEX_BUILD_SHA);
+ // Β§3.3 cache contract applies to apex-owned responses too. Error responses (4xx/5xx)
+ // must never carry the s-maxage cache class β€” the cache key excludes User-Agent, so a
+ // cached UA-gate 403 or themed 404/503 would poison the edge for every visitor for the
+ // full s-maxage window. Mirrors the branch worker's withDocsHeaders() precedence.
+ // `cacheClass` lets a specific caller (e.g. the Β§5 PDF R2 fallback) apply a
+ // differently-classed success cache-control; canary/error still always win.
+ out.headers.set(
+ "Cache-Control",
+ env.DOCS_ENV === "canary" || out.status >= 400 ? "no-store" : cacheClass,
+ );
+ return out;
+}
+
+// R2's `R2Range` is a three-shape union β€” `{offset, length?}`, `{length}` (offset implicitly 0)
+// and `{suffix}` (the trailing N bytes) β€” so `"offset" in range` is NOT a safe way to read it:
+// the two offset-less shapes would fall through to the full-object 200 branch and be served
+// with a Content-Length claiming the whole object while the body held only a slice. workerd is
+// observed to normalize every shape to `{offset, length}` before it reaches us, but the type
+// admits the others, so resolve all three to concrete byte bounds, clamped to the object size.
+// Exported for direct unit testing.
+export function resolveRange(
+ range: { offset?: number; length?: number; suffix?: number },
+ size: number,
+): { start: number; length: number } {
+ if (typeof range.suffix === "number") {
+ const suffix = Math.min(Math.max(range.suffix, 0), size); // a suffix past the start is the whole object
+ return { start: size - suffix, length: suffix };
+ }
+ const start = Math.min(Math.max(range.offset ?? 0, 0), size);
+ // The trailing Math.max(_, 0) keeps the documented "clamped to the object size" contract
+ // total: without it a negative `range.length` would pass straight through Math.min and
+ // yield a negative length (and so a negative Content-Length). A real R2 binding cannot
+ // produce that β€” see classifyRangeHeader's note on observed R2 behaviour β€” but this
+ // function is exported and unit-tested as a standalone utility over the R2Range union, so
+ // it should not have a documented invariant its own signature can violate. Deliberately
+ // NOT guarding non-finite inputs: NaN bounds are unreachable from the binding and the
+ // guard would be untestable-in-anger dead weight.
+ const length = Math.max(Math.min(range.length ?? size - start, size - start), 0);
+ return { start, length };
+}
+
+// A single `bytes=` range-spec. Anything with a comma is a multi-range and deliberately
+// fails to match. The whitespace class is `\s`, which is DELIBERATELY wider than the ` `
+// (ASCII space) that R2's own parser accepts β€” see classifyRangeHeader's contract note on
+// why the two grammars are allowed to disagree.
+const SINGLE_BYTE_RANGE = /^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$/i;
+
+/**
+ * Numeric comparison of two non-empty digit strings, without going through Number().
+ *
+ * A range-spec's positions are unbounded digit strings, and Number() silently rounds
+ * anything above 2^53: `Number("9007199254740993") === Number("9007199254740992")`, which
+ * collapsed `bytes=9007199254740993-9007199254740992` β€” an invalid spec (last < first)
+ * that Β§14.1.2 says to IGNORE, so 200 β€” into an apparently-valid one that then read as
+ * unsatisfiable and answered 416. Comparing normalised digit strings by length and then
+ * lexically is exact at every magnitude.
+ */
+function cmpDigits(a: string, b: string): number {
+ const x = a.replace(/^0+(?=\d)/, "");
+ const y = b.replace(/^0+(?=\d)/, "");
+ if (x.length !== y.length) return x.length - y.length;
+ return x < y ? -1 : x > y ? 1 : 0;
+}
+
+export type RangeIntent =
+ | { kind: "ignored" }
+ | { kind: "unsatisfiable" }
+ | { kind: "single"; start: number; length: number };
+
+/**
+ * What the client's Range header ASKS FOR, judged against the representation length.
+ *
+ * This exists because R2 does not tell us. Probed against a real R2 binding under
+ * vitest-pool-workers, `get(key, {range: <Headers>})` signals "I ignored your Range" by
+ * returning the WHOLE object with `range = {offset: 0, length: size}` β€” the byte-for-byte
+ * same shape it returns for a legitimately-satisfied whole-object range like `bytes=0-`.
+ * It does this for every unsatisfiable spec (`bytes=10-` / `bytes=99-` / `bytes=-0` on a
+ * 10-byte object), every malformed one (`bytes=abc`, `bytes=-`, `bytes=5-2`), multi-ranges
+ * (`bytes=0-1,4-5`) and unknown units (`items=0-5`). It does NOT throw for any of them and
+ * it never returns a zero/negative length except for a genuinely zero-length object.
+ * (The object-literal form `get(key, {range: {offset: 99}})` DOES throw
+ * "The requested range is not satisfiable (10039)" β€” but this Worker passes Headers, so
+ * that path is unreachable here.)
+ *
+ * Trusting `obj.range` alone therefore answered `Range: bytes=99-` with
+ * `206 + Content-Range: bytes 0-9/10` and the FULL body β€” a 206 that does not correspond to
+ * the request (RFC 9110 Β§15.3.7). That is actively dangerous for the resuming downloader
+ * this range forwarding exists to serve: a client resuming at byte 99 would append bytes
+ * 0-9 to its partial file and silently corrupt it. Re-deriving intent from the client's own
+ * header is the only way to separate the three cases.
+ *
+ * Satisfiability follows RFC 9110 Β§14.1.2 verbatim: an int-range is satisfiable iff
+ * first-pos < length; a suffix-range iff suffix-length is non-zero (so on a zero-length
+ * representation, a non-zero suffix-range is the ONLY satisfiable form). An invalid spec
+ * (last-pos < first-pos) MUST be ignored rather than rejected, hence "ignored", not
+ * "unsatisfiable".
+ *
+ * The `single` verdict carries the CONCRETE byte bounds the client asked for, clamped the
+ * way Β§14.1.2 clamps them. That is what makes this classifier safe to disagree with R2's
+ * parser. The two grammars are not identical and cannot be kept identical: R2's accepts
+ * only ASCII space around the tokens (miniflare's `/^ *(\d+)? *- *(\d+)? *$/`) while this
+ * one accepts `\s`, so `Range: bytes=2<TAB>-<TAB>4` parses here and is ignored there. When
+ * a caller compares these bounds against the bytes R2 actually handed back, any such
+ * divergence β€” this one, or the next one a parser change introduces β€” degrades to a plain
+ * 200 instead of a 206 whose Content-Range describes a body the client did not ask for.
+ * Chasing byte-for-byte grammar parity would put the guarantee back in the hands of two
+ * regexes staying in sync, which is the coupling that produced the bug.
+ */
+export function classifyRangeHeader(header: string, size: number): RangeIntent {
+ const m = SINGLE_BYTE_RANGE.exec(header);
+ if (!m) return { kind: "ignored" }; // multi-range, unknown unit, or unparseable
+ const [, firstRaw, lastRaw] = m;
+ if (firstRaw === "") {
+ if (lastRaw === "") return { kind: "ignored" }; // bare "bytes=-" is malformed
+ // Β§14.1.2: suffix-length 0 is unsatisfiable; a suffix past the start is the whole object.
+ if (cmpDigits(lastRaw, "0") <= 0) return { kind: "unsatisfiable" };
+ const suffix = Math.min(Number(lastRaw), size);
+ return { kind: "single", start: size - suffix, length: suffix };
+ }
+ // Β§14.1.2: an invalid spec (last-pos < first-pos) is ignored, not rejected.
+ if (lastRaw !== "" && cmpDigits(lastRaw, firstRaw) < 0) return { kind: "ignored" };
+ if (cmpDigits(firstRaw, String(size)) >= 0) return { kind: "unsatisfiable" };
+ const first = Number(firstRaw); // < size, so within safe-integer range
+ const last = lastRaw === "" ? size - 1 : Math.min(Number(lastRaw), size - 1);
+ return { kind: "single", start: first, length: last - first + 1 };
+}
+
+/** A quoted entity-tag list (`"a", W/"b"`) or `*`, compared per RFC 9110 Β§8.8.3.2. */
+function etagListMatches(list: string, etag: string, compare: "strong" | "weak"): boolean {
+ const items = list.split(",").map((s) => s.trim()).filter((s) => s !== "");
+ if (items.includes("*")) return true; // "*" matches iff a representation exists β€” one does
+ const weaken = (t: string) => t.replace(/^W\//, "");
+ // Strong comparison: neither side may be weak (Β§8.8.3.2).
+ if (compare === "strong" && etag.startsWith("W/")) return false;
+ return items.some((t) =>
+ compare === "strong" ? t === etag : weaken(t) === weaken(etag),
+ );
+}
+
+/**
+ * `uploaded <= <HTTP-date>`, at seconds granularity, or null when the date is unparseable
+ * (Β§13.1.3: an invalid date MUST be ignored, which callers map to "precondition passes").
+ * Seconds granularity matches R2's own comparison, which the Headers form of `onlyIf`
+ * selects β€” evaluating at millisecond precision here would disagree with the binding that
+ * produced the failure we are trying to name.
+ */
+function uploadedAtOrBefore(uploaded: Date | undefined, httpDate: string): boolean | null {
+ const at = Date.parse(httpDate);
+ if (Number.isNaN(at) || !uploaded) return null;
+ return Math.floor(uploaded.getTime() / 1000) <= Math.floor(at / 1000);
+}
+
+/**
+ * The conditional headers R2 is allowed to see, filtered to those RFC 9110 Β§13.2.2 says
+ * actually apply to THIS request.
+ *
+ * R2 ANDs together every validator it is handed; Β§13.2.2 instead defines a precedence in
+ * which a lower-ranked validator is not evaluated at all. Forwarding `request.headers`
+ * wholesale therefore let R2 fail a request on a validator the RFC says to ignore β€” most
+ * visibly `If-Modified-Since` on a non-GET/HEAD method, which Β§13.2.2 step 4 does not
+ * evaluate, but which R2 evaluated anyway and answered with a body-less object that this
+ * Worker could only turn into a 412. Filtering at the source means a body-less result now
+ * always corresponds to a precondition that genuinely applies.
+ *
+ * The METHOD decides this before any header does. Β§13.2.1: "a server MUST ignore the
+ * conditional request header fields defined by this specification when received with a
+ * request method that does not involve the selection or modification of a selected
+ * representation, such as CONNECT, OPTIONS, or TRACE." Filtering by validator applicability
+ * alone still handed those methods' conditionals to R2, so an OPTIONS carrying a stale
+ * `If-Match` a client had left lying around was refused 412 where the same request without
+ * it succeeded. Returning an empty set here is what "ignore" means at this layer: R2 is
+ * given nothing to evaluate, so it cannot answer body-less, so preconditionStatus() β€” which
+ * is only ever reached from a body-less result β€” is unreachable for these methods too.
+ */
+function applicablePreconditions(h: Headers, method: string): Headers {
+ const out = new Headers();
+ if (method === "OPTIONS" || method === "TRACE" || method === "CONNECT") return out;
+ const isGetOrHead = method === "GET" || method === "HEAD";
+ const ifMatch = h.get("if-match");
+ const ifNoneMatch = h.get("if-none-match");
+ if (ifMatch !== null) out.set("if-match", ifMatch);
+ else {
+ const ius = h.get("if-unmodified-since"); // Β§13.2.2 step 2: only when If-Match is absent
+ if (ius !== null) out.set("if-unmodified-since", ius);
+ }
+ if (ifNoneMatch !== null) out.set("if-none-match", ifNoneMatch);
+ else if (isGetOrHead) {
+ const ims = h.get("if-modified-since"); // step 4: only when If-None-Match is absent, GET/HEAD only
+ if (ims !== null) out.set("if-modified-since", ims);
+ }
+ return out;
+}
+
+/**
+ * Which status a FAILED `onlyIf` owes the client, decided by re-evaluating the request's
+ * conditionals against the object's own validators in RFC 9110 Β§13.2.2 order.
+ *
+ * R2 reports THAT a precondition failed and never WHICH one. Inferring from header
+ * PRESENCE cannot be right in both directions, which is how `If-Match: "x"` +
+ * `If-None-Match: "x"` on a matching object β€” If-Match satisfied, If-None-Match failed,
+ * so Β§13.1.2 owes a 304 β€” came back 412 purely because an If-Match header was present.
+ * Evaluating the validators removes the guess: presence selects which check runs, the
+ * comparison decides the answer.
+ */
+export function preconditionStatus(
+ h: Headers, isGetOrHead: boolean, etag: string, uploaded: Date | undefined,
+): 304 | 412 {
+ const ifMatch = h.get("if-match");
+ if (ifMatch !== null) {
+ if (!etagListMatches(ifMatch, etag, "strong")) return 412; // Β§13.2.2 step 1
+ } else {
+ const ius = h.get("if-unmodified-since"); // step 2
+ if (ius !== null && uploadedAtOrBefore(uploaded, ius) === false) return 412;
+ }
+ const ifNoneMatch = h.get("if-none-match");
+ if (ifNoneMatch !== null) {
+ // Β§13.1.2: a failed If-None-Match is 304 for GET/HEAD and 412 for every other method.
+ if (etagListMatches(ifNoneMatch, etag, "weak")) return isGetOrHead ? 304 : 412;
+ } else if (isGetOrHead) {
+ const ims = h.get("if-modified-since"); // step 4
+ if (ims !== null && uploadedAtOrBefore(uploaded, ims) === true) return 304;
+ }
+ // R2 refused for a reason this evaluation could not reproduce (a validator comparison
+ // that differs at the margins, say). 412 is the safe answer: a 304 would assert a cache
+ // validity we have not established.
+ return 412;
+}
+
+/**
+ * RFC 9110 Β§13.1.5 If-Range: does the client's validator still describe this object?
+ *
+ * R2 cannot answer this β€” its `R2Conditional` carries only etagMatches /
+ * etagDoesNotMatch / uploadedBefore / uploadedAfter, so an `If-Range` in the forwarded
+ * Headers is silently dropped and the range is applied unconditionally. For an object
+ * whose ETag has moved on, that answered `Range: bytes=100-` + `If-Range: "old"` with
+ * bytes 100+ of the NEW representation under a 206 β€” a resuming downloader then appends
+ * the new tail to its old prefix and silently corrupts the file, which is the precise
+ * failure this range forwarding exists to avoid.
+ *
+ * Β§13.1.5 requires a STRONG validator, so a weak entity-tag never matches. The date form
+ * likewise never matches here: it must compare against Last-Modified, and this Worker
+ * does not emit one, so no client can hold a date validator for this resource that we
+ * could honour β€” treating it as a mismatch (serve the complete representation) is both
+ * correct and the safe direction.
+ */
+function ifRangeMatches(value: string, etag: string): boolean {
+ const v = value.trim();
+ if (!v.startsWith('"')) return false; // weak tag or HTTP-date β†’ not a strong match
+ return etagListMatches(v, etag, "strong");
+}
+
+/**
+ * Abandon a body stream this Worker has decided not to send.
+ *
+ * R2 hands back a body on paths whose response carries none. An unsatisfiable Range gets
+ * the COMPLETE object (29.2 MiB for the 1.3 PDF) and is answered 416 with a null body; a
+ * stale `If-Range` gets the sliced range and is answered from a re-read. Dropping the
+ * reference leaves the stream open until GC collects it, holding the connection; cancelling
+ * releases it now and aborts the transfer rather than draining it. Failures are swallowed β€”
+ * this is cleanup on a path whose response is already decided, and a stream that is already
+ * closed or errored is exactly the state we wanted.
+ */
+async function discardBody(body: ReadableStream | null | undefined): Promise<void> {
+ try {
+ await body?.cancel();
+ } catch {
+ /* already closed or errored β€” nothing left to release */
+ }
+}
+
+async function themed(env: ApexEnv, status: 404 | 503): Promise<Response> {
+ const page = await env.ASSETS.fetch(new Request(`https://apex.internal/${status}.html`));
+ return apexHeaders(new Response(page.body, { status, headers: { "content-type": "text/html; charset=utf-8" } }), env);
+}
+
+export default {
+ async fetch(request: Request, env: ApexEnv): Promise<Response> {
+ const url = new URL(request.url);
+
+ // 1. UA gate β€” production only (Β§3.2.1)
+ if (env.DOCS_ENV === "production") {
+ const v = uaVerdict(request.headers.get("user-agent") ?? "", policy);
+ if (v === "block") return apexHeaders(new Response("Forbidden", { status: 403 }), env);
+ if (v === "log") console.log(JSON.stringify({ event: "ua-log", ua: request.headers.get("user-agent"), path: url.pathname }));
+ }
+
+ // 2. Special paths (Β§3.2.2)
+ const special = await specialPathFor(request, manifest, env as never);
+ if (special) return apexHeaders(special, env);
+
+ // 2b. Legacy PDF R2 fallback (spec Β§5) β€” the 1.3 PDF (29.2 MiB) exceeds the 25 MiB
+ // static-asset cap and is excluded from the content Worker's own build. MUST run
+ // before version dispatch (step 6): the legacy Worker's asset tree lacks this file,
+ // so unconditional dispatch would 404 on the exact path the PDF 301 (Β§3.2.4) targets.
+ const pdfVersion = manifest.versions.find((v) => v.pdf_r2_key && v.pdf === url.pathname);
+ if (pdfVersion) {
+ const bucket = env.DOCS_PDFS;
+ if (!bucket) {
+ console.log(JSON.stringify({ event: "binding-missing", binding: "DOCS_PDFS" }));
+ return themed(env, 503);
+ }
+ const method = request.method.toUpperCase();
+ const isGetOrHead = method === "GET" || method === "HEAD";
+ // Β§14.2: "GET is the only method for which range handling is defined" β€” a Range on
+ // any other method MUST be ignored. Reading it as null here suppresses the whole
+ // partial-content path in one place: R2 is never asked to slice, and the 206/416
+ // branches below are unreachable. Gating only the R2 forward would leave the
+ // response side still seeing a Range header and answering a HEAD or a POST with a
+ // 416 or a Content-Range.
+ const rangeHeader = method === "GET" ? request.headers.get("range") : null;
+ const onlyIf = applicablePreconditions(request.headers, method);
+
+ let raw: R2ObjectBody | R2Object | null;
+ try {
+ // Forward Range + the APPLICABLE conditionals so a resumed download or a client
+ // with a fresh cached copy doesn't have to re-pull the full 29.2 MiB object.
+ raw = await bucket.get(pdfVersion.pdf_r2_key!, {
+ ...(rangeHeader !== null ? { range: request.headers } : {}),
+ onlyIf,
+ });
+ } catch (e) {
+ console.log(JSON.stringify({ event: "binding-error", binding: "DOCS_PDFS", error: String(e) }));
+ return themed(env, 503);
+ }
+ if (!raw) return themed(env, 404);
+
+ // Distinct (longer) cache class from apex's default control-response class β€” this
+ // is effectively content, just not content the legacy content Worker can serve.
+ // 304/206 are both <400 so apexHeaders() still applies this class, not "no-store".
+ const pdfCacheClass = "public, max-age=300, s-maxage=600, must-revalidate";
+ const pdfHeaders: Record<string, string> = {
+ etag: raw.httpEtag,
+ "accept-ranges": "bytes",
+ };
+
+ // A FAILED onlyIf precondition makes R2 hand back a body-less R2Object β€” just the
+ // validators, no content β€” and never says which validator failed. Because `onlyIf`
+ // was filtered to the conditionals Β§13.2.2 actually applies to this request, a
+ // body-less result here always means a precondition that genuinely applies failed;
+ // preconditionStatus() re-evaluates them against the object's own validators, in
+ // Β§13.2.2 order, to decide between 304 and 412.
+ if (!("body" in raw) || !raw.body) {
+ const status = preconditionStatus(
+ request.headers, isGetOrHead, raw.httpEtag, raw.uploaded);
+ return apexHeaders(new Response(null, { status, headers: pdfHeaders }), env, pdfCacheClass);
+ }
+ let obj = raw as R2ObjectBody;
+ pdfHeaders["content-type"] = "application/pdf";
+
+ // Β§13.1.5 If-Range, which R2 cannot evaluate (see ifRangeMatches). A failed validator
+ // means the client's partial copy is stale, so the Range is ignored ENTIRELY and the
+ // complete representation is served β€” including for a spec that would otherwise be
+ // unsatisfiable, since the 416 branch below must not fire on a range we have decided
+ // not to honour. R2 has already applied the range at this point, so the whole object
+ // has to be re-read; that costs one extra R2 read on the rare stale-resume path and
+ // nothing at all on the common one.
+ //
+ // The re-read carries the SAME `onlyIf`. Β§13.2.1 requires preconditions to hold for the
+ // representation ultimately selected, and the two reads need not see one object: a bare
+ // re-get answered `Range` + stale `If-Range` + `If-Match: "A"` with a 200 carrying
+ // object B, whose ETag the client had explicitly excluded, whenever the key was
+ // rewritten in between. That rewrite is not hypothetical β€” the legacy snapshot repo's
+ // deploy workflow re-uploads this exact key on its `force_pdf_refresh` input. Re-sending
+ // the conditionals ties the verdict to the bytes actually served, because R2 evaluates
+ // `onlyIf` against the very object it returns; the body-less outcome that produces is
+ // not a gap in this path but the correct answer, resolved by preconditionStatus()
+ // exactly as on the first read. When no conditionals were sent, `onlyIf` is empty and a
+ // body-less result cannot occur, so the common path is untouched.
+ //
+ // A head() before the get() would also expose the validators, and would avoid opening
+ // this slice stream at all β€” but it would put a second round-trip on the path where
+ // If-Range MATCHES, which is the normal resumed download, in exchange for tidying the
+ // rare one where it does not. It would also widen the window this shape keeps narrow:
+ // the object whose validators decide the verdict is the one R2 returns from the same
+ // call. So the get-then-re-read stays, and the slice we are abandoning is cancelled
+ // rather than left to GC.
+ const ifRange = rangeHeader !== null ? request.headers.get("if-range") : null;
+ let rangeApplies = rangeHeader !== null;
+ if (ifRange !== null && !ifRangeMatches(ifRange, obj.httpEtag)) {
+ rangeApplies = false;
+ await discardBody(obj.body);
+ let full: R2ObjectBody | R2Object | null;
+ try {
+ full = await bucket.get(pdfVersion.pdf_r2_key!, { onlyIf });
+ } catch (e) {
+ console.log(JSON.stringify({ event: "binding-error", binding: "DOCS_PDFS", error: String(e) }));
+ return themed(env, 503);
+ }
+ if (!full) return themed(env, 404); // deleted between the two reads
+ if (!("body" in full) || !full.body) {
+ // The key was rewritten between the two reads and the request's preconditions do
+ // not hold for the new representation. Same treatment as a first-read failure, on
+ // the new object's validators β€” never the old ones, which describe a representation
+ // this response is not about.
+ const status = preconditionStatus(
+ request.headers, isGetOrHead, full.httpEtag, full.uploaded);
+ return apexHeaders(new Response(null, {
+ status, headers: { etag: full.httpEtag, "accept-ranges": "bytes" },
+ }), env, pdfCacheClass);
+ }
+ obj = full as R2ObjectBody;
+ pdfHeaders.etag = obj.httpEtag;
+ }
+
+ // A satisfied Range request. R2 echoes the actually-served byte range on `obj.range` β€”
+ // but it does so for FULL gets too: against a real R2 binding under workerd, a get()
+ // whose forwarded Headers carry NO Range header still comes back with
+ // `range = {offset: 0, length: obj.size}`. Keying the 206 off `obj.range` alone therefore
+ // turned every plain GET of the 1.3 PDF into a 206 β€” which is exactly what the nightly
+ // canary sweep observed (`/en/1.3/vyos-documentation.pdf: status=206`) β€” and RFC 9110
+ // Β§15.3.7 only permits a 206 in answer to a request that actually carried a Range header.
+ // So: gate on the REQUEST first, then normalize whatever shape R2 handed back β€” and
+ // then CHECK that the two agree before promising a 206, because "R2 sliced it" and
+ // "R2 handed back everything" are the same shape on the wire.
+ //
+ // What R2 actually handed back, as concrete bounds. `obj.range` is absent only if a
+ // binding declines to report one, in which case the body is the complete object.
+ const actual = obj.range
+ ? resolveRange(obj.range, obj.size)
+ : { start: 0, length: obj.size };
+ const servedWhole = actual.start === 0 && actual.length === obj.size;
+
+ if (rangeApplies && rangeHeader !== null) {
+ const intent = classifyRangeHeader(rangeHeader, obj.size);
+ if (intent.kind === "unsatisfiable") {
+ // Β§14.2: "the server SHOULD send a 416"; Β§15.5.17: a 416 to a byte-range request
+ // SHOULD carry `Content-Range: bytes */<complete-length>`. Deliberately no
+ // content-type β€” there is no PDF payload on this response. 416 is >= 400 so
+ // apexHeaders() forces no-store, which is right: the verdict depends on the
+ // request's Range header and the cache key does not include it.
+ // R2 answers an unsatisfiable Range with the COMPLETE object, so the body being
+ // dropped here is the whole 29.2 MiB one β€” the largest abandoned stream on any
+ // path through this handler.
+ await discardBody(obj.body);
+ return apexHeaders(
+ new Response(null, {
+ status: 416,
+ headers: { etag: pdfHeaders.etag, "accept-ranges": "bytes",
+ "content-range": `bytes */${obj.size}` },
+ }),
+ env,
+ pdfCacheClass,
+ );
+ }
+ // A 206 is owed only when the bytes R2 selected are the bytes the client asked for.
+ // `length === 0` means a zero-length representation (the only way R2 yields it) β€”
+ // e.g. a non-zero suffix-range, which Β§14.1.2 calls satisfiable, against an empty
+ // object. No valid Content-Range exists for an empty selection (Β§14.4 forbids a
+ // last-pos below the first-pos), so a 206 is unrepresentable. Fall through to the
+ // 200: Β§15.5.17's own note records that servers are free to ignore Range and answer
+ // with the complete representation, which for an empty object is exactly this body.
+ if (intent.kind === "single" && intent.length > 0 &&
+ actual.start === intent.start && actual.length === intent.length) {
+ pdfHeaders["content-range"] =
+ `bytes ${intent.start}-${intent.start + intent.length - 1}/${obj.size}`;
+ pdfHeaders["content-length"] = String(intent.length);
+ return apexHeaders(new Response(obj.body, { status: 206, headers: pdfHeaders }), env, pdfCacheClass);
+ }
+ // Otherwise no 206 is owed: either the spec was one Β§14.1.2 says to ignore
+ // (multi-range / malformed / unknown unit), or R2 declined a spec that this parser
+ // accepted β€” the grammar divergence classifyRangeHeader documents. Both leave R2
+ // having returned the complete object, so fall through to the 200 below.
+ }
+
+ // Serve what R2 actually handed back, described truthfully. `servedWhole` is the
+ // normal case and the only one a 200 can describe; a partial body under a 200 would
+ // ship a Content-Length that contradicts it. A partial body reaching HERE β€” past the
+ // agreement check above β€” means R2 sliced to bounds the request did not ask for, and
+ // there is no honest success response left: a 200 would misstate the length, and a
+ // 206 would answer with a range the client never requested, which Β§14.4 does not
+ // permit (Content-Range on a 206 describes the selected range, and no range was
+ // selected). This used to ship that illegal 206.
+ //
+ // So: drop the slice and fail. Re-reading the object for a clean 200 is the other
+ // option Codex offered, but it means a second conditional read with its own
+ // object-rewritten-between-reads handling β€” a copy of the If-Range block above, or a
+ // refactor of that live and currently-correct path β€” bought for a branch that cannot
+ // execute under today's workerd (round 3 established empirically that the Headers
+ // form ignores multi-range and returns the whole object). The log line is the part
+ // that earns its keep: it is the alarm that R2's range semantics have moved, and it
+ // is what would justify writing that re-read for real.
+ if (!servedWhole) {
+ console.log(JSON.stringify({
+ event: "r2-range-divergence", path: url.pathname, size: obj.size,
+ served: `${actual.start}+${actual.length}`, requested: rangeHeader ?? null,
+ }));
+ await discardBody(obj.body);
+ return themed(env, 503);
+ }
+ pdfHeaders["content-length"] = String(obj.size);
+ return apexHeaders(new Response(obj.body, { status: 200, headers: pdfHeaders }), env, pdfCacheClass);
+ }
+
+ // 3+4. Trailing-slash + alias/codename/PDF 301s (Β§3.2.3-4)
+ const redir = redirectFor(url, manifest);
+ if (redir) return apexHeaders(redir, env);
+
+ // 5. /kb seam (Β§3.2.5)
+ if (url.pathname.startsWith("/kb/") || url.pathname === "/kb") {
+ if (env.DOCS_KB) return securityHeaders(await env.DOCS_KB.fetch(request));
+ return themed(env, 404);
+ }
+
+ // 6. Version dispatch (Β§3.2.6)
+ const hit = resolveVersion(url.pathname, dispatch);
+ if (hit) {
+ const fetcher = bindingGuard(env, hit.binding);
+ if (!fetcher) { // 7. runtime binding guard (Β§3.2.7)
+ console.log(JSON.stringify({ event: "binding-missing", binding: hit.binding }));
+ return themed(env, 503);
+ }
+ try {
+ const resp = await fetcher.fetch(request);
+ if (resp.status === 404) return themed(env, 404);
+ return securityHeaders(resp); // Β§3.3: security headers at apex; cache + X-Docs-Build stay content-owned
+ } catch (e) {
+ console.log(JSON.stringify({ event: "binding-error", binding: hit.binding, error: String(e) }));
+ return themed(env, 503);
+ }
+ }
+
+ // 7. Fallback
+ return themed(env, 404);
+ },
+} satisfies ExportedHandler<ApexEnv>;
diff --git a/workers/apex/src/manifest.ts b/workers/apex/src/manifest.ts
new file mode 100644
index 00000000..396f29ed
--- /dev/null
+++ b/workers/apex/src/manifest.ts
@@ -0,0 +1,55 @@
+import raw from "../../versions.json";
+
+export interface VersionEntry {
+ slug: string; label: string;
+ status: "dev" | "lts" | "eol";
+ binding: string; aliases: string[];
+ pdf: string | null;
+ // R2 object key for the apex PDF fallback (spec Β§5) β€” set only on versions whose PDF
+ // exceeds the 25 MiB static-asset cap and is therefore absent from the content
+ // Worker's own asset tree (currently just 1.3). Optional; most versions omit it.
+ pdf_r2_key?: string;
+}
+export interface Manifest {
+ schema_version: number;
+ default_lang: string; default_version: string;
+ languages: { code: string; label: string }[];
+ versions: VersionEntry[];
+}
+
+// Split out from loadManifest() so tests can validate synthetic manifests without
+// touching the real ../../versions.json import (which loadManifest() is hardwired to).
+export function validateManifest(m: Manifest): Manifest {
+ if (m.schema_version !== 2) throw new Error(`versions.json schema_version ${m.schema_version} != 2`);
+ const slugs = new Set<string>();
+ for (const v of m.versions) {
+ if (!/^DOCS_[A-Z0-9_]+$/.test(v.binding)) throw new Error(`bad binding for ${v.slug}`);
+ if (!["dev", "lts", "eol"].includes(v.status)) throw new Error(`bad status for ${v.slug}`);
+ if (slugs.has(v.slug)) throw new Error(`duplicate slug: ${v.slug}`);
+ // pdf_r2_key names an R2 fallback for the exact `pdf` URL β€” a null pdf has no URL
+ // for the fallback to ever be reached at, so the pairing is nonsensical.
+ if (v.pdf_r2_key && v.pdf === null) throw new Error(`pdf_r2_key set but pdf is null for ${v.slug}`);
+ slugs.add(v.slug);
+ }
+ // Separate pass: an alias must not collide with ANY canonical slug (not just an
+ // earlier one), so `slugs` must be fully populated before this check runs.
+ const aliases = new Set<string>();
+ for (const v of m.versions) {
+ for (const alias of v.aliases) {
+ if (slugs.has(alias)) throw new Error(`alias ${alias} (on ${v.slug}) collides with a canonical slug`);
+ if (aliases.has(alias)) throw new Error(`duplicate alias: ${alias}`);
+ aliases.add(alias);
+ }
+ }
+ if (!m.versions.some((v) => v.slug === m.default_version))
+ throw new Error(`default_version ${m.default_version} not in versions[]`);
+ return m;
+}
+
+export function loadManifest(): Manifest {
+ return validateManifest(raw as Manifest);
+}
+
+export function buildDispatch(m: Manifest): Map<string, string> {
+ return new Map(m.versions.map((v) => [v.slug, v.binding]));
+}
diff --git a/workers/apex/src/redirects.ts b/workers/apex/src/redirects.ts
new file mode 100644
index 00000000..835b50f7
--- /dev/null
+++ b/workers/apex/src/redirects.ts
@@ -0,0 +1,38 @@
+import type { Manifest } from "./manifest";
+
+function aliasMap(m: Manifest): Map<string, string> {
+ const map = new Map<string, string>();
+ for (const v of m.versions) for (const a of v.aliases) map.set(a, v.slug);
+ return map;
+}
+
+function r301(pathAndQuery: string): Response {
+ return new Response(null, { status: 301, headers: { Location: pathAndQuery } });
+}
+
+export function redirectFor(url: URL, m: Manifest): Response | null {
+ const { pathname, search } = url; // never touch url.hash β€” fragments don't reach the server
+
+ // RTD PDF URLs: /_/downloads/en/<ver>/pdf/* β†’ /en/<slug>/vyos-documentation.pdf
+ const pdf = pathname.match(/^\/_\/downloads\/en\/([^/]+)\/pdf(?:\/|$)/);
+ if (pdf) {
+ const slug = aliasMap(m).get(pdf[1]) ?? pdf[1];
+ const entry = m.versions.find((v) => v.slug === slug);
+ // pdf: null means no PDF artifact exists for this version β€” don't 301 into a dead-end 404.
+ // The manifest's pdf value is the source of truth (not a hardcoded filename) so a future
+ // R2-fallback path for 1.3 (or any other version) can move the target without a code change.
+ if (entry && entry.pdf !== null) return r301(`${entry.pdf}${search}`);
+ }
+
+ // Alias / codename prefixes: /en/<alias>/* β†’ /en/<slug>/*
+ const seg = pathname.match(/^\/en\/([^/]+)(\/.*)?$/);
+ if (seg) {
+ const [, first, rest = ""] = seg;
+ const target = aliasMap(m).get(first);
+ if (target) return r301(`/en/${target}${rest || "/"}${search}`);
+ // Trailing-slash normalization on bare version roots: /en/<slug> β†’ /en/<slug>/
+ if (rest === "" && m.versions.some((v) => v.slug === first))
+ return r301(`/en/${first}/${search}`);
+ }
+ return null;
+}
diff --git a/workers/apex/src/special.ts b/workers/apex/src/special.ts
new file mode 100644
index 00000000..15e693b1
--- /dev/null
+++ b/workers/apex/src/special.ts
@@ -0,0 +1,60 @@
+import type { Manifest } from "./manifest";
+import { bindingGuard } from "./dispatch";
+
+// Static assets (404.html, 503.html, robots.txt, favicons, root) live in the apex
+// ASSETS binding; versions.json is served from the imported manifest so the body
+// always matches the dispatch map.
+export async function specialPathFor(
+ request: Request,
+ m: Manifest,
+ env: Record<string, unknown> & { ASSETS: Fetcher },
+): Promise<Response | null> {
+ const url = new URL(request.url);
+ const p = url.pathname;
+
+ if (p === "/") // default-version redirect (Β§3.2.2) β€” query preserved, like alias redirects
+ return new Response(null, { status: 301, headers: { Location: `/en/${m.default_version}/${url.search}` } });
+
+ if (p === "/versions.json")
+ return new Response(JSON.stringify(m), {
+ headers: { "content-type": "application/json; charset=utf-8" },
+ });
+
+ if (p === "/healthz")
+ return new Response(JSON.stringify({ status: "ok", versions: m.versions.length }), {
+ headers: { "content-type": "application/json" },
+ });
+
+ if (p === "/sitemap.xml") {
+ // Origin comes from the REQUEST, never a hard-coded production hostname: this same Worker
+ // also serves the canary origin (docs-next.vyos.io, DOCS_ENV=canary), and a canary sitemap
+ // index whose entries pointed at docs.vyos.io would send any checker that follows it
+ // straight to production β€” a candidate tree with broken or missing per-version sitemaps
+ // would then pass its own sitemap check by silently grading production instead of itself.
+ const entries = m.versions
+ .map((v) => `<sitemap><loc>${url.origin}/en/${v.slug}/sitemap.xml</loc></sitemap>`)
+ .join("");
+ return new Response(
+ `<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${entries}</sitemapindex>`,
+ { headers: { "content-type": "application/xml" } },
+ );
+ }
+
+ if (p === "/llms.txt") { // direct body, not a redirect (Β§3.2.2)
+ const def = m.versions.find((v) => v.slug === m.default_version)!;
+ const b = bindingGuard(env, def.binding);
+ if (!b) // do NOT fall through β€” /llms.txt with a missing binding is a 503, not a 404
+ return new Response("service unavailable", { status: 503, headers: { "content-type": "text/plain" } });
+ // Forward the ORIGINAL request (method + conditional-GET headers), just retargeted
+ // to the versioned path β€” mirrors the robots.txt/favicon pass-through below.
+ const target = new URL(`/en/${def.slug}/llms.txt`, url);
+ return b.fetch(new Request(target, request));
+ }
+
+ if (p === "/robots.txt" || p === "/favicon.ico" || /^\/apple-touch-icon.*\.png$/.test(p))
+ // Pass the ORIGINAL request through (not a re-synthesized `new Request(url)`) so
+ // conditional-GET headers (If-None-Match / If-Modified-Since) reach ASSETS intact.
+ return env.ASSETS.fetch(request);
+
+ return null;
+}
diff --git a/workers/apex/src/uagate.ts b/workers/apex/src/uagate.ts
new file mode 100644
index 00000000..4a407e51
--- /dev/null
+++ b/workers/apex/src/uagate.ts
@@ -0,0 +1,48 @@
+export interface UaPolicy {
+ allow: string[];
+ log: string[];
+ block: string[];
+}
+
+export type UaVerdict = "allow" | "block" | "log";
+
+/**
+ * The LONGEST entry in `list` occurring in the (already-lowercased) UA, lowercased, or null.
+ * Longest rather than first-hit so the containment test in uaVerdict() compares against the
+ * most specific entry a multi-token UA matched, not an arbitrary earlier one.
+ */
+function bestMatch(lowerUa: string, list: string[]): string | null {
+ return list.reduce<string | null>((best, entry) => {
+ const needle = entry.toLowerCase();
+ if (!lowerUa.includes(needle)) return best;
+ return best === null || needle.length > best.length ? needle : best;
+ }, null);
+}
+
+export function uaVerdict(ua: string, policy: UaPolicy): UaVerdict {
+ const lowerUa = ua.toLowerCase();
+ // Explicit blocks take precedence β€” a request-controlled UA string that spoofs an
+ // allow-listed substring (e.g. "Googlebot EvilScraper") must not be able to bypass a
+ // block entry just by also matching the allow list.
+ if (bestMatch(lowerUa, policy.block) !== null) return "block";
+
+ // A log match WINS over any competing allow match, unconditionally. `log` is a telemetry
+ // verdict, not a denial (the request is served either way), so resolving a contest the
+ // wrong way is asymmetric: choosing `allow` loses the ua-log event permanently, while
+ // choosing `log` costs one log line. A UA presenting BOTH an allow token and a log token
+ // (e.g. "GPTBot/1.0 DuckDuckBot") is exactly the shape worth recording.
+ //
+ // There used to be a carve-out here: a matched allow entry that strictly CONTAINED the
+ // matched log entry won, so a policy could express a narrow allow exception inside a
+ // broader log entry (log "Foo", allow "Foo-Search"). It is gone, for two reasons. It was
+ // spoofable β€” containment was tested between the two matched ENTRIES, never against the
+ // UA's own token structure, so a caller writing "Bytespider/2.0 Bytespider-Search/1.0"
+ // matched both entries as independent tokens and bought itself `allow`, and the UA
+ // string is entirely request-controlled. And it bought nothing: no entry pair in
+ // ua-policy.json takes that branch. The pair the shipped policy does depend on runs the
+ // OTHER way β€” Apple ships "Applebot" (search, allow) and "Applebot-Extended" (AI
+ // training, log), where the log entry is the longer one, so there is no containment and
+ // log wins regardless. Losing the carve-out costs a future narrow-allow vendor variant
+ // nothing worse than being logged as well as served.
+ return bestMatch(lowerUa, policy.log) === null ? "allow" : "log"; // unknown UAs fail open
+}
diff --git a/workers/apex/test/dispatch.test.ts b/workers/apex/test/dispatch.test.ts
new file mode 100644
index 00000000..9a61169d
--- /dev/null
+++ b/workers/apex/test/dispatch.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect } from "vitest";
+import { loadManifest, buildDispatch } from "../src/manifest";
+import { resolveVersion, bindingGuard } from "../src/dispatch";
+
+const dispatch = buildDispatch(loadManifest());
+
+describe("version dispatch (Β§3.2.6)", () => {
+ it("resolves /en/<slug>/... to its binding, path untouched", () => {
+ expect(resolveVersion("/en/rolling/cli/index.html", dispatch))
+ .toEqual({ slug: "rolling", binding: "DOCS_ROLLING" });
+ expect(resolveVersion("/en/1.5/", dispatch))
+ .toEqual({ slug: "1.5", binding: "DOCS_V15" });
+ });
+ it("returns null for unknown version or non-version paths", () => {
+ expect(resolveVersion("/en/9.9/x", dispatch)).toBeNull();
+ expect(resolveVersion("/kb/article", dispatch)).toBeNull();
+ });
+});
+
+describe("runtime binding guard (Β§3.2.7)", () => {
+ it("returns the fetcher when binding exists", () => {
+ const fake = { fetch: async () => new Response("ok") };
+ expect(bindingGuard({ DOCS_ROLLING: fake } as never, "DOCS_ROLLING")).toBe(fake);
+ });
+ it("returns null (β†’ themed 503) when binding missing or not a Fetcher", () => {
+ expect(bindingGuard({} as never, "DOCS_ROLLING")).toBeNull();
+ expect(bindingGuard({ DOCS_ROLLING: 42 } as never, "DOCS_ROLLING")).toBeNull();
+ });
+});
diff --git a/workers/apex/test/manifest.test.ts b/workers/apex/test/manifest.test.ts
new file mode 100644
index 00000000..f5653360
--- /dev/null
+++ b/workers/apex/test/manifest.test.ts
@@ -0,0 +1,135 @@
+import { describe, it, expect } from "vitest";
+import { loadManifest, buildDispatch, validateManifest, type Manifest } from "../src/manifest";
+// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented
+// stub β€” see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs); import the
+// config as a Vite `?raw` asset instead so the file content is inlined at bundle time.
+// eslint-disable-next-line import/no-unresolved
+import wranglerJsonc from "../wrangler.jsonc?raw";
+// eslint-disable-next-line import/no-unresolved
+import rootHtml from "../assets/root.html?raw";
+
+describe("versions.json v2 manifest (Β§3.4)", () => {
+ it("loads and validates schema_version 2 with 5 versions", () => {
+ const m = loadManifest();
+ expect(m.schema_version).toBe(2);
+ expect(m.versions).toHaveLength(5);
+ expect(m.default_version).toBe("rolling");
+ });
+ it("every version carries a binding; statuses in dev|lts|eol", () => {
+ const m = loadManifest();
+ for (const v of m.versions) {
+ expect(v.binding).toMatch(/^DOCS_[A-Z0-9_]+$/);
+ expect(["dev", "lts", "eol"]).toContain(v.status);
+ }
+ });
+ it("dispatch map: every version slug maps to its binding (shared legacy included)", () => {
+ const m = loadManifest();
+ const d = buildDispatch(m);
+ expect(d.size).toBe(m.versions.length);
+ for (const v of m.versions) {
+ expect(d.get(v.slug)).toBe(v.binding);
+ }
+ // aliases are redirect-layer concerns, never dispatch keys
+ for (const v of m.versions) {
+ for (const a of v.aliases) {
+ expect(d.has(a)).toBe(false);
+ }
+ }
+ });
+ it("only 1.3 carries pdf_r2_key (Β§5 apex PDF R2 fallback)", () => {
+ const m = loadManifest();
+ const withKey = m.versions.filter((v) => v.pdf_r2_key);
+ expect(withKey).toHaveLength(1);
+ expect(withKey[0]).toMatchObject({
+ slug: "1.3",
+ pdf: "/en/1.3/vyos-documentation.pdf",
+ pdf_r2_key: "legacy/1.3/vyos-documentation.pdf",
+ });
+ });
+});
+
+function baseManifest(): Manifest {
+ return {
+ schema_version: 2,
+ default_lang: "en",
+ default_version: "rolling",
+ languages: [{ code: "en", label: "English" }],
+ versions: [
+ { slug: "rolling", label: "Rolling", status: "dev", binding: "DOCS_ROLLING",
+ aliases: ["latest"], pdf: null },
+ { slug: "1.5", label: "1.5", status: "lts", binding: "DOCS_V15",
+ aliases: ["stable", "lts"], pdf: null },
+ ],
+ };
+}
+
+describe("validateManifest β€” duplicate/ambiguous slug + alias rejection", () => {
+ it("rejects a duplicate slug", () => {
+ const m = baseManifest();
+ m.versions.push({ ...m.versions[0], binding: "DOCS_ROLLING2" });
+ expect(() => validateManifest(m)).toThrow(/duplicate slug: rolling/);
+ });
+
+ it("rejects a duplicate alias across two versions", () => {
+ const m = baseManifest();
+ m.versions[1].aliases.push("latest"); // "latest" already aliases rolling
+ expect(() => validateManifest(m)).toThrow(/duplicate alias: latest/);
+ });
+
+ it("rejects an alias that collides with a canonical slug", () => {
+ const m = baseManifest();
+ m.versions[0].aliases.push("1.5"); // "1.5" is a real slug
+ expect(() => validateManifest(m)).toThrow(/alias 1\.5 .* collides with a canonical slug/);
+ });
+
+ it("accepts a well-formed manifest unchanged", () => {
+ const m = baseManifest();
+ const snapshot = structuredClone(m);
+ expect(validateManifest(m)).toBe(m);
+ expect(m).toEqual(snapshot); // validation must not mutate the manifest
+ });
+
+ it("accepts pdf_r2_key when pdf is set", () => {
+ const m = baseManifest();
+ m.versions[1].pdf = "/en/1.5/vyos-documentation.pdf";
+ m.versions[1].pdf_r2_key = "legacy/1.5/vyos-documentation.pdf";
+ expect(validateManifest(m)).toBe(m);
+ });
+
+ it("rejects pdf_r2_key set on a version whose pdf is null (no URL for the fallback to be reached at)", () => {
+ const m = baseManifest();
+ m.versions[0].pdf_r2_key = "legacy/rolling/vyos-documentation.pdf"; // pdf stays null
+ expect(() => validateManifest(m)).toThrow(/pdf_r2_key set but pdf is null for rolling/);
+ });
+});
+
+it("every versions.json binding exists in BOTH apex wrangler envs (Β§3.4 gate a)", () => {
+ const raw = wranglerJsonc.replace(/\/\/.*$/gm, ""); // strip line comments
+ const cfg = JSON.parse(raw);
+ const bindings = new Set(buildDispatch(loadManifest()).values());
+ for (const envName of ["canary", "production"]) {
+ const services = new Set((cfg.env[envName].services as { binding: string }[]).map((s) => s.binding));
+ for (const b of bindings) expect(services, `${envName} missing ${b}`).toContain(b);
+ }
+});
+
+it("root.html's hardcoded default-version references stay congruent with the manifest", () => {
+ // root.html is a static fallback shell (never templated at build time β€” see the
+ // comment in the file) so its `/en/<slug>/` references must be hand-kept in sync
+ // with the manifest's default_version. This is the drift guard: it fails loudly if
+ // someone bumps default_version without also updating the static asset.
+ const expected = `/en/${loadManifest().default_version}/`;
+ // Strip HTML comments first β€” the explanatory comment in root.html mentions the
+ // literal placeholder text "/en/<default_version>/", which is documentation, not
+ // a real markup reference, and must not be asserted against the manifest.
+ // Applied repeatedly to a fixpoint: a single pass can splice two partial comments
+ // into a new one (CodeQL js/incomplete-multi-character-sanitization).
+ let withoutComments = rootHtml;
+ for (let prev = ""; prev !== withoutComments; ) {
+ prev = withoutComments;
+ withoutComments = withoutComments.replace(/<!--[\s\S]*?-->/g, "");
+ }
+ const refs = withoutComments.match(/\/en\/[^/"'\s]+\//g) ?? [];
+ expect(refs.length).toBeGreaterThan(0);
+ for (const ref of refs) expect(ref).toBe(expected);
+});
diff --git a/workers/apex/test/redirects.test.ts b/workers/apex/test/redirects.test.ts
new file mode 100644
index 00000000..5df03197
--- /dev/null
+++ b/workers/apex/test/redirects.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect } from "vitest";
+import { loadManifest } from "../src/manifest";
+import { redirectFor } from "../src/redirects";
+
+const m = loadManifest();
+const loc = (u: string) => {
+ const r = redirectFor(new URL(u), m);
+ return r ? { status: r.status, location: r.headers.get("Location") } : null;
+};
+
+describe("alias + codename 301s (Β§3.2.4)", () => {
+ it("maps every alias, preserving path + query", () => {
+ expect(loc("https://docs.vyos.io/en/latest/cli/index.html?x=1"))
+ .toEqual({ status: 301, location: "/en/rolling/cli/index.html?x=1" });
+ expect(loc("https://docs.vyos.io/en/stable/")).toEqual({ status: 301, location: "/en/1.5/" });
+ expect(loc("https://docs.vyos.io/en/lts/a")).toEqual({ status: 301, location: "/en/1.5/a" });
+ expect(loc("https://docs.vyos.io/en/circinus/a")).toEqual({ status: 301, location: "/en/1.5/a" });
+ expect(loc("https://docs.vyos.io/en/sagitta/a")).toEqual({ status: 301, location: "/en/1.4/a" });
+ expect(loc("https://docs.vyos.io/en/equuleus/a")).toEqual({ status: 301, location: "/en/1.3/a" });
+ expect(loc("https://docs.vyos.io/en/crux/a")).toEqual({ status: 301, location: "/en/1.2/a" });
+ });
+ it("RTD PDF URLs β†’ PDF path taken from the manifest (source of truth, Β§3.4)", () => {
+ const v15pdf = m.versions.find((v) => v.slug === "1.5")!.pdf;
+ const rollingPdf = m.versions.find((v) => v.slug === "rolling")!.pdf;
+ expect(loc("https://docs.vyos.io/_/downloads/en/1.5/pdf/"))
+ .toEqual({ status: 301, location: v15pdf });
+ expect(loc("https://docs.vyos.io/_/downloads/en/latest/pdf/"))
+ .toEqual({ status: 301, location: rollingPdf });
+ });
+ it("RTD PDF URLs for a pdf:null version β†’ no redirect (no dead-end 301)", () => {
+ expect(loc("https://docs.vyos.io/_/downloads/en/crux/pdf/")).toBeNull();
+ expect(loc("https://docs.vyos.io/_/downloads/en/1.2/pdf/")).toBeNull();
+ });
+ it("RTD PDF URLs preserve the query string, appended to the manifest's pdf value", () => {
+ const v14pdf = m.versions.find((v) => v.slug === "1.4")!.pdf;
+ expect(loc("https://docs.vyos.io/_/downloads/en/sagitta/pdf/?x=1"))
+ .toEqual({ status: 301, location: `${v14pdf}?x=1` });
+ });
+ it("does not match /pdf-notes (only an exact /pdf segment)", () => {
+ expect(loc("https://docs.vyos.io/_/downloads/en/rolling/pdf-notes")).toBeNull();
+ });
+ it("trailing-slash normalization on bare version roots (Β§3.2.3)", () => {
+ expect(loc("https://docs.vyos.io/en/1.5")).toEqual({ status: 301, location: "/en/1.5/" });
+ expect(loc("https://docs.vyos.io/en/rolling?q=1")).toEqual({ status: 301, location: "/en/rolling/?q=1" });
+ });
+ it("never emits a fragment in Location (Β§3.2.6)", () => {
+ const r = loc("https://docs.vyos.io/en/latest/page.html#section");
+ expect(r!.location).not.toContain("#");
+ });
+ it("returns null for canonical paths (no redirect loop)", () => {
+ expect(loc("https://docs.vyos.io/en/rolling/")).toBeNull();
+ expect(loc("https://docs.vyos.io/en/1.5/cli/")).toBeNull();
+ });
+});
diff --git a/workers/apex/test/router.test.ts b/workers/apex/test/router.test.ts
new file mode 100644
index 00000000..b5f965fc
--- /dev/null
+++ b/workers/apex/test/router.test.ts
@@ -0,0 +1,1067 @@
+import { describe, it, expect, vi } from "vitest";
+import worker, { resolveRange, classifyRangeHeader } from "../src/index";
+
+function makeEnv(overrides: Record<string, unknown> = {}) {
+ const html = (body: string, status = 200) =>
+ new Response(body, { status, headers: { "content-type": "text/html", "X-Docs-Build": "sha-content" } });
+ const fetcher = (tag: string) => ({
+ fetch: async (req: Request) => {
+ const p = new URL(req.url).pathname;
+ if (p.endsWith("/missing.html")) return html("nope", 404);
+ return html(`${tag}:${p}`);
+ },
+ });
+ return {
+ DOCS_ROLLING: fetcher("rolling"), DOCS_V15: fetcher("v15"),
+ DOCS_V14: fetcher("v14"), DOCS_LEGACY: fetcher("legacy"),
+ ASSETS: { fetch: async () => new Response("<h1>404</h1>", { status: 200, headers: { "content-type": "text/html" } }) },
+ APEX_BUILD_SHA: "apex-sha", DOCS_ENV: "canary",
+ ...overrides,
+ } as never;
+}
+const get = (path: string, env = makeEnv(), ua = "vitest") =>
+ worker.fetch(new Request(`https://docs-next.vyos.io${path}`, { headers: { "user-agent": ua } }), env);
+
+describe("apex router (Β§3.2 order)", () => {
+ it("/ β†’ 301 default version", async () => {
+ const r = await get("/");
+ expect(r.status).toBe(301);
+ expect(r.headers.get("Location")).toBe("/en/rolling/");
+ expect(r.headers.get("X-Apex-Build")).toBe("apex-sha");
+ });
+ it("/ redirect preserves the query string (like alias redirects)", async () => {
+ const r = await get("/?ref=email");
+ expect(r.status).toBe(301);
+ expect(r.headers.get("Location")).toBe("/en/rolling/?ref=email");
+ });
+ it("/versions.json served from manifest with X-Apex-Build", async () => {
+ const r = await get("/versions.json");
+ expect(r.status).toBe(200);
+ const body = await r.json() as { schema_version: number };
+ expect(body.schema_version).toBe(2);
+ expect(r.headers.get("X-Apex-Build")).toBe("apex-sha");
+ });
+ it("dispatches /en/rolling/x to the binding with original path", async () => {
+ const r = await get("/en/rolling/cli/index.html");
+ expect(await r.text()).toBe("rolling:/en/rolling/cli/index.html");
+ expect(r.headers.get("X-Docs-Build")).toBe("sha-content");
+ });
+ it("content responses get apex security headers, content-owned headers untouched (Β§3.3)", async () => {
+ const r = await get("/en/rolling/cli/index.html");
+ expect(r.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ expect(r.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
+ expect(r.headers.get("X-Docs-Build")).toBe("sha-content"); // not overwritten
+ expect(r.headers.get("X-Apex-Build")).toBeNull(); // apex build header is apex-paths-only
+ });
+ it("alias 301 before dispatch", async () => {
+ const r = await get("/en/latest/cli/");
+ expect(r.status).toBe(301);
+ expect(r.headers.get("Location")).toBe("/en/rolling/cli/");
+ });
+ it("binding 404 β†’ themed 404 with real 404 status (Β§3.2.7)", async () => {
+ const r = await get("/en/rolling/missing.html");
+ expect(r.status).toBe(404);
+ expect(r.headers.get("X-Apex-Build")).toBe("apex-sha");
+ });
+ it("missing binding degrades to themed 503, not a crash", async () => {
+ const env = makeEnv({ DOCS_ROLLING: undefined });
+ const r = await get("/en/rolling/", env);
+ expect(r.status).toBe(503);
+ });
+ it("/kb/* β†’ themed 404 while seam unbound; dispatches when DOCS_KB present", async () => {
+ expect((await get("/kb/article")).status).toBe(404);
+ const env = makeEnv({ DOCS_KB: { fetch: async () => new Response("kb!") } });
+ const r = await get("/kb/article", env);
+ expect(await r.text()).toBe("kb!");
+ // /kb passthrough gets the same security headers as any other content response.
+ expect(r.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ expect(r.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
+ });
+ it("unknown path β†’ themed 404 + security headers", async () => {
+ const r = await get("/nope");
+ expect(r.status).toBe(404);
+ expect(r.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ expect(r.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
+ });
+ it("UA gate blocks only in production env", async () => {
+ const prodEnv = makeEnv({ DOCS_ENV: "production" });
+ // policy.block is empty at launch β†’ craft env-independent check via log class:
+ const r = await get("/en/rolling/", prodEnv, "GPTBot/1.0");
+ expect(r.status).toBe(200); // log-only, not blocked
+ });
+ it("apex responses carry Β§3.3 cache headers (no-store canary; revalidate production)", async () => {
+ expect((await get("/versions.json")).headers.get("Cache-Control")).toBe("no-store");
+ const prod = await get("/versions.json", makeEnv({ DOCS_ENV: "production" }));
+ expect(prod.headers.get("Cache-Control")).toBe("public, max-age=0, s-maxage=300, must-revalidate");
+ });
+ it("error responses stay no-store in production β€” cache key excludes UA, so a cached 4xx/5xx would poison the edge for everyone", async () => {
+ const prodEnv = makeEnv({ DOCS_ENV: "production" });
+
+ // themed 404 (unknown path)
+ const notFound = await get("/nope", prodEnv);
+ expect(notFound.status).toBe(404);
+ expect(notFound.headers.get("Cache-Control")).toBe("no-store");
+
+ // themed 503 (missing runtime binding)
+ const svcUnavailable = await get("/en/rolling/", makeEnv({ DOCS_ENV: "production", DOCS_ROLLING: undefined }));
+ expect(svcUnavailable.status).toBe(503);
+ expect(svcUnavailable.headers.get("Cache-Control")).toBe("no-store");
+
+ // UA-block 403 β€” force a block entry via a policy override, since the shipped
+ // ua-policy.json block list is empty at launch.
+ vi.resetModules();
+ vi.doMock("../ua-policy.json", () => ({
+ default: { allow: [], log: [], block: ["EvilScraper"] },
+ }));
+ try {
+ const { default: freshWorker } = await import("../src/index");
+ const blocked = await freshWorker.fetch(
+ new Request("https://docs-next.vyos.io/en/rolling/", { headers: { "user-agent": "EvilScraper/1.0" } }),
+ prodEnv,
+ );
+ expect(blocked.status).toBe(403);
+ expect(blocked.headers.get("Cache-Control")).toBe("no-store");
+ } finally {
+ vi.doUnmock("../ua-policy.json");
+ vi.resetModules();
+ }
+ });
+ it("/sitemap.xml index entries use the REQUEST origin, not a hard-coded docs.vyos.io", async () => {
+ // This Worker serves the canary origin too. A canary sitemap index pointing at production
+ // would send any checker that follows it to docs.vyos.io, so a candidate tree with broken
+ // per-version sitemaps would pass by silently grading production instead of itself.
+ const canary = await get("/sitemap.xml");
+ const canaryBody = await canary.text();
+ expect(canaryBody).toContain("<loc>https://docs-next.vyos.io/en/rolling/sitemap.xml</loc>");
+ expect(canaryBody).not.toContain("docs.vyos.io");
+
+ const prod = await worker.fetch(
+ new Request("https://docs.vyos.io/sitemap.xml", { headers: { "user-agent": "vitest" } }),
+ makeEnv({ DOCS_ENV: "production" }),
+ );
+ expect(await prod.text()).toContain("<loc>https://docs.vyos.io/en/1.5/sitemap.xml</loc>");
+ });
+ it("/llms.txt with missing default binding β†’ 503, never 404", async () => {
+ const env = makeEnv({ DOCS_ROLLING: undefined });
+ expect((await get("/llms.txt", env)).status).toBe(503);
+ });
+ it("/llms.txt forwards the ORIGINAL request (method + conditional-GET headers preserved)", async () => {
+ let seenMethod: string | null = null;
+ let seenIfNoneMatch: string | null = null;
+ const env = makeEnv({
+ DOCS_ROLLING: {
+ fetch: async (req: Request) => {
+ seenMethod = req.method;
+ seenIfNoneMatch = req.headers.get("if-none-match");
+ return new Response("llms body", { headers: { "content-type": "text/plain" } });
+ },
+ },
+ });
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/llms.txt", {
+ method: "HEAD",
+ headers: { "user-agent": "vitest", "if-none-match": '"xyz"' },
+ }),
+ env,
+ );
+ expect(r.status).toBe(200);
+ expect(seenMethod).toBe("HEAD");
+ expect(seenIfNoneMatch).toBe('"xyz"');
+ });
+ it("robots.txt passthrough forwards the ORIGINAL request (conditional-GET headers preserved)", async () => {
+ let seenIfNoneMatch: string | null = null;
+ const env = makeEnv({
+ ASSETS: {
+ fetch: async (req: Request) => {
+ seenIfNoneMatch = req.headers.get("if-none-match");
+ return new Response("User-agent: *", { headers: { "content-type": "text/plain" } });
+ },
+ },
+ });
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/robots.txt", {
+ headers: { "user-agent": "vitest", "if-none-match": '"abc123"' },
+ }),
+ env,
+ );
+ expect(r.status).toBe(200);
+ expect(seenIfNoneMatch).toBe('"abc123"');
+ });
+
+ it("UA gate in production tolerates a missing User-Agent header (no crash, fail-open)", async () => {
+ const prodEnv = makeEnv({ DOCS_ENV: "production" });
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/rolling/", { headers: {} }),
+ prodEnv,
+ );
+ expect(r.status).toBe(200);
+ });
+
+ describe("legacy PDF R2 fallback (spec Β§5) β€” runs before version dispatch", () => {
+ // Fake R2Bucket mock following the preview-worker precedent (apex/preview/test/preview.test.ts).
+ // Simulates R2's onlyIf (If-None-Match β†’ body-less R2Object) + range (echoes the
+ // satisfied byte range on `.range`) behavior closely enough to exercise index.ts's
+ // handling of both without needing the real R2 binding.
+ const ETAG = '"pdf-etag-1"';
+ const UPLOADED = new Date("2026-01-15T10:00:00Z");
+ const BEFORE_UPLOAD = "Wed, 14 Jan 2026 10:00:00 GMT";
+ const AFTER_UPLOAD = "Fri, 16 Jan 2026 10:00:00 GMT";
+ // R2 hands back a ReadableStream, not a string, and the difference is exactly what makes
+ // an abandoned body observable: a stream the Worker neither sends nor cancels stays open
+ // holding its connection. A string-bodied mock cannot see that class of bug at all, so
+ // bodies here are real streams that record their own cancellation. `highWaterMark: 0`
+ // keeps `pull` from running until something actually reads, so a stream cancelled before
+ // any read still reaches its `cancel()` algorithm rather than being already closed.
+ function bodyStream(text: string, sink?: { cancelled: string[] }) {
+ return new ReadableStream({
+ pull(c) {
+ c.enqueue(new TextEncoder().encode(text));
+ c.close();
+ },
+ cancel() {
+ sink?.cancelled.push(text);
+ },
+ }, { highWaterMark: 0 });
+ }
+
+ function r2Env(
+ objects: Record<string, { body: string; etag?: string; uploaded?: Date }>,
+ overrides: Record<string, unknown> = {},
+ sink?: { cancelled: string[] },
+ ) {
+ return makeEnv({
+ DOCS_PDFS: {
+ get: async (key: string, options?: { onlyIf?: Headers; range?: Headers }) => {
+ const hit = objects[key];
+ if (!hit) return null;
+ const etag = hit.etag ?? ETAG;
+ const size = hit.body.length;
+
+ const uploaded = hit.uploaded ?? UPLOADED;
+ const secs = (d: number) => Math.floor(d / 1000); // R2 compares at seconds granularity
+
+ // R2 returns a body-less R2Object whenever an onlyIf precondition FAILS, and it
+ // never says which one did β€” that ambiguity is exactly what index.ts has to
+ // resolve by re-evaluating the request's conditionals against these validators.
+ // R2 ANDs every validator it is handed and knows nothing about the request
+ // METHOD; index.ts is what filters the set down to the ones RFC 9110 Β§13.2.2
+ // says apply, so this mock deliberately evaluates whatever it is given.
+ const bodyless = { httpEtag: etag, size, uploaded };
+ const ifNoneMatch = options?.onlyIf?.get?.("if-none-match");
+ if (ifNoneMatch && (ifNoneMatch === "*" || ifNoneMatch.split(",").some(
+ (t) => t.trim().replace(/^W\//, "") === etag.replace(/^W\//, "")))) {
+ return bodyless; // If-None-Match matched β†’ "not modified"
+ }
+ const ifMatch = options?.onlyIf?.get?.("if-match");
+ if (ifMatch && ifMatch !== "*" && !ifMatch.split(",").some(
+ (t) => t.trim() === etag)) {
+ return bodyless; // If-Match failed β†’ precondition failed
+ }
+ const ifUnmodifiedSince = options?.onlyIf?.get?.("if-unmodified-since");
+ if (ifUnmodifiedSince && secs(uploaded.getTime()) > secs(Date.parse(ifUnmodifiedSince))) {
+ return bodyless; // object is newer than the client's copy
+ }
+ const ifModifiedSince = options?.onlyIf?.get?.("if-modified-since");
+ if (ifModifiedSince && secs(uploaded.getTime()) <= secs(Date.parse(ifModifiedSince))) {
+ return bodyless; // not modified since the client's copy
+ }
+
+ // The whole-object result. R2 returns this shape for a plain un-ranged get AND
+ // β€” critically β€” for every Range header it declines to honour. Both verified
+ // against a real R2 binding under @cloudflare/vitest-pool-workers.
+ const whole = {
+ httpEtag: etag, size, uploaded, body: bodyStream(hit.body, sink),
+ range: { offset: 0, length: size },
+ };
+
+ const rangeHeader = options?.range?.get?.("range");
+ if (!rangeHeader) return whole;
+
+ // Range parsing mirrors R2's OWN grammar rather than being merely "strict":
+ // miniflare src/workers/shared/range.ts uses /^ *bytes *=/i for the prefix and
+ // /^ *(\d+)? *- *(\d+)? *$/ per comma-separated spec β€” ASCII SPACE ONLY, never
+ // \s. index.ts's classifier accepts \s, so the two grammars genuinely disagree
+ // on a tab. Reproducing R2's grammar here is what makes the tab-separated-Range
+ // test a real divergence rather than an artefact of a lazily-strict mock.
+ const prefix = / *bytes *=/i.exec(rangeHeader);
+ if (!prefix || prefix.index !== 0) return whole; // unknown unit β†’ ignored
+ const specs = rangeHeader.substring(prefix[0].length).split(",");
+ if (specs.length !== 1) return whole; // multi-range β†’ ignored
+ const m = /^ *(\d+)? *- *(\d+)? *$/.exec(specs[0]);
+ if (!m) return whole; // unparseable (a tab lands here, exactly as in R2)
+ const [, startRaw, endRaw] = m;
+
+ if (startRaw !== undefined && endRaw !== undefined) {
+ const offset = Number(startRaw);
+ const last = Number(endRaw);
+ // Observed R2: an int-range with first-pos >= size, or an invalid spec with
+ // last < first, is IGNORED β€” R2 hands back the complete object rather than
+ // throwing or returning a zero-length range. (`bytes=10-20` and `bytes=5-2`
+ // on a 10-byte object both returned `{offset: 0, length: 10}` + the full body.)
+ if (offset >= size || last < offset) return whole;
+ const length = Math.min(last, size - 1) - offset + 1; // last-pos clamps to EOF
+ return {
+ httpEtag: etag, size, uploaded,
+ body: bodyStream(hit.body.slice(offset, offset + length), sink),
+ range: { offset, length },
+ };
+ }
+ if (startRaw !== undefined) { // open-ended `bytes=5-`
+ const offset = Number(startRaw);
+ if (offset >= size) return whole; // unsatisfiable β†’ ignored
+ return {
+ httpEtag: etag, size, uploaded,
+ body: bodyStream(hit.body.slice(offset), sink),
+ range: { offset, length: size - offset },
+ };
+ }
+ if (endRaw !== undefined) {
+ // Suffix form. R2Range is a three-shape union and this arm deliberately
+ // returns the RAW `{suffix}` shape rather than pre-normalizing to
+ // `{offset, length}` β€” that is what exercises index.ts's resolveRange().
+ // (workerd itself normalizes, but the type admits this shape.)
+ const n = Number(endRaw);
+ // miniflare: a suffix >= length yields no ranges, and `bytes=-0` is skipped β€”
+ // both leave R2 serving the complete object rather than rejecting.
+ if (n === 0 || n >= size) return whole;
+ return {
+ httpEtag: etag, size, uploaded,
+ body: bodyStream(hit.body.slice(size - n), sink),
+ range: { suffix: n },
+ };
+ }
+ return whole; // bare `bytes=-`
+ },
+ } as unknown as R2Bucket,
+ ...overrides,
+ });
+ }
+
+ it("served-from-R2 200: content-type application/pdf, PDF cache class, X-Apex-Build present, etag + accept-ranges", async () => {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" },
+ );
+ const r = await get("/en/1.3/vyos-documentation.pdf", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("PDF-BYTES");
+ expect(r.headers.get("content-type")).toBe("application/pdf");
+ expect(r.headers.get("Cache-Control")).toBe("public, max-age=300, s-maxage=600, must-revalidate");
+ expect(r.headers.get("X-Apex-Build")).toBe("apex-sha");
+ expect(r.headers.get("etag")).toBe(ETAG);
+ expect(r.headers.get("accept-ranges")).toBe("bytes");
+ expect(r.headers.get("content-length")).toBe("9");
+ });
+
+ it("If-None-Match matching R2's etag β†’ 304, no body, etag present, PDF cache class", async () => {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" },
+ );
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", "if-none-match": ETAG },
+ }),
+ env,
+ );
+ expect(r.status).toBe(304);
+ expect(await r.text()).toBe("");
+ expect(r.headers.get("etag")).toBe(ETAG);
+ expect(r.headers.get("Cache-Control")).toBe("public, max-age=300, s-maxage=600, must-revalidate");
+ expect(r.headers.get("content-type")).toBeNull();
+ });
+
+ it("Range: bytes=0-3 β†’ 206 + Content-Range + partial body, PDF cache class", async () => {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, // length 9
+ { DOCS_ENV: "production" },
+ );
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", range: "bytes=0-3" },
+ }),
+ env,
+ );
+ expect(r.status).toBe(206);
+ expect(await r.text()).toBe("PDF-");
+ expect(r.headers.get("content-range")).toBe("bytes 0-3/9");
+ expect(r.headers.get("content-length")).toBe("4");
+ expect(r.headers.get("etag")).toBe(ETAG);
+ expect(r.headers.get("Cache-Control")).toBe("public, max-age=300, s-maxage=600, must-revalidate");
+ });
+
+ it("canary env still forces no-store on the PDF response (canary rule wins)", async () => {
+ const env = r2Env({ "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }); // default DOCS_ENV: canary
+ const r = await get("/en/1.3/vyos-documentation.pdf", env);
+ expect(r.status).toBe(200);
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ });
+
+ it("R2 miss β†’ themed 404, no-store, never falls through to DOCS_LEGACY dispatch", async () => {
+ const env = r2Env({}, { DOCS_ENV: "production" }); // bucket present but empty
+ const r = await get("/en/1.3/vyos-documentation.pdf", env);
+ expect(r.status).toBe(404);
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ expect(await r.text()).not.toContain("legacy:"); // not the DOCS_LEGACY fetcher's echoed tag
+ });
+
+ it("R2 get() throwing β†’ themed 503, no-store", async () => {
+ const env = makeEnv({
+ DOCS_ENV: "production",
+ DOCS_PDFS: { get: async () => { throw new Error("R2 unavailable"); } } as unknown as R2Bucket,
+ });
+ const r = await get("/en/1.3/vyos-documentation.pdf", env);
+ expect(r.status).toBe(503);
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ });
+
+ it("DOCS_PDFS binding missing β†’ themed 503, not a crash", async () => {
+ const env = makeEnv({ DOCS_ENV: "production" }); // no DOCS_PDFS at all
+ const r = await get("/en/1.3/vyos-documentation.pdf", env);
+ expect(r.status).toBe(503);
+ });
+
+ it("other versions' PDF paths never touch DOCS_PDFS β€” fall through to normal dispatch", async () => {
+ // env carries a DOCS_PDFS bucket that would 500 if queried at all, proving the
+ // rolling/1.5/1.4 PDF paths (no pdf_r2_key on those manifest entries) skip it entirely.
+ const env = r2Env(
+ {},
+ {
+ DOCS_ENV: "production",
+ DOCS_PDFS: { get: async () => { throw new Error("must not be called for non-1.3 PDFs"); } } as unknown as R2Bucket,
+ },
+ );
+ const r = await get("/en/rolling/vyos-documentation.pdf", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("rolling:/en/rolling/vyos-documentation.pdf");
+ });
+
+ it("1.2 (pdf: null, no pdf_r2_key) falls through to normal dispatch unaffected", async () => {
+ const env = r2Env({}, { DOCS_ENV: "production" });
+ const r = await get("/en/1.2/vyos-documentation.pdf", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("legacy:/en/1.2/vyos-documentation.pdf");
+ });
+
+ // --- Regression: a plain GET must never answer 206. R2 reports a whole-object `range`
+ // on un-ranged gets, so keying the 206 off `obj.range` alone made EVERY plain GET of the
+ // 1.3 PDF a 206 with a Content-Range β€” which is what the nightly canary sweep observed
+ // ("/en/1.3/vyos-documentation.pdf: status=206") and what RFC 9110 Β§15.3.7 forbids. ---
+
+ it("plain GET (no Range header) β†’ 200, never 206, even though R2 echoes a whole-object range", async () => {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" },
+ );
+ const r = await get("/en/1.3/vyos-documentation.pdf", env);
+ expect(r.status).toBe(200);
+ expect(r.headers.get("content-range")).toBeNull();
+ expect(r.headers.get("content-length")).toBe("9");
+ expect(await r.text()).toBe("PDF-BYTES");
+ });
+
+ it("suffix Range (bytes=-4) β†’ 206 with the last 4 bytes and a matching Content-Range/Length", async () => {
+ // The `{suffix}` R2Range shape used to miss the `"offset" in range` guard entirely and
+ // fall through to the 200 branch, where content-length claimed the WHOLE object size
+ // while the body held only the tail β€” a corrupt download for any resumed fetch.
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, // length 9
+ { DOCS_ENV: "production" },
+ );
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", range: "bytes=-4" },
+ }),
+ env,
+ );
+ expect(r.status).toBe(206);
+ expect(await r.text()).toBe("YTES");
+ expect(r.headers.get("content-range")).toBe("bytes 5-8/9");
+ expect(r.headers.get("content-length")).toBe("4");
+ });
+
+ it("failed If-Match β†’ 412, not 304 (a 304 would tell the client its stale copy is current)", async () => {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" },
+ );
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", "if-match": '"some-other-etag"' },
+ }),
+ env,
+ );
+ expect(r.status).toBe(412);
+ expect(await r.text()).toBe("");
+ // 412 is an error response, so the Β§3.3 precedence forces no-store over the PDF class.
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ });
+
+ // --- RFC 9110 Β§13.2.2 precondition PRECEDENCE. R2 reports THAT an onlyIf precondition
+ // failed, never WHICH one, so index.ts re-derives it from the request's own headers.
+ // Testing the not-modified family first got the ordering backwards. ---
+
+ async function conditional(headers: Record<string, string>, method = "GET") {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" },
+ );
+ return worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ method,
+ headers: { "user-agent": "vitest", ...headers },
+ }),
+ env,
+ );
+ }
+
+ it("If-None-Match alone (matching) β†’ 304", async () => {
+ expect((await conditional({ "if-none-match": '"pdf-etag-1"' })).status).toBe(304);
+ });
+
+ it("If-Match + If-None-Match together β†’ 412: If-Match takes strict precedence", async () => {
+ // The failing precondition here is If-Match. Answering 304 (because If-None-Match is
+ // also present) would tell the client its stale copy is still current, when the
+ // higher-precedence check it asked for actually failed. Β§13.2.2 steps 1 and 3.
+ const r = await conditional({
+ "if-match": '"stale-etag"',
+ "if-none-match": '"pdf-etag-1"',
+ });
+ expect(r.status).toBe(412);
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ });
+
+ it("If-Unmodified-Since β†’ 412, never 304 (Β§13.2.2 step 2 outranks the 304 family)", async () => {
+ const r = await conditional({
+ "if-unmodified-since": "Wed, 01 Jan 2020 00:00:00 GMT",
+ "if-none-match": '"pdf-etag-1"',
+ });
+ expect(r.status).toBe(412);
+ });
+
+ it("a failed If-None-Match on a non-GET/HEAD method β†’ 412, not 304", async () => {
+ // Β§13.1.2: on a false If-None-Match the origin MUST answer "304 ... if the request
+ // method is GET or HEAD or 412 ... for all other request methods". Nothing upstream
+ // restricts the method, so this path is reachable and 304 would be an invalid answer.
+ const r = await conditional({ "if-none-match": '"pdf-etag-1"' }, "POST");
+ expect(r.status).toBe(412);
+ });
+
+ it("HEAD keeps the 304 (it is one of the two methods Β§13.1.2 allows it for)", async () => {
+ expect((await conditional({ "if-none-match": '"pdf-etag-1"' }, "HEAD")).status).toBe(304);
+ });
+
+ // --- RFC 9110 Β§14.1.2 / Β§15.5.17 range satisfiability. R2 signals "I ignored your
+ // Range" by returning the WHOLE object β€” the same shape as a satisfied whole-object
+ // range β€” so index.ts re-derives intent from the client's own header. ---
+
+ async function ranged(rangeHeader: string, body = "PDF-BYTES") {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body } },
+ { DOCS_ENV: "production" },
+ );
+ return worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", range: rangeHeader },
+ }),
+ env,
+ );
+ }
+
+ it("Range past EOF β†’ 416 + Content-Range: bytes */size, NOT a 206 serving the whole body", async () => {
+ // The pre-fix path trusted obj.range, and R2 answers an unsatisfiable range with the
+ // complete object β€” so this returned `206 Content-Range: bytes 0-8/9` plus all 9
+ // bytes. A client resuming at byte 99 would have appended bytes 0-8 to its partial
+ // file and silently corrupted the download.
+ const r = await ranged("bytes=99-"); // body is 9 bytes
+ expect(r.status).toBe(416);
+ expect(r.headers.get("content-range")).toBe("bytes */9");
+ expect(await r.text()).toBe("");
+ expect(r.headers.get("Cache-Control")).toBe("no-store"); // 416 >= 400
+ });
+
+ it("Range starting exactly at EOF β†’ 416 (Β§14.1.2: satisfiable iff first-pos < length)", async () => {
+ const r = await ranged("bytes=9-");
+ expect(r.status).toBe(416);
+ expect(r.headers.get("content-range")).toBe("bytes */9");
+ });
+
+ it("closed Range wholly past EOF β†’ 416", async () => {
+ const r = await ranged("bytes=20-30");
+ expect(r.status).toBe(416);
+ });
+
+ it("suffix-length 0 β†’ 416 (Β§14.1.2 names it unsatisfiable)", async () => {
+ const r = await ranged("bytes=-0");
+ expect(r.status).toBe(416);
+ expect(r.headers.get("content-range")).toBe("bytes */9");
+ });
+
+ it("multi-range β†’ 200 with the complete body, not a single-range 206 that misdescribes it", async () => {
+ // R2 ignores multi-ranges and returns the whole object. Stamping
+ // `Content-Range: bytes 0-8/9` on it would claim a single partial covering
+ // everything, in answer to a request for two disjoint sub-ranges.
+ const r = await ranged("bytes=0-1,4-5");
+ expect(r.status).toBe(200);
+ expect(r.headers.get("content-range")).toBeNull();
+ expect(await r.text()).toBe("PDF-BYTES");
+ });
+
+ it("malformed Range β†’ 200 with the complete body (Β§14.1.2: invalid spec is ignored)", async () => {
+ for (const bad of ["bytes=abc", "bytes=-", "bytes=5-2", "items=0-5"]) {
+ const r = await ranged(bad);
+ expect(r.status, `Range: ${bad}`).toBe(200);
+ expect(r.headers.get("content-range"), `Range: ${bad}`).toBeNull();
+ }
+ });
+
+ it("a satisfiable whole-object Range still gets a real 206", async () => {
+ // The 416/200 guards must not swallow the legitimate case: `bytes=0-` IS satisfiable
+ // (first-pos 0 < 9), so it keeps its 206 even though the payload is the whole object.
+ const r = await ranged("bytes=0-");
+ expect(r.status).toBe(206);
+ expect(r.headers.get("content-range")).toBe("bytes 0-8/9");
+ expect(await r.text()).toBe("PDF-BYTES");
+ });
+
+ // --- The classifier's grammar and R2's grammar are NOT the same grammar, and the
+ // Worker no longer assumes they are: it checks the bounds it derived against the bytes
+ // R2 actually returned before promising a 206. ---
+
+ it("tab-separated Range β†’ 200 with the whole body, never a 206 for bytes nobody sliced", async () => {
+ // R2 parses ranges with ASCII space only (/^ *bytes *=/i + /^ *(\d+)? *- *(\d+)? *$/);
+ // the classifier's \s also accepts a tab. So this header says "single, bytes 2-4"
+ // here and "unparseable, serve everything" to R2 β€” and trusting the classifier alone
+ // shipped `206 Content-Range: bytes 0-8/9` carrying all 9 bytes in answer to a
+ // request for 3. Same lying-206 class as the unsatisfiable case above.
+ const r = await ranged("bytes=2\t-\t4");
+ expect(r.status).toBe(200);
+ expect(r.headers.get("content-range")).toBeNull();
+ expect(r.headers.get("content-length")).toBe("9");
+ expect(await r.text()).toBe("PDF-BYTES");
+ });
+
+ it("LEADING whitespace is stripped before either parser sees it, so only INNER whitespace diverges", async () => {
+ // Worth pinning because it bounds the divergence surface. `Headers` strips the
+ // optional whitespace around a field value (RFC 9110 Β§5.5), so "\tbytes=2-4" arrives
+ // as "bytes=2-4" and both grammars accept it β€” the 206 here is correct, not a
+ // regression. Only whitespace INSIDE the value (the test above) can reach the two
+ // parsers intact and be read differently by them.
+ const r = await ranged("\tbytes=2-4");
+ expect(r.status).toBe(206);
+ expect(r.headers.get("content-range")).toBe("bytes 2-4/9");
+ });
+
+ it("space-separated Range stays a 206 β€” R2 accepts spaces, so the bounds still agree", async () => {
+ // The degrade must be driven by actual disagreement, not by giving up on whitespace.
+ const r = await ranged("bytes = 2-4");
+ expect(r.status).toBe(206);
+ expect(r.headers.get("content-range")).toBe("bytes 2-4/9");
+ expect(await r.text()).toBe("F-B");
+ });
+
+ it("positions above 2^53: an invalid spec is ignored (200), not read as unsatisfiable (416)", async () => {
+ // Number() rounds 9007199254740993 down to ...992, so `last < first` read as false and
+ // this invalid spec was promoted to "unsatisfiable" β†’ 416. Β§14.1.2 says ignore it.
+ const r = await ranged("bytes=9007199254740993-9007199254740992");
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("PDF-BYTES");
+ });
+
+ it("a partial body the Worker did not ask for is a 503, never a 206 for bytes nobody requested", async () => {
+ // Belt and braces for a future R2 whose grammar accepts something this classifier
+ // calls "ignored". The body in hand is a slice of bounds the request never named:
+ // a 200 would ship Content-Length: 9 over 3 bytes, and a 206 would carry
+ // `Content-Range: bytes 2-4/9` in answer to `bytes=0-1,4-5` β€” a selected range the
+ // client did not select, which Β§14.4 does not permit. Neither is honest, so the
+ // divergence is surfaced as a failure (plus the r2-range-divergence log line) rather
+ // than dressed up as a success. Unreachable under today's workerd, which ignores
+ // multi-range and returns the whole object.
+ const env = makeEnv({
+ DOCS_ENV: "production",
+ DOCS_PDFS: {
+ get: async () => ({
+ httpEtag: ETAG, size: 9, uploaded: UPLOADED,
+ body: "F-B", range: { offset: 2, length: 3 },
+ }),
+ } as unknown as R2Bucket,
+ });
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", range: "bytes=0-1,4-5" }, // classifier: ignored
+ }),
+ env,
+ );
+ expect(r.status).toBe(503);
+ expect(r.headers.get("content-range")).toBeNull();
+ });
+
+ // --- Β§14.2: "GET is the only method for which range handling is defined." ---
+
+ it("HEAD + Range β†’ 200, no Content-Range: Range is ignored on every method but GET", async () => {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" },
+ );
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ method: "HEAD",
+ headers: { "user-agent": "vitest", range: "bytes=0-3" },
+ }),
+ env,
+ );
+ expect(r.status).toBe(200);
+ expect(r.headers.get("content-range")).toBeNull();
+ expect(r.headers.get("content-length")).toBe("9");
+ });
+
+ it("POST + an unsatisfiable Range β†’ 200, not 416: the header is ignored, not judged", async () => {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" },
+ );
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ method: "POST",
+ headers: { "user-agent": "vitest", range: "bytes=99-" },
+ }),
+ env,
+ );
+ expect(r.status).toBe(200);
+ expect(r.headers.get("content-range")).toBeNull();
+ });
+
+ // --- Β§13.2.1: "a server MUST ignore the conditional request header fields defined by
+ // this specification when received with a request method that does not involve the
+ // selection or modification of a selected representation, such as CONNECT, OPTIONS, or
+ // TRACE." Only OPTIONS is testable through worker.fetch() β€” TRACE and CONNECT are
+ // forbidden methods in the fetch spec and `new Request` refuses to construct them. ---
+
+ it("OPTIONS ignores conditionals entirely: neither a failing nor a matching one is judged", async () => {
+ // A stale If-Match reached R2 as an onlyIf, came back body-less, and this Worker had
+ // no reading of that but 412 β€” so an OPTIONS carrying a conditional a client had left
+ // lying around was refused where the same request without it succeeded.
+ const options = (headers: Record<string, string>) => worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ method: "OPTIONS",
+ headers: { "user-agent": "vitest", ...headers },
+ }),
+ r2Env({ "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" }),
+ );
+ expect((await options({ "if-match": '"stale-etag"' })).status).toBe(200); // not 412
+ expect((await options({ "if-unmodified-since": BEFORE_UPLOAD })).status).toBe(200); // not 412
+ expect((await options({ "if-none-match": ETAG })).status).toBe(200); // not 304/412
+ });
+
+ // --- RFC 9110 Β§13.1.5 If-Range. R2's R2Conditional carries only etagMatches /
+ // etagDoesNotMatch / uploadedBefore / uploadedAfter, so an If-Range in the forwarded
+ // Headers is silently DROPPED and the range applied unconditionally. ---
+
+ async function withIfRange(ifRange: string, rangeHeader: string) {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" },
+ );
+ return worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", range: rangeHeader, "if-range": ifRange },
+ }),
+ env,
+ );
+ }
+
+ // --- Abandoned body streams. R2 hands back a body on paths whose response carries
+ // none; a stream that is neither sent nor cancelled holds its connection until GC. ---
+
+ it("an unsatisfiable Range cancels the whole-object body it answers 416 without", async () => {
+ // The largest abandoned stream on any path here: R2 answers an unsatisfiable Range
+ // with the COMPLETE object, which for the 1.3 PDF is 29.2 MiB, and the 416 sends none
+ // of it. Cancelling aborts the transfer rather than draining or leaking it.
+ const sink = { cancelled: [] as string[] };
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" }, sink,
+ );
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", range: "bytes=99-" },
+ }),
+ env,
+ );
+ expect(r.status).toBe(416);
+ expect(sink.cancelled).toEqual(["PDF-BYTES"]);
+ });
+
+ it("a stale If-Range cancels the sliced body it discards before re-reading", async () => {
+ const sink = { cancelled: [] as string[] };
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" }, sink,
+ );
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", range: "bytes=4-", "if-range": '"stale-etag"' },
+ }),
+ env,
+ );
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("PDF-BYTES");
+ expect(sink.cancelled).toEqual(["BYTES"]); // the abandoned slice, not the served body
+ });
+
+ it("a served body is NEVER cancelled β€” the cleanup must not reach the response path", async () => {
+ const sink = { cancelled: [] as string[] };
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" }, sink,
+ );
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", range: "bytes=4-" }, // satisfiable, honoured
+ }),
+ env,
+ );
+ expect(r.status).toBe(206);
+ expect(await r.text()).toBe("BYTES");
+ expect(sink.cancelled).toEqual([]);
+ });
+
+ it("If-Range matching the current ETag β†’ the range is honoured, 206", async () => {
+ const r = await withIfRange(ETAG, "bytes=4-");
+ expect(r.status).toBe(206);
+ expect(r.headers.get("content-range")).toBe("bytes 4-8/9");
+ expect(await r.text()).toBe("BYTES");
+ });
+
+ it("If-Range naming a STALE ETag β†’ 200 with the complete new representation", async () => {
+ // The corruption case. R2 cannot evaluate If-Range, so it applied the range anyway and
+ // this returned bytes 4+ of the NEW object under a 206 β€” a resuming downloader then
+ // appends the new tail to its old prefix and silently produces a broken PDF. Β§13.1.5
+ // requires the failed validator to yield the complete representation instead.
+ const r = await withIfRange('"stale-etag"', "bytes=4-");
+ expect(r.status).toBe(200);
+ expect(r.headers.get("content-range")).toBeNull();
+ expect(r.headers.get("content-length")).toBe("9");
+ expect(await r.text()).toBe("PDF-BYTES");
+ });
+
+ it("a stale If-Range wins over an unsatisfiable spec β†’ 200, not 416", async () => {
+ // Ordering matters: a Range being ignored entirely (Β§13.1.5) is decided before
+ // satisfiability (Β§14.1.2) is ever judged, so no 416 may escape here.
+ const r = await withIfRange('"stale-etag"', "bytes=99-");
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("PDF-BYTES");
+ });
+
+ it("a WEAK If-Range validator never matches (Β§13.1.5 requires a strong one)", async () => {
+ const r = await withIfRange('W/"pdf-etag-1"', "bytes=4-");
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("PDF-BYTES");
+ });
+
+ it("an HTTP-date If-Range never matches β€” this Worker emits no Last-Modified to compare against", async () => {
+ const r = await withIfRange(AFTER_UPLOAD, "bytes=4-");
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("PDF-BYTES");
+ });
+
+ it("an object rewritten between the two If-Range reads is still judged against the request's preconditions", async () => {
+ // Β§13.2.1 requires preconditions to hold for the representation ULTIMATELY SELECTED.
+ // The re-read after a stale If-Range was a BARE get(), dropping every other
+ // precondition the request carried, so: If-Match passes on read 1, the key is
+ // rewritten, and the bare read 2 then answered 200 with the very representation the
+ // client's If-Match excluded. The key does get rewritten β€” `force_pdf_refresh: true`
+ // in the legacy snapshot repo's deploy workflow re-uploads it.
+ const KEY = "legacy/1.3/vyos-documentation.pdf";
+ const objects: Record<string, { body: string; etag?: string }> = {
+ [KEY]: { body: "PDF-BYTES", etag: ETAG },
+ };
+ // Borrow the shared mock, then wrap it so the object changes BETWEEN the two reads.
+ type Bucket = { get: (key: string, options?: unknown) => Promise<unknown> };
+ const inner = (r2Env(objects) as unknown as { DOCS_PDFS: Bucket }).DOCS_PDFS;
+ let reads = 0;
+ const env = r2Env(objects, {
+ DOCS_ENV: "production",
+ DOCS_PDFS: {
+ get: async (key: string, options?: unknown) => {
+ const result = await inner.get(key, options);
+ if (++reads === 1) objects[key].etag = '"pdf-etag-2"'; // rewritten mid-flight
+ return result;
+ },
+ },
+ });
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: {
+ "user-agent": "vitest",
+ range: "bytes=4-",
+ "if-range": '"stale-etag"', // fails β†’ forces the whole-object re-read
+ "if-match": ETAG, // satisfied on read 1, violated by read 2's object
+ },
+ }),
+ env,
+ );
+ expect(reads).toBe(2);
+ expect(r.status).toBe(412);
+ expect(await r.text()).toBe("");
+ // The validator reported is the one belonging to the object the verdict was reached on.
+ expect(r.headers.get("etag")).toBe('"pdf-etag-2"');
+ expect(r.headers.get("content-type")).not.toBe("application/pdf");
+ });
+
+ // --- Β§13.2.2 preconditions, decided by EVALUATING the validators rather than by
+ // guessing from which headers are present. ---
+
+ it("If-Match satisfied + If-None-Match satisfied on a GET β†’ 304, not 412", async () => {
+ // The inverse of the If-Match-fails case above, and the one presence-inference got
+ // wrong: If-Match matches (so step 1 passes) while If-None-Match also matches (so
+ // step 3 FAILS) β€” Β§13.1.2 owes a 304. Seeing an If-Match header at all returned 412.
+ const r = await conditional({
+ "if-match": '"pdf-etag-1"',
+ "if-none-match": '"pdf-etag-1"',
+ });
+ expect(r.status).toBe(304);
+ expect(await r.text()).toBe("");
+ });
+
+ it("If-Modified-Since alone on a non-GET/HEAD β†’ 200: Β§13.2.2 step 4 never evaluates it", async () => {
+ // R2 ANDs every validator it is handed and knows nothing about the method, so
+ // forwarding the raw headers made it fail the request on a validator the RFC says to
+ // ignore β€” and the only answer left was a 412. Filtering the conditionals down to the
+ // applicable set means the request simply proceeds.
+ const r = await conditional({ "if-modified-since": AFTER_UPLOAD }, "POST");
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("PDF-BYTES");
+ });
+
+ it("If-Modified-Since alone on a GET IS evaluated β†’ 304", async () => {
+ // The other side of the filter: dropping the header for non-GET must not drop it here.
+ expect((await conditional({ "if-modified-since": AFTER_UPLOAD })).status).toBe(304);
+ });
+
+ it("a satisfied If-Unmodified-Since serves the object; a failed one is 412", async () => {
+ expect((await conditional({ "if-unmodified-since": AFTER_UPLOAD })).status).toBe(200);
+ expect((await conditional({ "if-unmodified-since": BEFORE_UPLOAD })).status).toBe(412);
+ });
+
+ it("If-Match: * matches any existing representation (Β§13.1.1)", async () => {
+ expect((await conditional({ "if-match": "*" })).status).toBe(200);
+ });
+
+ it("If-None-Match: * on an existing representation fails β†’ 304 on a GET", async () => {
+ expect((await conditional({ "if-none-match": "*" })).status).toBe(304);
+ });
+
+ it("If-None-Match matches WEAKLY (Β§13.1.2 mandates the weak comparison)", async () => {
+ // A weak tag from the client must still match a strong stored tag, or every
+ // revalidation from a cache that weakened the tag re-downloads 29.2 MiB.
+ expect((await conditional({ "if-none-match": 'W/"pdf-etag-1"' })).status).toBe(304);
+ });
+
+ it("If-None-Match honours a comma-separated tag list", async () => {
+ const r = await conditional({ "if-none-match": '"other", "pdf-etag-1"' });
+ expect(r.status).toBe(304);
+ });
+
+ it("If-Match compares STRONGLY: a weak tag from the client never satisfies it", async () => {
+ expect((await conditional({ "if-match": 'W/"pdf-etag-1"' })).status).toBe(412);
+ });
+ });
+
+ describe("resolveRange (R2Range is a three-shape union)", () => {
+ it("resolves offset+length, length-only, and suffix forms, clamped to the object size", () => {
+ expect(resolveRange({ offset: 2, length: 3 }, 10)).toEqual({ start: 2, length: 3 });
+ expect(resolveRange({ offset: 4 }, 10)).toEqual({ start: 4, length: 6 }); // to end of object
+ expect(resolveRange({ length: 4 }, 10)).toEqual({ start: 0, length: 4 }); // offset defaults to 0
+ expect(resolveRange({ suffix: 3 }, 10)).toEqual({ start: 7, length: 3 }); // trailing bytes
+ expect(resolveRange({ suffix: 99 }, 10)).toEqual({ start: 0, length: 10 }); // suffix past start clamps
+ expect(resolveRange({ offset: 8, length: 99 }, 10)).toEqual({ start: 8, length: 2 }); // length clamps
+ });
+
+ it("never returns a negative length, so Content-Length can never go negative", () => {
+ // A real R2 binding cannot produce these, but resolveRange is exported and its
+ // contract says "clamped to the object size" β€” that must hold for every input the
+ // R2Range type admits, not just the ones observed in practice.
+ expect(resolveRange({ offset: 0, length: -5 }, 10)).toEqual({ start: 0, length: 0 });
+ expect(resolveRange({ offset: 10, length: 5 }, 10)).toEqual({ start: 10, length: 0 });
+ expect(resolveRange({ offset: 99 }, 10)).toEqual({ start: 10, length: 0 });
+ expect(resolveRange({ suffix: -3 }, 10)).toEqual({ start: 10, length: 0 });
+ expect(resolveRange({ offset: 0, length: 0 }, 0)).toEqual({ start: 0, length: 0 });
+ });
+ });
+
+ describe("classifyRangeHeader (Β§14.1.2 satisfiability, re-derived from the client's header)", () => {
+ // R2 cannot tell us: it answers unsatisfiable, malformed, multi-range and unknown-unit
+ // Range headers identically β€” with the complete object β€” which is also exactly what a
+ // satisfied whole-object range looks like. Verified against a real R2 binding.
+ it("single satisfiable byte ranges β†’ single, with the concrete bounds they select", () => {
+ // The bounds are the point: they are what the Worker compares against the bytes R2
+ // actually returned before it will promise a 206.
+ const cases: Array<[string, number, number]> = [
+ ["bytes=0-", 0, 10], ["bytes=5-", 5, 5], ["bytes=0-0", 0, 1],
+ ["bytes=-3", 7, 3], ["bytes=0-9", 0, 10],
+ ["bytes=5-99", 5, 5], // last-pos clamps to EOF
+ ["bytes=9-", 9, 1],
+ ["bytes=-99", 0, 10], // suffix past the start is the whole object
+ ];
+ for (const [h, start, length] of cases) {
+ expect(classifyRangeHeader(h, 10), h).toEqual({ kind: "single", start, length });
+ }
+ });
+
+ it("unsatisfiable ranges β†’ unsatisfiable", () => {
+ for (const h of ["bytes=10-", "bytes=99-", "bytes=10-20", "bytes=-0"]) {
+ // first-pos >= length, or suffix-length 0
+ expect(classifyRangeHeader(h, 10), h).toEqual({ kind: "unsatisfiable" });
+ }
+ });
+
+ it("multi-range, malformed and unknown-unit β†’ ignored (Β§14.1.2: an invalid spec is ignored)", () => {
+ for (const h of ["bytes=0-1,4-5", "bytes=abc", "bytes=-", "items=0-5", "bytes=5-2", ""]) {
+ expect(classifyRangeHeader(h, 10), h).toEqual({ kind: "ignored" });
+ }
+ });
+
+ it("tolerates the case and whitespace variation R2 itself accepts", () => {
+ // R2 honours all three of these, so misreading them as "ignored" would downgrade a
+ // legitimate 206 to a 200.
+ expect(classifyRangeHeader("BYTES=0-5", 10)).toEqual({ kind: "single", start: 0, length: 6 });
+ expect(classifyRangeHeader("bytes = 0-5", 10)).toEqual({ kind: "single", start: 0, length: 6 });
+ expect(classifyRangeHeader("bytes=0-5 ", 10)).toEqual({ kind: "single", start: 0, length: 6 });
+ });
+
+ it("zero-length representation: only a non-zero suffix-range is satisfiable", () => {
+ // Β§14.1.2 states this case explicitly. `bytes=0-` fails first-pos < length (0 < 0).
+ expect(classifyRangeHeader("bytes=0-", 0)).toEqual({ kind: "unsatisfiable" });
+ // Satisfiable, but it selects zero bytes β€” no Content-Range can describe an empty
+ // selection (Β§14.4), so the caller's `length > 0` guard sends it to a 200.
+ expect(classifyRangeHeader("bytes=-5", 0)).toEqual({ kind: "single", start: 0, length: 0 });
+ });
+
+ it("positions above 2^53 compare exactly β€” an invalid spec stays ignored, not 416", () => {
+ // Number() rounds both of these to 9007199254740992, so `last < first` read as false
+ // and the spec was promoted from "invalid, ignore it" (Β§14.1.2 β†’ 200) to
+ // "unsatisfiable" (β†’ 416). Digit-string comparison is exact at any magnitude.
+ expect(classifyRangeHeader("bytes=9007199254740993-9007199254740992", 10))
+ .toEqual({ kind: "ignored" });
+ // ...while a genuinely huge first-pos is still unsatisfiable.
+ expect(classifyRangeHeader("bytes=9007199254740993-", 10)).toEqual({ kind: "unsatisfiable" });
+ // Leading zeros normalise rather than inflating the digit count.
+ expect(classifyRangeHeader("bytes=00000005-00000002", 10)).toEqual({ kind: "ignored" });
+ expect(classifyRangeHeader("bytes=0000000002-0000000005", 10))
+ .toEqual({ kind: "single", start: 2, length: 4 });
+ });
+
+ it("accepts whitespace R2's own parser rejects β€” the divergence the bounds check absorbs", () => {
+ // R2 parses ranges with `/^ *bytes *=/i` + `/^ *(\d+)? *- *(\d+)? *$/` (ASCII space
+ // only; miniflare src/workers/shared/range.ts). This classifier's `\s` accepts a tab
+ // too, so the two grammars genuinely disagree here. That is tolerated by design: the
+ // Worker checks these bounds against the bytes R2 returned, so a spec R2 declined
+ // degrades to a 200 rather than to a 206 describing a body nobody asked for. The
+ // end-to-end proof is the "tab-separated Range" test below.
+ expect(classifyRangeHeader("bytes=2\t-\t4", 10)).toEqual({ kind: "single", start: 2, length: 3 });
+ expect(classifyRangeHeader("\tbytes=2-4", 10)).toEqual({ kind: "single", start: 2, length: 3 });
+ });
+ });
+});
diff --git a/workers/apex/test/uagate.test.ts b/workers/apex/test/uagate.test.ts
new file mode 100644
index 00000000..1989f847
--- /dev/null
+++ b/workers/apex/test/uagate.test.ts
@@ -0,0 +1,102 @@
+import { describe, it, expect } from "vitest";
+import { uaVerdict } from "../src/uagate";
+import policy from "../ua-policy.json";
+
+describe("UA gate (Β§3.2.1) β€” ships log-only for AI crawlers", () => {
+ it("search engines always allowed", () => {
+ expect(uaVerdict("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", policy)).toBe("allow");
+ expect(uaVerdict("Mozilla/5.0 (compatible; bingbot/2.0)", policy)).toBe("allow");
+ });
+ it("AI-training crawlers are log-only initially", () => {
+ expect(uaVerdict("GPTBot/1.0", policy)).toBe("log");
+ expect(uaVerdict("CCBot/2.0", policy)).toBe("log");
+ });
+ it("named abusers blocked", () => {
+ expect(uaVerdict("EvilScraper/0.1", { ...policy, block: ["EvilScraper"] })).toBe("block");
+ });
+ it("unknown UA β†’ allow (fail-open for humans)", () => {
+ expect(uaVerdict("Mozilla/5.0 (X11; Linux x86_64) Firefox/128.0", policy)).toBe("allow");
+ });
+ it("Applebot is allowed but Applebot-Extended is logged β€” most-specific match wins", () => {
+ // Apple's AI-training crawler token CONTAINS the search crawler's, so plain
+ // substring matching with a fixed allow-before-log precedence let the allow entry
+ // swallow it: the AI crawler was allowed AND never logged, unlike every other AI
+ // crawler in the log list.
+ expect(uaVerdict("Mozilla/5.0 (compatible; Applebot/0.1; +http://www.apple.com/go/applebot)", policy)).toBe("allow");
+ expect(uaVerdict("Mozilla/5.0 (compatible; Applebot-Extended/0.1)", policy)).toBe("log");
+ });
+ it("Google-Extended is not a UA token β€” it must not sit in the UA policy at all", () => {
+ // Google-Extended is a robots.txt user-agent control token; it never appears in a
+ // User-Agent header, so an entry for it could only ever be dead weight.
+ // Compared case-INSENSITIVELY on both sides: bestMatch() lowercases every policy entry
+ // before matching, so "google-extended" would be functionally identical to the token
+ // this guard exists to keep out β€” but toContain() compares primitives by strict
+ // equality, so a lowercase variant would sail past a case-sensitive assertion and
+ // quietly restore the entry. Match the matcher's own case semantics.
+ const entries = [...policy.allow, ...policy.log, ...policy.block].map((e) => e.toLowerCase());
+ expect(entries).not.toContain("google-extended");
+ });
+ it("block takes precedence over allow on a UA matching both lists", () => {
+ const dualMatch = { ...policy, allow: ["Googlebot"], block: ["Googlebot EvilScraper"] };
+ expect(uaVerdict("Mozilla/5.0 (compatible; Googlebot EvilScraper/1.0)", dualMatch)).toBe("block");
+ });
+
+ // --- allow-vs-log contests. Pinned verdicts for the four UAs that distinguish every
+ // candidate rule, so a future tweak to the precedence cannot silently drop telemetry. ---
+
+ it("a UA carrying BOTH a log token and a longer allow token is logged, not allowed", () => {
+ // "GPTBot/1.0 DuckDuckBot" matches allow "DuckDuckBot" (11 chars) and log "GPTBot" (6).
+ // Under the longest-match rule the longer ALLOW needle won and the ua-log event never
+ // fired; under the original allow-before-log rule it also won. A UA presenting two
+ // different crawlers' tokens is precisely the shape worth recording, and `log` costs
+ // nothing but a log line β€” the request is served either way.
+ expect(uaVerdict("GPTBot/1.0 DuckDuckBot", policy)).toBe("log");
+ });
+
+ it("pinned verdicts for the four discriminating UAs", () => {
+ expect(uaVerdict("Mozilla/5.0 (compatible; Applebot/0.1)", policy)).toBe("allow");
+ expect(uaVerdict("Mozilla/5.0 (compatible; Applebot-Extended/0.1)", policy)).toBe("log");
+ expect(uaVerdict("GPTBot/1.0 DuckDuckBot", policy)).toBe("log");
+ expect(uaVerdict("Mozilla/5.0 (compatible; Googlebot/2.1)", policy)).toBe("allow");
+ });
+
+ it("a narrow allow entry no longer overrides a matched log entry β€” log wins outright", () => {
+ // This branch used to return "allow" when the matched allow entry strictly CONTAINED
+ // the matched log entry, so a policy could carve a narrow allow out of a broad log
+ // entry. Removed as spoofable (see the next test). Both rows are now "log", which is
+ // the safe verdict β€” the request is still served either way; only telemetry differs.
+ const carveOut = { allow: ["Bytespider-Search"], log: ["Bytespider"], block: [] };
+ expect(uaVerdict("Bytespider-Search/1.0", carveOut)).toBe("log");
+ expect(uaVerdict("Bytespider/1.0", carveOut)).toBe("log");
+ });
+
+ it("the removed carve-out was spoofable by quoting both tokens independently", () => {
+ // The concrete bypass. Containment was tested between the two matched ENTRIES, never
+ // against the UA's own token structure, so a request-controlled string naming both
+ // tokens separately matched allow "Bytespider-Search" and log "Bytespider", satisfied
+ // the containment test, and bought the AI crawler an `allow`. It must be logged.
+ const carveOut = { allow: ["Bytespider-Search"], log: ["Bytespider"], block: [] };
+ expect(uaVerdict("Bytespider/2.0 Bytespider-Search/1.0", carveOut)).toBe("log");
+ });
+
+ it("dropping the carve-out leaves every SHIPPED-policy verdict unchanged", () => {
+ // The vendor pair the shipped policy actually depends on runs the other way round: log
+ // "Applebot-Extended" is LONGER than allow "Applebot", so the allow entry never
+ // contained the log entry and log already won. No pair in ua-policy.json took the
+ // removed branch, so its removal is behaviour-preserving for what we ship.
+ expect(uaVerdict("Mozilla/5.0 (compatible; Applebot/0.1)", policy)).toBe("allow");
+ expect(uaVerdict("Mozilla/5.0 (compatible; Applebot-Extended/0.1)", policy)).toBe("log");
+ });
+
+ it("an entry present in BOTH lists resolves to log, not allow", () => {
+ // Equality is not containment. Listing the same token twice is an authoring error, and
+ // `log` is the resolution that cannot lose data.
+ const contradictory = { allow: ["CCBot"], log: ["CCBot"], block: [] };
+ expect(uaVerdict("CCBot/2.0", contradictory)).toBe("log");
+ });
+
+ it("block still short-circuits ahead of the allow-vs-log contest", () => {
+ const all3 = { allow: ["DuckDuckBot"], log: ["GPTBot"], block: ["EvilScraper"] };
+ expect(uaVerdict("GPTBot/1.0 DuckDuckBot EvilScraper", all3)).toBe("block");
+ });
+});
diff --git a/workers/apex/ua-policy.json b/workers/apex/ua-policy.json
new file mode 100644
index 00000000..f0021be5
--- /dev/null
+++ b/workers/apex/ua-policy.json
@@ -0,0 +1,5 @@
+{
+ "allow": ["Googlebot", "bingbot", "DuckDuckBot", "YandexBot", "Applebot", "UptimeRobot"],
+ "log": ["GPTBot", "CCBot", "ClaudeBot", "Applebot-Extended", "Bytespider", "PerplexityBot", "meta-externalagent"],
+ "block": []
+}
diff --git a/workers/apex/wrangler.jsonc b/workers/apex/wrangler.jsonc
new file mode 100644
index 00000000..71c831f3
--- /dev/null
+++ b/workers/apex/wrangler.jsonc
@@ -0,0 +1,39 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-apex",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ "assets": { "directory": "assets", "binding": "ASSETS", "run_worker_first": true },
+ "env": {
+ "canary": {
+ // Routes are MANAGED MANUALLY (v4.2 amendment): the account-owned CI token has no
+ // Workers Routes group, and β€” decisive β€” docs.vyos.io is ALREADY orange-cloud, so a
+ // config-managed production route would capture live traffic on first env deploy.
+ // Canary route (docs-next.vyos.io/* β†’ this worker, env canary) is created once by the
+ // operator/dashboard (Task 3.6 step 2c); the production route IS the cutover (Task 6.2).
+ "vars": { "APEX_BUILD_SHA": "dev", "DOCS_ENV": "canary" },
+ "services": [
+ { "binding": "DOCS_ROLLING", "service": "vyos-docs-rolling-en-candidate" },
+ { "binding": "DOCS_V15", "service": "vyos-docs-v15-en-candidate" },
+ { "binding": "DOCS_V14", "service": "vyos-docs-v14-en-candidate" },
+ { "binding": "DOCS_LEGACY", "service": "vyos-docs-legacy-candidate" }
+ ],
+ // Β§5 apex PDF fallback: same bucket as production β€” there's only one copy of the
+ // (single, immutable) oversized 1.3 PDF artifact, not a per-env candidate/live pair.
+ "r2_buckets": [{ "binding": "DOCS_PDFS", "bucket_name": "vyos-docs-artifacts" }]
+ },
+ "production": {
+ // production route deliberately ABSENT β€” created manually at cutover (Task 6.2); see canary note above
+ "vars": { "APEX_BUILD_SHA": "dev", "DOCS_ENV": "production" },
+ "services": [
+ { "binding": "DOCS_ROLLING", "service": "vyos-docs-rolling-en" },
+ { "binding": "DOCS_V15", "service": "vyos-docs-v15-en" },
+ { "binding": "DOCS_V14", "service": "vyos-docs-v14-en" },
+ { "binding": "DOCS_LEGACY", "service": "vyos-docs-legacy" }
+ ],
+ "r2_buckets": [{ "binding": "DOCS_PDFS", "bucket_name": "vyos-docs-artifacts" }]
+ }
+ }
+}
diff --git a/workers/bootstrap.sh b/workers/bootstrap.sh
new file mode 100755
index 00000000..a7454f9f
--- /dev/null
+++ b/workers/bootstrap.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# One-time bootstrap: deploy placeholder versions of every service-binding
+# target so the apex Worker (whose config binds all of them) can deploy.
+# Requires CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID in the environment.
+set -euo pipefail
+cd "$(dirname "$0")"
+
+mkdir -p ../dist/assets
+for slug in rolling 1.5 1.4 1.3 1.2; do
+ mkdir -p "../dist/assets/en/$slug"
+ printf '<html><body>bootstrap placeholder β€” real build pending</body></html>' \
+ > "../dist/assets/en/$slug/index.html"
+done
+
+deploy() { # config-suffix worker-name
+ for suffix in "" "-candidate"; do
+ npx wrangler deploy --config "branch/wrangler.$1.jsonc" \
+ --name "$2$suffix" \
+ --var DOCS_BUILD_SHA:bootstrap \
+ --var DOCS_ENV:$( [ -n "$suffix" ] && echo canary || echo production )
+ done
+}
+
+npm ci
+deploy rolling vyos-docs-rolling-en
+deploy v15 vyos-docs-v15-en
+deploy v14 vyos-docs-v14-en
+deploy legacy vyos-docs-legacy
+echo "bootstrap complete β€” all 8 binding targets exist; apex can now deploy"
diff --git a/workers/branch/src/index.ts b/workers/branch/src/index.ts
new file mode 100644
index 00000000..47ef3ad6
--- /dev/null
+++ b/workers/branch/src/index.ts
@@ -0,0 +1,83 @@
+export interface Env {
+ ASSETS: Fetcher; // static assets binding
+ DOCS_BUILD_SHA: string; // injected at deploy
+ DOCS_ENV: "production" | "canary";
+}
+
+export type CacheClass = "page" | "asset";
+
+// Binary/media asset extensions (case-insensitive, so ".PDF" also matches) get the longer
+// asset cache class, alongside the Sphinx /_static/ (theme) + /_images/ (figure) trees.
+const ASSET_EXT_RE = /\.(pdf|png|jpe?g|webp|svg|gif|ico|woff2?|ttf|otf|eot)$/i;
+
+export function classifyPath(path: string): CacheClass {
+ if (
+ path.includes("/_static/") ||
+ path.includes("/_images/") ||
+ ASSET_EXT_RE.test(path)
+ )
+ return "asset";
+ return "page"; // HTML, versions.json, sitemaps, robots/llms, pagefind index
+}
+
+export function cacheHeaderFor(cls: CacheClass): string {
+ return cls === "asset"
+ ? "public, max-age=300, s-maxage=600, must-revalidate"
+ : "public, max-age=0, s-maxage=300, must-revalidate";
+}
+
+export function withDocsHeaders(
+ resp: Response,
+ path: string,
+ env: Pick<Env, "DOCS_BUILD_SHA" | "DOCS_ENV">,
+): Response {
+ const out = new Response(resp.body, resp);
+ out.headers.set("X-Docs-Build", env.DOCS_BUILD_SHA);
+ out.headers.set(
+ "Cache-Control",
+ // Error responses (4xx/5xx) must never carry the page/asset cache class β€” a
+ // cached 404 would poison the edge for the full s-maxage window.
+ env.DOCS_ENV === "canary" || out.status >= 400
+ ? "no-store"
+ : cacheHeaderFor(classifyPath(path)),
+ );
+ return out;
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise<Response> {
+ const url = new URL(request.url);
+ // With assets html_handling "none", the runtime serves explicit .html URLs directly
+ // but does NOT map a directory URL ("/foo/") to its index.html β€” the worker must map
+ // trailing-slash URLs to index.html itself to preserve ReadTheDocs URL parity.
+ if (url.pathname.endsWith("/")) {
+ const mapped = new URL(url);
+ mapped.pathname = url.pathname + "index.html";
+ const resp = await env.ASSETS.fetch(new Request(mapped, request));
+ return withDocsHeaders(resp, url.pathname, env);
+ }
+ // Bare extensionless path (no trailing slash, no "." in the last segment): it may be a
+ // real directory whose slashed form RTD 301-redirects to ("/foo" β†’ "/foo/"), or a
+ // file-like path with no matching asset (e.g. "/cli", whose real asset is "cli.html")
+ // that RTD 404s. A dot heuristic can't separate them, so probe the assets binding for
+ // "<path>/index.html": 200 β†’ 301 to the slashed form; anything else β†’ fall through to
+ // the exact-path fetch (404 for "/cli", matching live RTD).
+ const lastSegment = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
+ if (lastSegment !== "" && !lastSegment.includes(".")) {
+ const probe = new URL(url);
+ probe.pathname = url.pathname + "/index.html";
+ const probeResp = await env.ASSETS.fetch(new Request(probe, { method: "GET" }));
+ probeResp.body?.cancel(); // existence check only β€” release the probe body stream
+ if (probeResp.status === 200) {
+ const location = url.pathname + "/" + url.search; // preserve query; no fragment
+ return withDocsHeaders(
+ new Response(null, { status: 301, headers: { Location: location } }),
+ url.pathname,
+ env,
+ );
+ }
+ }
+ const resp = await env.ASSETS.fetch(request);
+ return withDocsHeaders(resp, url.pathname, env);
+ },
+} satisfies ExportedHandler<Env>;
diff --git a/workers/branch/test/content.test.ts b/workers/branch/test/content.test.ts
new file mode 100644
index 00000000..6d04c35b
--- /dev/null
+++ b/workers/branch/test/content.test.ts
@@ -0,0 +1,278 @@
+import { describe, it, expect } from "vitest";
+import worker, { classifyPath, cacheHeaderFor, withDocsHeaders, type Env } from "../src/index";
+// The workers pool has no real filesystem; import the branch wrangler configs as Vite `?raw`
+// assets (pattern from apex/test/manifest.test.ts) so their content is inlined at bundle time
+// for the html_handling congruence pin at the bottom of this file.
+// eslint-disable-next-line import/no-unresolved
+import wranglerRolling from "../wrangler.rolling.jsonc?raw";
+// eslint-disable-next-line import/no-unresolved
+import wranglerV15 from "../wrangler.v15.jsonc?raw";
+// eslint-disable-next-line import/no-unresolved
+import wranglerV14 from "../wrangler.v14.jsonc?raw";
+// eslint-disable-next-line import/no-unresolved
+import wranglerLegacy from "../wrangler.legacy.jsonc?raw";
+
+describe("cache classes (Β§3.3)", () => {
+ it("HTML + config class β†’ max-age=0, s-maxage=300", () => {
+ for (const p of ["/en/rolling/index.html", "/en/rolling/versions.json",
+ "/en/rolling/sitemap.xml", "/en/rolling/pagefind/pagefind.js"]) {
+ expect(cacheHeaderFor(classifyPath(p)))
+ .toBe("public, max-age=0, s-maxage=300, must-revalidate");
+ }
+ });
+ it("PDF + _static class β†’ max-age=300, s-maxage=600", () => {
+ for (const p of ["/en/rolling/vyos-documentation.pdf", "/en/rolling/_static/css/theme.css"]) {
+ expect(cacheHeaderFor(classifyPath(p)))
+ .toBe("public, max-age=300, s-maxage=600, must-revalidate");
+ }
+ });
+});
+
+describe("response headers", () => {
+ it("adds X-Docs-Build and cache-control; canary forces no-store", () => {
+ const base = new Response("ok", { headers: { "content-type": "text/html" } });
+ const prod = withDocsHeaders(base, "/en/rolling/index.html",
+ { DOCS_BUILD_SHA: "abc123", DOCS_ENV: "production" });
+ expect(prod.headers.get("X-Docs-Build")).toBe("abc123");
+ expect(prod.headers.get("Cache-Control")).toBe("public, max-age=0, s-maxage=300, must-revalidate");
+ const canary = withDocsHeaders(base, "/en/rolling/index.html",
+ { DOCS_BUILD_SHA: "abc123", DOCS_ENV: "canary" });
+ expect(canary.headers.get("Cache-Control")).toBe("no-store");
+ });
+});
+
+describe("default fetch entrypoint", () => {
+ const makeEnv = (docsEnv: Env["DOCS_ENV"], seen: string[]): Env => ({
+ ASSETS: {
+ fetch: async (req: Request) => {
+ seen.push(req.url);
+ return new Response("<html>", { headers: { "content-type": "text/html" } });
+ },
+ } as unknown as Fetcher,
+ DOCS_BUILD_SHA: "testsha",
+ DOCS_ENV: docsEnv,
+ });
+
+ it("serves assets verbatim with docs headers; path is byte-stable", async () => {
+ const seen: string[] = [];
+ const url = "https://docs.vyos.io/en/rolling/index.html";
+ const resp = await worker.fetch(new Request(url), makeEnv("production", seen));
+ expect(resp.headers.get("X-Docs-Build")).toBe("testsha");
+ expect(resp.headers.get("Cache-Control"))
+ .toBe("public, max-age=0, s-maxage=300, must-revalidate");
+ expect(seen).toEqual([url]); // original request URL reached ASSETS unmodified
+ });
+
+ it("canary env forces no-store on the fetch path too", async () => {
+ const seen: string[] = [];
+ const resp = await worker.fetch(
+ new Request("https://docs.vyos.io/en/rolling/index.html"),
+ makeEnv("canary", seen),
+ );
+ expect(resp.headers.get("Cache-Control")).toBe("no-store");
+ expect(resp.headers.get("X-Docs-Build")).toBe("testsha");
+ });
+
+ it("4xx/5xx responses are never cached, even in production", async () => {
+ const env: Env = {
+ ASSETS: {
+ fetch: async () => new Response("nope", { status: 404, headers: { "content-type": "text/html" } }),
+ } as unknown as Fetcher,
+ DOCS_BUILD_SHA: "testsha",
+ DOCS_ENV: "production",
+ };
+ const resp = await worker.fetch(
+ new Request("https://docs.vyos.io/en/rolling/missing.html"),
+ env,
+ );
+ expect(resp.status).toBe(404);
+ expect(resp.headers.get("Cache-Control")).toBe("no-store");
+ });
+});
+
+describe("directory-index mapping (html_handling \"none\", Β§3.2.3 amended 2026-07-22)", () => {
+ // NOTE: these tests mock the ASSETS binding β€” they exercise the WORKER's directory
+ // mapping + bare-directory probe logic, NOT the wrangler `html_handling` config (which
+ // only exists at deploy time). The config-level backstop is the smoke gate,
+ // scripts/docs_gates/smoke.py; the raw-string congruence test at the bottom of this file
+ // is the unit-level pin against a silent revert.
+ //
+ // Assets binding stub emulating html_handling:"none": exact-path lookups only β€” no
+ // extension inference and no directory auto-index. A bare "/foo/" resolves only because
+ // the worker rewrites it to "/foo/index.html" first; a bare "/foo" 301s only because the
+ // worker probes "/foo/index.html" and finds it β€” exactly the behavior under test.
+ const ASSET_MAP: Record<string, string> = {
+ "/en/rolling/index.html": "<html>root index</html>",
+ "/en/rolling/cli.html": "<html>cli page</html>",
+ "/en/rolling/guide/index.html": "<html>guide index</html>",
+ "/en/rolling/installation/index.html": "<html>installation index</html>",
+ };
+
+ const makeAssetsEnv = (docsEnv: Env["DOCS_ENV"], seen: string[]): Env => ({
+ ASSETS: {
+ fetch: async (req: Request) => {
+ seen.push(req.url);
+ const body = ASSET_MAP[new URL(req.url).pathname];
+ return body === undefined
+ ? new Response("not found", { status: 404, headers: { "content-type": "text/html" } })
+ : new Response(body, { headers: { "content-type": "text/html" } });
+ },
+ } as unknown as Fetcher,
+ DOCS_BUILD_SHA: "testsha",
+ DOCS_ENV: docsEnv,
+ });
+
+ it("trailing-slash directory URL is mapped to index.html β†’ 200 + index content", async () => {
+ const seen: string[] = [];
+ const resp = await worker.fetch(
+ new Request("https://docs.vyos.io/en/rolling/"),
+ makeAssetsEnv("production", seen),
+ );
+ expect(resp.status).toBe(200);
+ expect(await resp.text()).toBe("<html>root index</html>");
+ // worker rewrote "/en/rolling/" β†’ "/en/rolling/index.html" before hitting ASSETS
+ expect(seen).toEqual(["https://docs.vyos.io/en/rolling/index.html"]);
+ // 200 still carries the build stamp + the page cache class
+ expect(resp.headers.get("X-Docs-Build")).toBe("testsha");
+ expect(resp.headers.get("Cache-Control"))
+ .toBe("public, max-age=0, s-maxage=300, must-revalidate");
+ });
+
+ it("explicit .html URL is served directly β€” 200, never a 3xx redirect", async () => {
+ const seen: string[] = [];
+ const resp = await worker.fetch(
+ new Request("https://docs.vyos.io/en/rolling/cli.html"),
+ makeAssetsEnv("production", seen),
+ );
+ expect(resp.status).toBe(200);
+ expect(resp.status).toBeLessThan(300); // no 307/308 for explicit .html paths
+ expect(await resp.text()).toBe("<html>cli page</html>");
+ // passed through unmodified β€” the worker never rewrites explicit-file paths
+ expect(seen).toEqual(["https://docs.vyos.io/en/rolling/cli.html"]);
+ expect(resp.headers.get("X-Docs-Build")).toBe("testsha");
+ expect(resp.headers.get("Cache-Control"))
+ .toBe("public, max-age=0, s-maxage=300, must-revalidate");
+ });
+
+ it("explicit nested /folder/index.html is served directly β†’ 200", async () => {
+ const seen: string[] = [];
+ const resp = await worker.fetch(
+ new Request("https://docs.vyos.io/en/rolling/guide/index.html"),
+ makeAssetsEnv("production", seen),
+ );
+ expect(resp.status).toBe(200);
+ expect(await resp.text()).toBe("<html>guide index</html>");
+ expect(seen).toEqual(["https://docs.vyos.io/en/rolling/guide/index.html"]);
+ expect(resp.headers.get("X-Docs-Build")).toBe("testsha");
+ expect(resp.headers.get("Cache-Control"))
+ .toBe("public, max-age=0, s-maxage=300, must-revalidate");
+ });
+
+ it("extensionless file-like path (no dir behind it) probes, misses, then 404s", async () => {
+ const seen: string[] = [];
+ const resp = await worker.fetch(
+ new Request("https://docs.vyos.io/en/rolling/cli"),
+ makeAssetsEnv("production", seen),
+ );
+ expect(resp.status).toBe(404);
+ // probe "/cli/index.html" misses (no such dir) β†’ fall through to the exact-path fetch of
+ // "/cli", which also misses (real asset is cli.html); html_handling:"none" never infers it.
+ expect(seen).toEqual([
+ "https://docs.vyos.io/en/rolling/cli/index.html",
+ "https://docs.vyos.io/en/rolling/cli",
+ ]);
+ // a 404 must never carry a cacheable page/asset class
+ expect(resp.headers.get("Cache-Control")).toBe("no-store");
+ });
+
+ it("directory mapping preserves the original query string", async () => {
+ const seen: string[] = [];
+ const resp = await worker.fetch(
+ new Request("https://docs.vyos.io/en/rolling/?q=foo"),
+ makeAssetsEnv("production", seen),
+ );
+ expect(resp.status).toBe(200);
+ expect(seen).toEqual(["https://docs.vyos.io/en/rolling/index.html?q=foo"]);
+ });
+
+ it("bare directory path (real dir behind it) β†’ 301 to the slashed form, query preserved", async () => {
+ const seen: string[] = [];
+ const resp = await worker.fetch(
+ new Request("https://docs.vyos.io/en/rolling/installation?q=x"),
+ makeAssetsEnv("production", seen),
+ );
+ expect(resp.status).toBe(301);
+ expect(resp.headers.get("Location")).toBe("/en/rolling/installation/?q=x");
+ expect(resp.headers.get("X-Docs-Build")).toBe("testsha");
+ // only the index.html probe reached ASSETS β€” the redirect short-circuits the passthrough
+ expect(seen).toEqual(["https://docs.vyos.io/en/rolling/installation/index.html?q=x"]);
+ });
+
+ it("trailing-slash mapping preserves request method + conditional/range headers", async () => {
+ // Codex: pins that `new Request(mapped, request)` carries method + headers to ASSETS,
+ // so conditional-GET (304) and Range (206) still work on directory URLs.
+ let captured: Request | undefined;
+ const env: Env = {
+ ASSETS: {
+ fetch: async (req: Request) => {
+ captured = req;
+ return new Response(null, { headers: { "content-type": "text/html" } });
+ },
+ } as unknown as Fetcher,
+ DOCS_BUILD_SHA: "testsha",
+ DOCS_ENV: "production",
+ };
+ await worker.fetch(
+ new Request("https://docs.vyos.io/en/rolling/", {
+ method: "HEAD",
+ headers: { "If-None-Match": '"abc"', Range: "bytes=0-0" },
+ }),
+ env,
+ );
+ expect(captured?.url).toBe("https://docs.vyos.io/en/rolling/index.html");
+ expect(captured?.method).toBe("HEAD");
+ expect(captured?.headers.get("If-None-Match")).toBe('"abc"');
+ expect(captured?.headers.get("Range")).toBe("bytes=0-0");
+ });
+});
+
+describe("asset cache-class classification (Β§3.3, extended)", () => {
+ it("images, fonts, and the /_images/ + /_static/ trees get the asset class", () => {
+ for (const p of [
+ "/en/rolling/_images/diagram.png",
+ "/en/rolling/_static/fonts/roboto.woff2",
+ "/en/rolling/logo.svg",
+ "/en/rolling/photo.jpeg",
+ "/en/rolling/hero.webp", // webp added to ASSET_EXT_RE (standalone path)
+ "/en/rolling/font.otf", // otf added to ASSET_EXT_RE (standalone path)
+ "/en/rolling/icon.ico",
+ "/en/rolling/vyos-documentation.pdf",
+ "/en/rolling/vyos-documentation.PDF", // .pdf folded into the case-insensitive regex
+ ]) {
+ expect(classifyPath(p)).toBe("asset");
+ }
+ });
+ it("HTML pages and data files stay the page class", () => {
+ for (const p of [
+ "/en/rolling/index.html",
+ "/en/rolling/cli.html",
+ "/en/rolling/versions.json",
+ "/en/rolling/sitemap.xml",
+ ]) {
+ expect(classifyPath(p)).toBe("page");
+ }
+ });
+});
+
+describe("wrangler config congruence β€” html_handling pinned to \"none\"", () => {
+ // Unit tests mock ASSETS and never exercise the real wrangler html_handling config; this
+ // raw-string assertion is the unit-level pin against a silent revert to
+ // "auto-trailing-slash" (which reintroduces the 307-on-explicit-.html smoke failure). The
+ // deploy-time smoke gate (scripts/docs_gates/smoke.py) is the runtime-level backstop.
+ it("all four branch wrangler envs set html_handling \"none\"", () => {
+ for (const raw of [wranglerRolling, wranglerV15, wranglerV14, wranglerLegacy]) {
+ expect(raw).toContain('"html_handling": "none"');
+ expect(raw).not.toContain("auto-trailing-slash");
+ }
+ });
+});
diff --git a/workers/branch/wrangler.legacy.jsonc b/workers/branch/wrangler.legacy.jsonc
new file mode 100644
index 00000000..aef5144e
--- /dev/null
+++ b/workers/branch/wrangler.legacy.jsonc
@@ -0,0 +1,16 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-legacy",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ "assets": {
+ "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3
+ "binding": "ASSETS",
+ "html_handling": "none", // "none" + worker index-mapping: RTD parity β€” explicit .html URLs must serve 200, never 307 (Β§3.2.3 amended 2026-07-22)
+ "not_found_handling": "404-page",
+ "run_worker_first": true
+ },
+ "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" }
+}
diff --git a/workers/branch/wrangler.rolling.jsonc b/workers/branch/wrangler.rolling.jsonc
new file mode 100644
index 00000000..7a6f646c
--- /dev/null
+++ b/workers/branch/wrangler.rolling.jsonc
@@ -0,0 +1,16 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-rolling-en",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ "assets": {
+ "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3
+ "binding": "ASSETS",
+ "html_handling": "none", // "none" + worker index-mapping: RTD parity β€” explicit .html URLs must serve 200, never 307 (Β§3.2.3 amended 2026-07-22)
+ "not_found_handling": "404-page",
+ "run_worker_first": true
+ },
+ "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" }
+}
diff --git a/workers/branch/wrangler.v14.jsonc b/workers/branch/wrangler.v14.jsonc
new file mode 100644
index 00000000..fe758dd9
--- /dev/null
+++ b/workers/branch/wrangler.v14.jsonc
@@ -0,0 +1,16 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-v14-en",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ "assets": {
+ "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3
+ "binding": "ASSETS",
+ "html_handling": "none", // "none" + worker index-mapping: RTD parity β€” explicit .html URLs must serve 200, never 307 (Β§3.2.3 amended 2026-07-22)
+ "not_found_handling": "404-page",
+ "run_worker_first": true
+ },
+ "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" }
+}
diff --git a/workers/branch/wrangler.v15.jsonc b/workers/branch/wrangler.v15.jsonc
new file mode 100644
index 00000000..f2a0d7a4
--- /dev/null
+++ b/workers/branch/wrangler.v15.jsonc
@@ -0,0 +1,16 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-v15-en",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ "assets": {
+ "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3
+ "binding": "ASSETS",
+ "html_handling": "none", // "none" + worker index-mapping: RTD parity β€” explicit .html URLs must serve 200, never 307 (Β§3.2.3 amended 2026-07-22)
+ "not_found_handling": "404-page",
+ "run_worker_first": true
+ },
+ "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" }
+}
diff --git a/workers/matrix.json b/workers/matrix.json
new file mode 100644
index 00000000..31ece863
--- /dev/null
+++ b/workers/matrix.json
@@ -0,0 +1,5 @@
+{
+ "rolling": { "worker": "vyos-docs-rolling-en", "slug": "rolling" },
+ "circinus": { "worker": "vyos-docs-v15-en", "slug": "1.5" },
+ "sagitta": { "worker": "vyos-docs-v14-en", "slug": "1.4" }
+}
diff --git a/workers/package-lock.json b/workers/package-lock.json
new file mode 100644
index 00000000..150edc15
--- /dev/null
+++ b/workers/package-lock.json
@@ -0,0 +1,2843 @@
+{
+ "name": "vyos-docs-workers",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "vyos-docs-workers",
+ "devDependencies": {
+ "@cloudflare/vitest-pool-workers": "^0.21.3",
+ "typescript": "^5.5.0",
+ "vitest": "~4.1.0",
+ "wrangler": "^4.123.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@cloudflare/kv-asset-handler": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
+ "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@cloudflare/unenv-preset": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
+ "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "peerDependencies": {
+ "unenv": "2.0.0-rc.24",
+ "workerd": ">1.20260305.0 <2.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "workerd": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers": {
+ "version": "0.21.3",
+ "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.21.3.tgz",
+ "integrity": "sha512-jCoGRQ6FlsP5adp1GJISvITOGdTHTpsWOq3KkSGIfeDY/5RKjTUagDwaXjpqBXY5gAk6id/QbgnGuCTAg6lSTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cjs-module-lexer": "1.2.3",
+ "esbuild": "0.28.1",
+ "miniflare": "5.20260811.1-alpha",
+ "wrangler": "4.123.0",
+ "zod": "4.4.3"
+ },
+ "peerDependencies": {
+ "@vitest/runner": "^4.1.0",
+ "@vitest/snapshot": "^4.1.0",
+ "vitest": "^4.1.0"
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260811.1.tgz",
+ "integrity": "sha512-i5jqz+ywtOefr0AJbiAc8qxBLfSim/B0WJG7aW3B+pWnoVfMJdUQvi+BWcFKZJ0MoCci3KadTx6g31VfuEEqpQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260811.1.tgz",
+ "integrity": "sha512-NoOUM/nvaDdm2Onlnz33FikWjtatzulNtvwvy4xs0IrHaTCHwC0c8NwIt6s+AI13FkDs02/vm2I3GTPLCT9+hQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260811.1.tgz",
+ "integrity": "sha512-sdYq2jL1AD1supa3fsi5O4zTB28wSjvTHj7Migh6/ts8EROPdvrSwv+rdGHhv8HJNAz/wbIAY3wZsi1Rw4uUIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260811.1.tgz",
+ "integrity": "sha512-RIRv4shbu1kg05sD+DHTpSFCNnb5Dl2SkPDMUykqZa508tkPqe7VVw7gO0Q5msTBGyL0FfFrLuRxwwfA8u5Sow==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260811.1.tgz",
+ "integrity": "sha512-g6VquwjASlYAibcNW/0E6Zszht4qLkmnXOGwIjjRHl2A0Qz48kVeMcGvyH6eA0G9U3OzZojjYFpP+YeyQmmdjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
+ "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
+ "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz",
+ "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.2"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
+ "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
+ "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
+ "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
+ "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
+ "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
+ "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
+ "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
+ "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
+ "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
+ "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
+ "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
+ "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
+ "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
+ "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
+ "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
+ "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
+ "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
+ "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.1"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz",
+ "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==",
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.1"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz",
+ "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.2"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
+ "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
+ "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
+ "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.139.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@poppinss/colors": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^4.1.5"
+ }
+ },
+ "node_modules/@poppinss/dumper": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@sindresorhus/is": "^7.0.2",
+ "supports-color": "^10.0.0"
+ }
+ },
+ "node_modules/@poppinss/exception": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@speed-highlight/core": {
+ "version": "1.2.24",
+ "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz",
+ "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/blake3-wasm": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cjs-module-lexer": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz",
+ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/error-stack-parser-es": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "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"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/miniflare": {
+ "version": "5.20260811.1-alpha",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260811.1-alpha.tgz",
+ "integrity": "sha512-DtOG0BeanIxs2sH0smFvExZD89cBQwGckbHiFkRJrrNAUu3NGClZkUxqu+zy7HYfKBAgq935EMY49vIPm3JVdA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "sharp": "0.35.2",
+ "undici": "7.29.0",
+ "workerd": "1.20260811.1",
+ "ws": "8.21.0",
+ "youch": "4.1.0-beta.10"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
+ "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.23",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.139.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "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"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.35.2",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
+ "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.4"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.2",
+ "@img/sharp-darwin-x64": "0.35.2",
+ "@img/sharp-freebsd-wasm32": "0.35.2",
+ "@img/sharp-libvips-darwin-arm64": "1.3.1",
+ "@img/sharp-libvips-darwin-x64": "1.3.1",
+ "@img/sharp-libvips-linux-arm": "1.3.1",
+ "@img/sharp-libvips-linux-arm64": "1.3.1",
+ "@img/sharp-libvips-linux-ppc64": "1.3.1",
+ "@img/sharp-libvips-linux-riscv64": "1.3.1",
+ "@img/sharp-libvips-linux-s390x": "1.3.1",
+ "@img/sharp-libvips-linux-x64": "1.3.1",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.1",
+ "@img/sharp-linux-arm": "0.35.2",
+ "@img/sharp-linux-arm64": "0.35.2",
+ "@img/sharp-linux-ppc64": "0.35.2",
+ "@img/sharp-linux-riscv64": "0.35.2",
+ "@img/sharp-linux-s390x": "0.35.2",
+ "@img/sharp-linux-x64": "0.35.2",
+ "@img/sharp-linuxmusl-arm64": "0.35.2",
+ "@img/sharp-linuxmusl-x64": "0.35.2",
+ "@img/sharp-webcontainers-wasm32": "0.35.2",
+ "@img/sharp-win32-arm64": "0.35.2",
+ "@img/sharp-win32-ia32": "0.35.2",
+ "@img/sharp-win32-x64": "0.35.2"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/unenv": {
+ "version": "2.0.0-rc.24",
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.1.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
+ "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.16",
+ "rolldown": "~1.1.4",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "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
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@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.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "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
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/workerd": {
+ "version": "1.20260811.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260811.1.tgz",
+ "integrity": "sha512-kh+FFm55JQ4ssxhHZV9VPdMQq3D1nHxNJgwxMtWGD4dGppJvLySdguTRDKgeNTvgq6heSz+6TTXyPSDGj8Yllw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20260811.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260811.1",
+ "@cloudflare/workerd-linux-64": "1.20260811.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260811.1",
+ "@cloudflare/workerd-windows-64": "1.20260811.1"
+ }
+ },
+ "node_modules/wrangler": {
+ "version": "4.123.0",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.123.0.tgz",
+ "integrity": "sha512-VXo2I1oa0x9aGAKIFPRSQPqTh0RBY5Ktl44YOhNmsJQFUdJKDA2vVTU6Xj+FC2koll6orJqWZN8jbXVIk9O67Q==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.5.0",
+ "@cloudflare/unenv-preset": "2.16.1",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.28.1",
+ "miniflare": "5.20260811.1-alpha",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.24",
+ "workerd": "1.20260811.1"
+ },
+ "bin": {
+ "cf-wrangler": "bin/cf-wrangler.js",
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^5.20260811.1"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "license": "MIT",
+ "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
+ }
+ }
+ },
+ "node_modules/youch": {
+ "version": "4.1.0-beta.10",
+ "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
+ "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@poppinss/dumper": "^0.6.4",
+ "@speed-highlight/core": "^1.2.7",
+ "cookie": "^1.0.2",
+ "youch-core": "^0.3.3"
+ }
+ },
+ "node_modules/youch-core": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
+ "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/exception": "^1.2.2",
+ "error-stack-parser-es": "^1.0.5"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/workers/package.json b/workers/package.json
new file mode 100644
index 00000000..ed750083
--- /dev/null
+++ b/workers/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "vyos-docs-workers",
+ "private": true,
+ "type": "module",
+ "engines": { "node": ">=20" },
+ "scripts": {
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "devDependencies": {
+ "@cloudflare/vitest-pool-workers": "^0.21.3",
+ "typescript": "^5.5.0",
+ "vitest": "~4.1.0",
+ "wrangler": "^4.123.0"
+ }
+}
diff --git a/workers/picker-test/pagefind-wrapper.test.ts b/workers/picker-test/pagefind-wrapper.test.ts
new file mode 100644
index 00000000..75e8be50
--- /dev/null
+++ b/workers/picker-test/pagefind-wrapper.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from "vitest";
+// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented
+// stub β€” see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs, and confirmed
+// empirically here: "readFileSync() is not yet implemented in Workers"). Same constraint
+// documented in picker.test.ts and apex/test/manifest.test.ts; import as Vite `?raw` asset
+// instead so content is inlined at bundle time β€” no runtime filesystem access needed.
+// eslint-disable-next-line import/no-unresolved
+import src from "../../docs/_static/js/pagefind-wrapper.js?raw";
+
+const ns: Record<string, unknown> = {};
+new Function("window", src)(ns as never);
+const W = (ns as never as { VyOSSearch: Record<string, CallableFunction> }).VyOSSearch;
+
+describe("basePathFor (Β§9)", () => {
+ it("production/canary version path", () => {
+ expect(W.basePathFor("/en/rolling/search.html"))
+ .toEqual({ base: "/en/rolling/", prefix: "" });
+ });
+ it("PR preview path keeps /pr-<n>/ prefix and reports it", () => {
+ expect(W.basePathFor("/pr-42/en/1.5/search.html"))
+ .toEqual({ base: "/pr-42/en/1.5/", prefix: "/pr-42" });
+ });
+ it("prefixes result URLs in previews", () => {
+ expect(W.prefixResultUrl("/en/1.5/cli/index.html", "/pr-42")).toBe("/pr-42/en/1.5/cli/index.html");
+ expect(W.prefixResultUrl("/en/1.5/cli/index.html", "")).toBe("/en/1.5/cli/index.html");
+ });
+});
+
+describe("assetUrlsFor (css + js pair under the version base)", () => {
+ it("production/canary base", () => {
+ expect(W.assetUrlsFor("/en/rolling/")).toEqual({
+ css: "/en/rolling/pagefind/pagefind-ui.css",
+ js: "/en/rolling/pagefind/pagefind-ui.js",
+ });
+ });
+ it("PR preview base keeps the /pr-<n>/ prefix", () => {
+ expect(W.assetUrlsFor("/pr-42/en/1.5/")).toEqual({
+ css: "/pr-42/en/1.5/pagefind/pagefind-ui.css",
+ js: "/pr-42/en/1.5/pagefind/pagefind-ui.js",
+ });
+ });
+});
diff --git a/workers/picker-test/picker.test.ts b/workers/picker-test/picker.test.ts
new file mode 100644
index 00000000..560fd0d2
--- /dev/null
+++ b/workers/picker-test/picker.test.ts
@@ -0,0 +1,156 @@
+import { describe, it, expect } from "vitest";
+// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented
+// stub β€” see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs, and confirmed
+// empirically here: "readFileSync() is not yet implemented in Workers"). Same constraint
+// documented in apex/test/manifest.test.ts; import as Vite `?raw` / native JSON assets
+// instead so content is inlined at bundle time β€” no runtime filesystem access needed.
+// eslint-disable-next-line import/no-unresolved
+import src from "../../docs/_static/js/version-picker.js?raw";
+// eslint-disable-next-line import/no-unresolved
+import manifest from "../versions.json";
+
+// Evaluate the plain script and grab its namespace (no DOM access at module scope allowed).
+const ns: Record<string, CallableFunction> = {};
+new Function("window", src)(ns as never);
+const P = (ns as never as { VyOSVersionPicker: Record<string, CallableFunction> }).VyOSVersionPicker;
+
+describe("parseLocation", () => {
+ it("extracts lang/slug/rest from a docs path", () => {
+ expect(P.parseLocation("/en/1.5/cli/index.html"))
+ .toEqual({ lang: "en", slug: "1.5", rest: "cli/index.html" });
+ });
+ it("returns null off the version tree (e.g. previews without prefix knowledge)", () => {
+ expect(P.parseLocation("/kb/x")).toBeNull();
+ });
+
+ // The slug segment is restricted to the sphinx slug charset so hostile text can never
+ // reach URL construction as a version identifier (CodeQL js/xss-through-dom).
+ it("accepts real version slugs", () => {
+ expect(P.parseLocation("/en/rolling/index.html")).toMatchObject({ slug: "rolling" });
+ expect(P.parseLocation("/en/1.5/cli/index.html")).toMatchObject({ slug: "1.5" });
+ });
+ it("rejects a slug carrying markup metacharacters", () => {
+ expect(P.parseLocation("/en/foo<img>/page.html")).toBeNull();
+ });
+ it("rejects a slug carrying a quote or a space", () => {
+ expect(P.parseLocation('/en/foo"bar/page.html')).toBeNull();
+ expect(P.parseLocation("/en/foo bar/page.html")).toBeNull();
+ });
+ // "." and ".." are the only normalizing dot-segments: as a slug they would walk out of
+ // the /<lang>/<slug>/ tree once the browser resolves the URL. Interior dots are fine.
+ it("rejects the dot-segments '.' and '..' as slugs, keeping dotted version slugs", () => {
+ expect(P.parseLocation("/en/../index.html")).toBeNull();
+ expect(P.parseLocation("/en/./index.html")).toBeNull();
+ expect(P.parseLocation("/en/1.5/index.html")).toMatchObject({ slug: "1.5" });
+ });
+});
+
+describe("bannerFor (Β§4)", () => {
+ it("dev β†’ info banner", () => {
+ expect(P.bannerFor("rolling", manifest)).toMatchObject({ kind: "dev" });
+ });
+ it("newest lts β†’ no banner; older lts β†’ newer-lts notice naming 1.5", () => {
+ expect(P.bannerFor("1.5", manifest)).toBeNull();
+ expect(P.bannerFor("1.4", manifest)).toMatchObject({ kind: "newer-lts", newest: "1.5" });
+ });
+ it("eol β†’ warning linking newest LTS", () => {
+ expect(P.bannerFor("1.3", manifest)).toMatchObject({ kind: "eol", newest: "1.5" });
+ });
+});
+
+describe("targetUrlFor", () => {
+ it("same path on target version", () => {
+ expect(P.targetUrlFor({ lang: "en", slug: "1.5", rest: "cli/index.html" }, "1.4"))
+ .toBe("/en/1.4/cli/index.html");
+ });
+});
+
+describe("navUrlFor (query + fragment preserved across version switch)", () => {
+ const loc = { lang: "en", slug: "1.4", rest: "quick-start.html" };
+ it("neither β†’ bare target path", () => {
+ expect(P.navUrlFor(loc, "1.5", "", "")).toBe("/en/1.5/quick-start.html");
+ });
+ it("query-only", () => {
+ expect(P.navUrlFor(loc, "1.5", "?ref=x", ""))
+ .toBe("/en/1.5/quick-start.html?ref=x");
+ });
+ it("hash-only", () => {
+ expect(P.navUrlFor(loc, "1.5", "", "#section-3"))
+ .toBe("/en/1.5/quick-start.html#section-3");
+ });
+ it("both, in query-then-hash order", () => {
+ expect(P.navUrlFor(loc, "1.5", "?ref=x", "#section-3"))
+ .toBe("/en/1.5/quick-start.html?ref=x#section-3");
+ });
+});
+
+/* DOM-text sources (select.value, location.pathname) reach a location.href sink, so every
+ * path component is percent-encoded at construction time (CodeQL js/xss-through-dom). */
+describe("URL construction percent-encodes hostile path components", () => {
+ const loc = { lang: "en", slug: "1.5", rest: "cli/index.html" };
+ // Characters that could break out of a path segment or introduce a URL scheme.
+ const HOSTILE = ['"', "'", "<", ">", " ", ":"];
+
+ it("is a no-op on legitimate sphinx slugs β€” URLs byte-identical to pre-hardening", () => {
+ expect(P.targetUrlFor(loc, "1.4")).toBe("/en/1.4/cli/index.html");
+ expect(P.targetUrlFor(loc, "rolling")).toBe("/en/rolling/cli/index.html");
+ expect(P.targetUrlFor({ lang: "en", slug: "1.4", rest: "" }, "1.5")).toBe("/en/1.5/");
+ expect(P.langUrlFor(loc, "de")).toBe("/de/1.5/cli/index.html");
+ });
+
+ it("encodePath keeps '/' separators while encoding each segment", () => {
+ expect(P.encodePath("cli/index.html")).toBe("cli/index.html");
+ expect(P.encodePath('a b/c"d/e.html')).toBe("a%20b/c%22d/e.html");
+ });
+
+ // location.pathname returns well-formed escapes verbatim, so encoding blindly would
+ // double-encode them (%2E -> %252E) and break the deep link on the HEAD probe.
+ it("encodePath normalizes pre-existing escapes instead of double-encoding", () => {
+ expect(P.encodePath("index%2Ehtml")).toBe("index.html");
+ expect(P.encodePath("a%20b/c.html")).toBe("a%20b/c.html");
+ expect(P.encodePath("a%2Fb/c")).toBe("a%2Fb/c"); // encoded slash stays in its segment
+ });
+
+ it("encodePath degrades safely on a malformed escape (no throw)", () => {
+ const url = P.encodePath("100%zz/x");
+ expect(url).toBe("100%25zz/x");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
+ // Normalization runs per %HH run, not per segment: a whole-segment decode throws on the
+ // malformed escape and then double-encodes the valid one beside it (a%2520b%25zz).
+ it("encodePath normalizes each escape run independently in a mixed-validity segment", () => {
+ const url = P.encodePath("a%20b%zz/x");
+ expect(url).toBe("a%20b%25zz/x");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
+ it("encodePath keeps an invalid-UTF-8 escape run verbatim (already pure %HH text)", () => {
+ const url = P.encodePath("x%E0%A4y.html");
+ expect(url).toBe("x%E0%A4y.html");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
+ it("targetUrlFor encodes a markup-injecting target slug", () => {
+ const url = P.targetUrlFor(loc, '"><img src=x>');
+ expect(url).toBe("/en/%22%3E%3Cimg%20src%3Dx%3E/cli/index.html");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
+ it("targetUrlFor kills the colon in a javascript:-shaped slug", () => {
+ expect(P.targetUrlFor(loc, "javascript:alert(1)"))
+ .toBe("/en/javascript%3Aalert(1)/cli/index.html");
+ });
+
+ it("targetUrlFor encodes a hostile lang segment", () => {
+ const url = P.targetUrlFor({ lang: 'en"><script>', slug: "1.5", rest: "a.html" }, "1.4");
+ expect(url).toBe("/en%22%3E%3Cscript%3E/1.4/a.html");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+
+ it("langUrlFor encodes the new lang plus the retained slug and rest", () => {
+ const url = P.langUrlFor({ lang: "en", slug: 'x"y', rest: 'a b/c.html' }, "de<i>");
+ expect(url).toBe("/de%3Ci%3E/x%22y/a%20b/c.html");
+ for (const c of HOSTILE) expect(url).not.toContain(c);
+ });
+});
diff --git a/workers/preview/src/index.ts b/workers/preview/src/index.ts
new file mode 100644
index 00000000..5c945175
--- /dev/null
+++ b/workers/preview/src/index.ts
@@ -0,0 +1,61 @@
+export interface Env { PREVIEWS: R2Bucket }
+
+const MIME: Record<string, string> = {
+ html: "text/html; charset=utf-8", css: "text/css", js: "text/javascript",
+ json: "application/json", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg",
+ gif: "image/gif", ico: "image/x-icon", txt: "text/plain; charset=utf-8",
+ xml: "application/xml", pdf: "application/pdf", woff2: "font/woff2", woff: "font/woff",
+};
+
+export function mimeFor(key: string): string {
+ const ext = key.split(".").pop() ?? "";
+ return MIME[ext] ?? "application/octet-stream";
+}
+
+export function keyFor(pathname: string): string {
+ let key = pathname.replace(/^\//, "");
+ if (key.endsWith("/") || key === "" ) key += "index.html";
+ return key;
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise<Response> {
+ const key = keyFor(new URL(request.url).pathname);
+ let obj: R2ObjectBody | null = null;
+ let resolvedKey = key;
+ try {
+ obj = await env.PREVIEWS.get(key);
+ if (!obj && !key.split("/").pop()?.includes(".")) {
+ // Extensionless directory URL with no trailing slash (e.g. /en/rolling/cli) β€”
+ // keyFor() only appends index.html for trailing-slash/empty paths, so probe the
+ // directory's index.html before 404ing. Check the LAST path segment only β€” a dot
+ // anywhere earlier (e.g. version segment "1.4" in /pr-42/en/1.4/cli) must not skip
+ // the probe for an otherwise-extensionless final segment.
+ resolvedKey = `${key}/index.html`;
+ obj = await env.PREVIEWS.get(resolvedKey);
+ }
+ } catch {
+ // Transient R2/binding error on either probe β€” fail closed with a controlled 503
+ // instead of letting an unhandled exception surface as a raw worker error.
+ return new Response("preview temporarily unavailable", {
+ status: 503,
+ headers: { "X-Robots-Tag": "noindex", "Cache-Control": "no-store" },
+ });
+ }
+ if (!obj) {
+ return new Response("preview not found", {
+ status: 404,
+ // no-store on the 404 too β€” a cached 404 would persist past the preview upload
+ headers: { "X-Robots-Tag": "noindex", "Cache-Control": "no-store" },
+ });
+ }
+ return new Response(obj.body, {
+ headers: {
+ "content-type": obj.httpMetadata?.contentType ?? mimeFor(resolvedKey),
+ "X-Robots-Tag": "noindex",
+ "Cache-Control": "no-store",
+ "X-Content-Type-Options": "nosniff",
+ },
+ });
+ },
+} satisfies ExportedHandler<Env>;
diff --git a/workers/preview/test/preview.test.ts b/workers/preview/test/preview.test.ts
new file mode 100644
index 00000000..90530690
--- /dev/null
+++ b/workers/preview/test/preview.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from "vitest";
+import worker, { mimeFor, keyFor } from "../src/index";
+import type { Env } from "../src/index";
+
+describe("preview worker helpers (Β§10)", () => {
+ it("derives R2 key from path, defaulting directory to index.html", () => {
+ expect(keyFor("/pr-42/en/rolling/")).toBe("pr-42/en/rolling/index.html");
+ expect(keyFor("/pr-42/en/rolling/cli/index.html")).toBe("pr-42/en/rolling/cli/index.html");
+ });
+ it("extension→MIME fallback map (nosniff-safe)", () => {
+ expect(mimeFor("a.html")).toBe("text/html; charset=utf-8");
+ expect(mimeFor("a.css")).toBe("text/css");
+ expect(mimeFor("a.js")).toBe("text/javascript");
+ expect(mimeFor("a.json")).toBe("application/json");
+ expect(mimeFor("a.svg")).toBe("image/svg+xml");
+ expect(mimeFor("a.woff2")).toBe("font/woff2");
+ expect(mimeFor("a.unknown")).toBe("application/octet-stream");
+ });
+});
+
+// Fake R2Bucket mock following the makeEnv() precedent in apex/test/router.test.ts β€”
+// only the `get` surface the handler consumes; miniflare's real R2 not needed here.
+function makeEnv(objects: Record<string, { body: string; contentType?: string }>): Env {
+ return {
+ PREVIEWS: {
+ get: async (key: string) => {
+ const hit = objects[key];
+ if (!hit) return null;
+ return {
+ body: hit.body,
+ httpMetadata: hit.contentType ? { contentType: hit.contentType } : undefined,
+ };
+ },
+ } as unknown as R2Bucket,
+ };
+}
+
+const get = (path: string, env: Env) =>
+ worker.fetch(new Request(`https://docs-preview.vyos.io${path}`), env);
+
+describe("preview worker fetch entrypoint (Β§10)", () => {
+ it("serves a found object with uploader contentType + noindex/no-store/nosniff headers", async () => {
+ const env = makeEnv({
+ "pr-42/en/rolling/index.html": { body: "<h1>preview</h1>", contentType: "text/html; charset=utf-8" },
+ });
+ const r = await get("/pr-42/en/rolling/", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("<h1>preview</h1>");
+ expect(r.headers.get("content-type")).toBe("text/html; charset=utf-8");
+ expect(r.headers.get("X-Robots-Tag")).toBe("noindex");
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ expect(r.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ });
+ it("missing object β†’ 404 with noindex AND no-store (404s must never be cached)", async () => {
+ const r = await get("/pr-42/en/rolling/missing.html", makeEnv({}));
+ expect(r.status).toBe(404);
+ expect(r.headers.get("X-Robots-Tag")).toBe("noindex");
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ });
+ it("object without httpMetadata.contentType falls back to mimeFor(key)", async () => {
+ const env = makeEnv({ "pr-42/en/rolling/style.css": { body: "body{}" } });
+ const r = await get("/pr-42/en/rolling/style.css", env);
+ expect(r.status).toBe(200);
+ expect(r.headers.get("content-type")).toBe("text/css");
+ });
+ it("extensionless directory URL with no trailing slash serves the directory's index.html", async () => {
+ const env = makeEnv({
+ "pr-42/en/rolling/cli/index.html": { body: "<h1>cli</h1>", contentType: "text/html; charset=utf-8" },
+ });
+ const r = await get("/pr-42/en/rolling/cli", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("<h1>cli</h1>");
+ expect(r.headers.get("content-type")).toBe("text/html; charset=utf-8");
+ });
+ it("extensionless directory URL with a dotted version segment earlier in the path still probes index.html (dot-check is last-segment-only)", async () => {
+ const env = makeEnv({
+ "pr-42/en/1.4/cli/index.html": { body: "<h1>cli 1.4</h1>", contentType: "text/html; charset=utf-8" },
+ });
+ const r = await get("/pr-42/en/1.4/cli", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("<h1>cli 1.4</h1>");
+ expect(r.headers.get("content-type")).toBe("text/html; charset=utf-8");
+ });
+ it("a transient R2/binding error on either probe degrades to a controlled 503 (never an unhandled exception)", async () => {
+ const throwingEnv: Env = {
+ PREVIEWS: { get: async () => { throw new Error("R2 unavailable"); } } as unknown as R2Bucket,
+ };
+ const r = await get("/pr-42/en/rolling/cli", throwingEnv);
+ expect(r.status).toBe(503);
+ expect(r.headers.get("X-Robots-Tag")).toBe("noindex");
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ });
+});
diff --git a/workers/preview/wrangler.jsonc b/workers/preview/wrangler.jsonc
new file mode 100644
index 00000000..fbdb4aba
--- /dev/null
+++ b/workers/preview/wrangler.jsonc
@@ -0,0 +1,11 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-preview",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ // Route managed manually (v4.2 amendment β€” CI token has no Workers Routes group):
+ // operator creates docs-preview.vyos.io/* β†’ vyos-docs-preview once, alongside the canary route.
+ "r2_buckets": [{ "binding": "PREVIEWS", "bucket_name": "vyos-docs-previews" }]
+}
diff --git a/workers/versions.json b/workers/versions.json
new file mode 100644
index 00000000..3e349bca
--- /dev/null
+++ b/workers/versions.json
@@ -0,0 +1,19 @@
+{
+ "schema_version": 2,
+ "default_lang": "en",
+ "default_version": "rolling",
+ "languages": [{ "code": "en", "label": "English" }],
+ "versions": [
+ { "slug": "rolling", "label": "Rolling (development)", "status": "dev", "binding": "DOCS_ROLLING",
+ "aliases": ["latest"], "pdf": "/en/rolling/vyos-documentation.pdf" },
+ { "slug": "1.5", "label": "1.5 (Circinus, LTS)", "status": "lts", "binding": "DOCS_V15",
+ "aliases": ["stable", "lts", "circinus"], "pdf": "/en/1.5/vyos-documentation.pdf" },
+ { "slug": "1.4", "label": "1.4 (Sagitta, LTS)", "status": "lts", "binding": "DOCS_V14",
+ "aliases": ["sagitta"], "pdf": "/en/1.4/vyos-documentation.pdf" },
+ { "slug": "1.3", "label": "1.3 (Equuleus, EOL)", "status": "eol", "binding": "DOCS_LEGACY",
+ "aliases": ["equuleus"], "pdf": "/en/1.3/vyos-documentation.pdf",
+ "pdf_r2_key": "legacy/1.3/vyos-documentation.pdf" },
+ { "slug": "1.2", "label": "1.2 (Crux, EOL)", "status": "eol", "binding": "DOCS_LEGACY",
+ "aliases": ["crux"], "pdf": null }
+ ]
+}
diff --git a/workers/vitest.config.ts b/workers/vitest.config.ts
new file mode 100644
index 00000000..340c5681
--- /dev/null
+++ b/workers/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vitest/config";
+import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
+
+export default defineConfig({
+ plugins: [
+ // Each suite provides its own wrangler config via miniflare option overrides;
+ // apex tests read workers/apex/wrangler.jsonc to assert config congruence.
+ cloudflareTest({
+ miniflare: {
+ compatibilityDate: "2026-07-01",
+ },
+ }),
+ ],
+});