19. Both public sites on Cloudflare Workers static assets, deployed from CI
- Status: Accepted
- Date: 2026-09
- Scope: site hosting only. No core, language, or public-API change; no public URL change.
Context
From 2026-07-15 the two public sites were Vercel deployments reached through the archlang.uk zone on Cloudflare DNS — three grey-cloud ("DNS only") CNAMEs to *.vercel-dns-016.com, with Vercel owning TLS and the www → apex redirect. That split the operational surface across two vendors for no benefit: the zone, the second domain (archcanvas.uk), and the account that manages both were already Cloudflare's.
Nothing about the Vercel deployment was broken. The move is a consolidation, and the constraint that shaped every decision below is that the public URLs must not change — https://archlang.uk and https://playground.archlang.uk are written into JSON Schema $ids, the VitePress sitemap.hostname, README permalinks, SKILL.md, and the MCP shim's baked context. A host move is cheap; a URL move is a repo-wide regeneration with its own documented traps (docs/hosting-and-domains.md).
Both sites are wholly static: VitePress emits HTML, the playground is a Vite bundle whose entire state lives in a #z= hash that never reaches the server. Neither has ever needed a server-side request handler, and neither should acquire one by accident.
Decision
Serve both sites as Cloudflare Workers with static assets, with no main script, and deploy them from GitHub Actions.
docs-site/wrangler.jsonc→ Workerarchlang-docs, assets from.vitepress/dist, custom domainarchlang.uk.playground/wrangler.jsonc→ Workerarchlang-playground, assets fromdist, custom domainplayground.archlang.uk.workers_dev: falseon both..github/workflows/deploy.yml("Deploy (Cloudflare)") keeps its push-to-maintrigger and its two-site matrix: build the core, build the site,wrangler deploy, then the unchangedscripts/smoke.mjscheck against the live origin.
Why Workers static assets, and not Cloudflare Pages
Pages is the product whose name suggests it. It was rejected on three counts.
- Workers is where Cloudflare is investing; Pages is in maintenance for new projects, and its Functions model is a second, divergent way to write the same code we do not write anyway.
wrangler.jsoncis repository state. A Pages project's build command, output directory, and redirect behaviour live in the dashboard. This repository's standing preference is that configuration which affects a deploy is committed and reviewable — the same reasondeploy.ymlpins itswranglerversion.- The static-asset path is identical either way, so Pages offered nothing to weigh against those two.
Why there is no main script
With no script to invoke, every request is a static-asset request: free, unmetered, and exempt from the Workers free plan's 100,000-requests/day cap. Adding a main — even a trivial pass-through — opts both public sites into that cap in exchange for nothing. This is a standing constraint, not an implementation detail: a future feature that wants a request handler must justify the cap it buys.
Why deploys stay in GitHub Actions, not Cloudflare's Git integration
Two reasons, both about what the deploy is actually made of.
- The build is a monorepo build.
npm run docs:buildandnpm run playground:buildbuild the core first and then the site —docs-site/sync-docs.mjshard-exits whendist/is missing, deliberately, so a site can never deploy with a broken example gallery. Cloudflare's Git integration would need that expressed as a dashboard build command, moving a load-bearing ordering constraint out of the repository. - The smoke check belongs welded to the deploy that produced it.
scripts/smoke.mjsruns as the step immediately afterwrangler deploy, against the live origin, in the same job. A dashboard-driven deploy has no step to weld it to.
A third, found rather than predicted: docs-site/.vitepress/config.ts sets lastUpdated: true, which reads each page's git commit time at build time. The workflow's actions/checkout therefore needs fetch-depth: 0. A depth-1 clone produces no error — every "Last updated" stamp silently collapses to the deploy commit. That is a CI-visible knob we control; on a hosted build integration it would be one more dashboard setting able to corrupt content quietly.
Why the www redirect is zone state, not an in-repo _redirects
www.archlang.uk → 301 → archlang.uk is a Cloudflare Single Redirect Rule in the http_request_dynamic_redirect phase, configured on the zone.
It is not in a _redirects file because Cloudflare's _redirects matches on path only and cannot express a scheme+host source. There is no way to say "requests whose Host is www.archlang.uk" in that format; a path-only rule attached to the docs Worker would fire for apex requests too and redirect the site to itself.
The rule's target is an expression, concat("https://archlang.uk", http.request.uri.path), with preserve_query_string: true — not a static value, which would flatten every www deep link onto the home page. The exact API shape is recorded in docs/hosting-and-domains.md so it can be recreated without guessing, since this is the one piece of the setup that lives outside the repository.
Why the two sites' asset routing differs
They differ on both knobs, and neither difference is an inconsistency to tidy up. One site has a page per route; the other has two pages and a fragment.
Docs:
html_handling: "drop-trailing-slash",not_found_handling: "404-page". The first is the equivalent of Vercel'scleanUrls: true+trailingSlash: false— VitePress emitsguide.htmland links to/guide, so/guidemust serve that file and/guide/must redirect to the extensionless form. The second is required by this repository's own checks: VitePress emits its own404.html, and bothdocs-site/e2e/routes.spec.tsandscripts/smoke.mjstreat atext/html404 as a negative signal — an unmatched path must 404, not answer 200 with the shell.Playground:
html_handling: "none",not_found_handling: "none", plus one_redirectsline."none"is an exact-path lookup, chosen for one measured reason: it keeps/embed.htmla literal 200. Every other mode drops the.htmland 307s to/embed, and/embed.htmlis a published URL pasted into third-party<iframe>snippets — a redirect would cost every embed on the internet a round trip and gain nothing. Vercel served it literally; this keeps that.The cost of
"none"is that/is not a file and would fall through tonot_found_handling.playground/public/_redirectsanswers it with/ /index.html 200— a status-200 rule is a rewrite, not a redirect, and the redirect layer runs before the asset lookup, so the root servesindex.htmlwith no redirect and no 404. (This is the one thing Cloudflare's_redirectscan express that we need: a path-only rewrite. It still cannot express the host-matchedwwwredirect above, which is why that one is zone state.)not_found_handling: "none"is explicit because the absence is the decision. There is no404.htmlhere, and this is not a single-page application —index.htmlandembed.htmlare two real entries and all state is in the#z=hash, which never reaches the server."single-page-application"would answer every mistyped path with a 200 and the editor shell, turning a 404 into a false success.
Consequences
- The proxy iron law inverts. The records are now proxied (orange cloud), because a Workers custom domain necessarily is one. The old rule — "never proxy, it breaks Vercel's TLS" — was correct for its origin and is now superseded. It is recorded as an inversion in
docs/hosting-and-domains.mdrather than deleted, because a reader who meets the old advice in an archived doc needs to know why it existed. - A proxied zone can silently change what a machine reads, where the grey cloud never touched a byte. Four zone features must therefore stay off: Bot Fight Mode and Browser Integrity Check (which would challenge
scripts/smoke.mjs, a bare zero-depfetch), Email Obfuscation (which rewrites HTML and would break the nightly raw-.mdbyte-equality staleness probe forever), and Rocket Loader (which defers and rewrites scripts, under which the client-compiling@prodPlaywright cases would flake). This is a new standing hazard the Vercel arrangement did not have. archlang-docs.vercel.appandarchlang-playground.vercel.appare gone, permanently. They used to301to the custom domains, and this repository's docs promised they were kept "so no external link ever breaks". That promise is withdrawn: those hostnames belong to Vercel and die with the projects. One known casualty is immutable — the MCP registry's first-published entry forio.github.ChanMeng666/archlang-mcp(0.1.1, 2026-07-10) carriesarchlang-docs.vercel.appas its website URL. It was already stale before this move (every later version points atarchlang.uk), and registry history cannot be edited. Accepted: noarchlang.uklink breaks, and the loss is confined to a hostname that had been redirect-only since 2026-07-15.- Three headers Vercel sent implicitly, Cloudflare sends not at all — and one of them is invisible to every check in this repository.
Access-Control-Allow-Origin: *,Content-Disposition: inlineandStrict-Transport-Security: max-age=63072000are now declared explicitly under/*on both sites. The CORS one is the finding: the JSON Schemas are fetched by browser-context validators at their canonical$idURLs andllms.txtadvertises the whole root as machine-fetchable, butscripts/smoke.mjsis a Nodefetchand Playwright'srequestfixture are both exempt from CORS, so losing the header would have read as a healthy 200 to every gate while being broken for every browser consumer. It is documented as a standing gap indocs/hosting-and-domains.mdrather than closed, because closing it means asserting a response header from a real browser origin — a test tier this repository does not have. (includeSubDomainsis deliberately absent from the HSTS value, matching what Vercel sent: adding it would newly bind every subdomain ofarchlang.uk.) - The rest of
_headersis a measured correction too, not a preference. Cloudflare types.mdastext/markdownwith no charset (Vercel sent one — without it atext/*response is latin-1 in some clients, mojibaking every em-dash in the raw markdown copies), and sends noContent-Typeat all for.gbnf(Vercel sentapplication/octet-stream; both deliver a machine-readable route as an opaque download). Both are corrected. Fingerprinted/assets/*also regainedmax-age=31536000, immutable, which Cloudflare does not assume; everything else keeps the revalidating default so a deploy is visible immediately. - No certificate work was needed. Universal SSL on the active zone already covered the apex and every first-level subdomain, so
archlang.uk,wwwandplaygroundwere inside the existing cert the moment the records flipped. - The whole cutover was verifiable before it was visible. Both Workers were deployed to
*.workers.devstaging origins and driven through the repository's own production checks with the live sites untouched: 42/42 docs smoke checks, 3/3 playground, 46/46 docs@prodPlaywright cases (including the byte-equality staleness probes) and 9/9 playground. Staging first, then DNS is the order to keep — every production check here takes an origin as an argument, which is what makes that possible. - Nothing in the core, the language, or any published artifact changed, and no URL moved, so the schema
$ids, the sitemap, the README permalinks and the MCP shim's baked context are all untouched.
Deferred, by name
- A
mainscript for anything. Redirects, headers and routing are all expressible inwrangler.jsonc+_headerstoday. The moment one is not, the cap above is the price. - Moving the
wwwredirect into the repository. It cannot be a_redirectsentry (path-only matching). It could become a Terraform/API-scripted zone config; that is a bigger commitment to infrastructure-as-code than one redirect justifies. - Preview deploys per branch. Vercel gave one per push; nothing replaces it here. A branch push now deploys nothing, and site changes are verified locally with
npm run e2e:docs/npm run e2e:playgroundagainst the built output. archcanvas.uk. In the same account and unaffected by this ADR.
Alternatives considered
Stay on Vercel. Rejected as consolidation, not as a fix: the Vercel deployment worked. The split cost was two dashboards, two vendors' failure modes, and a DNS arrangement whose only rule was "do not let Cloudflare touch this".
Cloudflare Pages. Rejected — see above: maintenance-mode for new projects, and it puts the build command and output directory in a dashboard where this repository wants them in a reviewed file.
A Worker script that serves the assets. Rejected: it buys nothing the asset layer does not already do (html_handling covers clean URLs and trailing slashes, _headers covers content types and caching), and it converts every request into a metered Worker invocation.
GitHub Pages. Rejected: no per-host redirect rules, no _headers equivalent for the content-type corrections the machine-readable routes need, and the apex/subdomain split across two sites does not fit one Pages site per repository.