diff options
| author | Yuriy Andamasov <yuriy@vyos.io> | 2026-07-10 17:14:13 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-10 16:14:13 +0200 |
| commit | 21689ef59b0eb34b1a29eda739dc10d33d25b44a (patch) | |
| tree | 95883c2ee70d4c09562ae3e0289e18f34999240c /workers/preview | |
| parent | 6d2d28d4411ff52ad0f9314cf3696336748ab89d (diff) | |
| download | vyos-documentation-21689ef59b0eb34b1a29eda739dc10d33d25b44a.tar.gz vyos-documentation-21689ef59b0eb34b1a29eda739dc10d33d25b44a.zip | |
docs: Cloudflare Workers hosting pipeline (apex, content workers, CI, previews) (#2140)
* docs-infra: scaffold Cloudflare workers workspace (versions.json v2, matrix, toolchain)
π€ Generated by [robots](https://vyos.io)
* docs-infra: record full Phase-0 plan decision in workers/PLAN.md
π€ Generated by [robots](https://vyos.io)
* docs-infra: shared content worker β asset serving, cache classes, X-Docs-Build, canary no-store
π€ Generated by [robots](https://vyos.io)
* docs-infra: run worker script before assets; test fetch entrypoint
π€ Generated by [robots](https://vyos.io)
* docs-infra: apex manifest loader + dispatch map + runtime binding guard (TDD)
π€ Generated by [robots](https://vyos.io)
* docs-infra: apex redirects (aliases, PDF, trailing-slash) + special paths (TDD)
π€ Generated by [robots](https://vyos.io)
* docs-infra: PDF redirect honors pdf:null and preserves query
π€ Generated by [robots](https://vyos.io)
* docs-infra: apex UA gate β allowlist-wins, log-only AI crawlers, empty block list at launch
π€ Generated by [robots](https://vyos.io)
* docs-infra: apex router (pipeline Β§3.2), themed 404/503, /kb seam, env configs + congruence test
π€ Generated by [robots](https://vyos.io)
* docs-infra: add missing-User-Agent regression test for apex UA gate
π€ Generated by [robots](https://vyos.io)
* docs-infra: R2-streaming preview worker β MIME map, noindex, no-store (TDD)
π€ Generated by [robots](https://vyos.io)
* docs-infra: preview 404 no-store + fetch handler tests
π€ Generated by [robots](https://vyos.io)
* docs-infra: bootstrap script β binding-target workers must exist before apex deploys
π€ Generated by [robots](https://vyos.io)
* docs-infra: apex run_worker_first, lockfile for npm ci, PDF Location from manifest
π€ Generated by [robots](https://vyos.io)
* docs-infra: derive html_baseurl from DOCS_VERSION_SLUG with RTD fallback (canonical gate prereq)
π€ Generated by [robots](https://vyos.io)
* docs-infra: version picker + status banner + language scaffold (vanilla JS, TDD pure core)
π€ Generated by [robots](https://vyos.io)
* docs-infra: picker preserves query+hash across switch; valid breadcrumb markup
π€ Generated by [robots](https://vyos.io)
* docs-infra: Pagefind search wrapper with runtime base-path + preview prefix handling (TDD)
π€ Generated by [robots](https://vyos.io)
* docs-infra: pagefind wrapper β asset-failure notice + UI stylesheet load
π€ Generated by [robots](https://vyos.io)
* docs-infra: gate Pagefind searchbox to CF builds (RTD keeps stock search until cutover)
π€ Generated by [robots](https://vyos.io)
* docs-infra: deploy sanity gates β limits, critical pages, count-delta, canonical (TDD)
π€ Generated by [robots](https://vyos.io)
* docs-infra: hermetic gate tests via fixture versions.json
π€ Generated by [robots](https://vyos.io)
* docs-infra: docs-build workflow β candidate/smoke/promote two-stage deploy + registry + rollback
Two-stage CF Workers pipeline: build in pinned container, assemble artifact,
sanity gates, deploy candidate, scoped pre-traffic smoke via canary apex,
promote (rollback-id capture, hostname purge, registry upload), post-promote
probe + auto-rollback. DOCS_CF_LIVE repo variable gates every docs.vyos.io
production interaction pre-cutover.
scripts/docs_gates/smoke.py adds one authorized check beyond the spec: the
version's index.html probe asserts the #vyos-search mount div is present in
the response body, guarding CI silently forgetting DOCS_VERSION_SLUG (which
would otherwise ship stock RTD search without the Pagefind gate noticing).
π€ Generated by [robots](https://vyos.io)
* docs-infra: build docs image in-workflow with buildx cache (v4.1 β digest pin dropped)
Plan v4.1 amendment: the ghcr.io digest-pinned image does not exist (workflow
would hard-fail at the first docker step on every push). Replace the BUILD_IMAGE
env placeholder with an in-workflow docker build from docker/Dockerfile via
docker/setup-buildx-action@v3 + docker/build-push-action@v6 (context: docker/,
load: true, tags: docs-build:local, GHA cache from/to). The checked-out commit
is the pin; buildx GHA cache keeps repeat builds cheap. Sphinx-build step swaps
to docs-build:local; inner script unchanged.
π€ Generated by [robots](https://vyos.io)
* docs-infra: apex/preview deploy workflow β canary auto, production behind environment approval
* docs-infra: apex-deploy concurrency guard (per-ref, cancel-in-progress)
π€ Generated by [robots](https://vyos.io)
* docs-infra: fork-safe PR preview pipeline β approval record, R2 prefixes, label consumption, cleanup
* docs-infra: nightly preview sweep β pipefail + per-prefix failure isolation
π€ Generated by [robots](https://vyos.io)
* docs-infra: nightly canary QA β per-entry sweep + URL-parity corpus vs RTD
π€ Generated by [robots](https://vyos.io)
* docs-infra: parity sweep scoped to CF-built versions; transport-error resilience
π€ Generated by [robots](https://vyos.io)
* docs-infra: one-off bootstrap workflow (binding targets β runs once on this push)
π€ Generated by [robots](https://vyos.io)
* docs-infra: remove one-off bootstrap workflow (bootstrap complete)
π€ Generated by [robots](https://vyos.io)
* docs-infra: one-off canary apex + preview deploy (route targets for Task 3.6 step 2c)
π€ Generated by [robots](https://vyos.io)
* docs-infra: remove one-off canary deploy workflow (targets live)
π€ Generated by [robots](https://vyos.io)
* docs-infra: address Phase-0 CodeRabbit findings (canonical gate, error caching, registry pointer, validation)
π€ Generated by [robots](https://vyos.io)
* docs-infra: strengthen manifest tests (full dispatch iteration, mutation-free validate)
π€ Generated by [robots](https://vyos.io)
* docs-infra: address GitHub CodeRabbit review (pointer-after-probe, fail-closed sweeps, block-precedence UA gate, preview hardening)
π€ Generated by [robots](https://vyos.io)
* docs-infra: adversarial review fixes β error no-store, probe retry, PR-list membership, preview dotted-segment
π€ Generated by [robots](https://vyos.io)
* docs-infra: serve oversized legacy PDF from R2 via apex (spec Β§5 fallback)
The 1.3 PDF (29.2 MiB) exceeds the 25 MiB static-asset cap and is absent
from the legacy content Worker's build, so /_/downloads/en/1.3/pdf/ (and
the picker's PDF link) 301'd into a dead-end 404 post-cutover. Add the R2
object fallback spec Β§5 already documented but never implemented: a
DOCS_PDFS R2 bucket binding on the apex Worker, a manifest pdf_r2_key
field (1.3 only), and a router step ahead of version dispatch that streams
the object with its own cache class (canary/error still force no-store).
π€ Generated by [robots](https://vyos.io)
* docs-infra: PDF R2 fallback honors Range + If-None-Match, preserves ETag
π€ Generated by [robots](https://vyos.io)
Diffstat (limited to 'workers/preview')
| -rw-r--r-- | workers/preview/src/index.ts | 61 | ||||
| -rw-r--r-- | workers/preview/test/preview.test.ts | 93 | ||||
| -rw-r--r-- | workers/preview/wrangler.jsonc | 11 |
3 files changed, 165 insertions, 0 deletions
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" }] +} |
