From f4a1ee391d6100d40d0eb6916f3fe636bb6be14e Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:34:47 +0300 Subject: [PATCH 1/3] fix(astro-renderer): status-page fixes after RFC-0043 PR 1 (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - withErrorPage rewrites only Astro's own 500 HTML page; status pages (x-cloudhal-state), a themed 500 and any other 5xx pass and log nothing, so the starter's not-published 503 is no longer a 500 error page. - A store read failure in the state step answers 503 unavailable with Retry-After: 300, as /__cloudhal/health does. - The error page uses the locale and name of a site the request already read (loadedSite(), no store read), else English. - An invalid preview token on feeds, sitemaps, llms.txt and JSON paths answers statusPlain's 410; no response echoes the failure reason (x-cloudhal-preview-error: invalid). - The state step and error page live in status-entry.ts (unit-tested). - Docs: ADR-0012 preview fallback, the status catalog in the translate skill, DESIGN.md §1, the Weblate guide and ADR-0034, RFC-0043 §1.9 unknown hosts and §1.2/§1.8 wording. Closes #167 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .agents/skills/translate/SKILL.md | 9 +- .changeset/status-page-fixes-167.md | 5 + docs/adr/0012-cloudflare-deployment.md | 2 +- docs/adr/0034-translation-platform.md | 8 +- docs/design/DESIGN.md | 2 +- docs/guides/translations-weblate.md | 4 +- .../rfcs/0043-status-pages-and-maintenance.md | 24 ++- .../astro-renderer/src/cloudflare-entry.ts | 66 ++---- packages/astro-renderer/src/resolver.ts | 5 + .../astro-renderer/src/runtime-workerd.ts | 37 ++-- packages/astro-renderer/src/runtime.ts | 5 + packages/astro-renderer/src/status-entry.ts | 125 +++++++++++ packages/astro-renderer/src/status-serve.ts | 40 +++- .../astro-renderer/test/status-entry.test.ts | 196 ++++++++++++++++++ packages/cli/test/cloudflare-status.test.ts | 17 +- 15 files changed, 465 insertions(+), 80 deletions(-) create mode 100644 .changeset/status-page-fixes-167.md create mode 100644 packages/astro-renderer/src/status-entry.ts create mode 100644 packages/astro-renderer/test/status-entry.test.ts diff --git a/.agents/skills/translate/SKILL.md b/.agents/skills/translate/SKILL.md index 1784ae93..a8aece1b 100644 --- a/.agents/skills/translate/SKILL.md +++ b/.agents/skills/translate/SKILL.md @@ -9,14 +9,14 @@ metadata: # Translate Studio -Studio's catalogs live in the repo (ADR-0034). **Only English is edited by hand** (`apps/studio/src/i18n/en.ts`, `lazy/.en.ts`); every other language is generated from translation files by `pnpm i18n:import`, and a hand edit fails `pnpm check`. This skill drafts those translation files. It uploads nothing and never approves anything: people review on the translation platform (or the owner, for now), and only they set `reviewed: true` in `locales.ts`. +Studio's catalogs live in the repo (ADR-0034), and so does one visitor-facing catalog that goes through the same exchange: the renderer's status pages (`packages/astro-renderer/src/status-strings/`, RFC-0043 §1.5). **Only English is edited by hand** (`apps/studio/src/i18n/en.ts`, `lazy/.en.ts`, `packages/astro-renderer/src/status-strings/en.ts`); every other language is generated from translation files by `pnpm i18n:import`, and a hand edit fails `pnpm check`. This skill drafts those translation files. It uploads nothing and never approves anything: people review on the translation platform (or the owner, for now), and only they set `reviewed: true` in `locales.ts`. ## Steps -1. `pnpm i18n:export` writes `.i18n//.json` (nested, ICU MessageFormat 1 values) for every language and `.i18n/descriptions.json` (each key's English text and its note). `` is `core` or a namespace. +1. `pnpm i18n:export` writes `.i18n//.json` (nested, ICU MessageFormat 1 values) for every language and `.i18n/descriptions.json` (each key's English text and its note). `` is `core`, a namespace, or `status` (the status pages). 2. For the target language, translate what is missing or changed — compare `.i18n/en/.json` with `.i18n//.json` (a new language: copy the English files and translate every value). Read the key's note in `descriptions.json`. 3. Write the translated JSON back to `.i18n//`. Same keys, same nesting, same order as English. -4. `pnpm i18n:import --locale `: checks keys and ICU syntax, rewrites the catalogs, runs `test/i18n.test.tsx` and `test/i18n-messages.test.ts`. Fix what they report and import again. +4. `pnpm i18n:import --locale `: checks keys and ICU syntax, rewrites the catalogs (for `status`: `status-strings/.ts` and the index `status-strings/catalog.ts`), runs `test/i18n.test.tsx` and `test/i18n-messages.test.ts`. After a `status` change also run `pnpm exec vitest run test/status-page.test.ts` in `packages/astro-renderer` (every state in every language within 8 KB, no `plural`/`select`/`number`). Fix what they report and import again. 5. `pnpm design:lint` and the Studio typecheck must pass; open a PR that says the strings are AI drafts awaiting review. ## Rules for the messages (DESIGN.md §1 "Message syntax", RFC-0032) @@ -26,10 +26,11 @@ Studio's catalogs live in the repo (ADR-0034). **Only English is edited by hand* - Translate whole sentences; when a word must change with a value, keep or write a `select` with whole sentences — never glue words. - A literal `{`, `}` or (inside a plural) `#` is quoted: `'{}'`, `'#'`. - Keep product and technical names as they are (Cloudhal, Studio, Snapshot, Release, MCP, Markdown, URL, API, SEO). Use the language's usual UI register (Turkish and German Studio use the informal "sen"/"du"; privacy notices to site visitors are formal). +- Status-page messages (`status.json`) take simple `{name}` arguments only (`{time}`, `{reference}`): no `plural`, `select` or `number`, since the site Worker substitutes them without a compiler. Site visitors read them, so use the language's neutral public register. - An argument the language cannot use at all (a case suffix on an arbitrary name) is declared in that language's `dropped` export, with the reason — the import keeps it. ## Never -- Edit `apps/studio/src/i18n/.ts` or `lazy/..ts` by hand for any locale but `en`. +- Edit `apps/studio/src/i18n/.ts`, `lazy/..ts` or `packages/astro-renderer/src/status-strings/.ts` (or its `catalog.ts`) by hand for any locale but `en`. - Set `reviewed: true`, or present a draft as reviewed. - Upload to a translation platform or send strings outside the repo (platform hosting is the owner's decision, ADR-0034). diff --git a/.changeset/status-page-fixes-167.md b/.changeset/status-page-fixes-167.md new file mode 100644 index 00000000..7e976f51 --- /dev/null +++ b/.changeset/status-page-fixes-167.md @@ -0,0 +1,5 @@ +--- +"@cloudhal/astro-renderer": patch +--- + +Status page fixes after RFC-0043 PR 1 (#167). The Site Worker no longer turns a 5xx the renderer or the theme answers on purpose into the `error` page: the starter's own `not-published` 503 for a request that reaches Astro (a `POST`, say) passes unchanged and logs nothing; only Astro's own 500 page and thrown errors become the `error` page. A store that cannot be read before rendering answers 503 `unavailable` with `Retry-After: 300`, as `/__cloudhal/health` reports it, instead of a 500. The `error` page uses the site's language and name when the request has already read the site. An invalid preview token on a feed, sitemap, `llms.txt` or JSON path answers 410 instead of the published content, and no response says why a token failed any more (`x-cloudhal-preview-error` is now always `invalid`). diff --git a/docs/adr/0012-cloudflare-deployment.md b/docs/adr/0012-cloudflare-deployment.md index 04782394..a4a169d3 100644 --- a/docs/adr/0012-cloudflare-deployment.md +++ b/docs/adr/0012-cloudflare-deployment.md @@ -12,7 +12,7 @@ Sprint 6 puts a Cloudhal site on Cloudflare. Publishing and rollback must be ins - *Site Worker* — the project's Astro build with `@astrojs/cloudflare` (`CLOUDHAL_TARGET=cloudflare`). Every project route renders on demand (`astro:route:setup` sets `prerender = false`). `@cloudhal/astro-renderer/runtime` has a `workerd` export condition that reads D1/R2 bindings through `SiteResolver`: the site's `publishedSnapshotId` decides what renders, so **publish and rollback are pointer flips** — no rebuild, no redeploy. Snapshot sources are immutable and cached per isolate; the pointer is read on every cache miss. - *Admin Worker* (`@cloudhal/cloudflare/admin`) — Studio SPA as static assets plus the Hono API, Better Auth (D1 binding, D1-backed rate limiting, `cf-connecting-ip`), `/mcp` and the push/pull endpoints, on one origin. `run_worker_first: ["/api/*", "/mcp", "/mcp/*", "/.well-known/*"]` because SPA navigation requests otherwise never reach the Worker. 2. **Workers Cache, not `caches.default`.** Published pages carry `Cloudflare-CDN-Cache-Control: public, max-age=31536000`, `Cache-Control: public, max-age=0, must-revalidate` and `Cache-Tag: cloudhal-site-`. A hit never runs the Worker. Publish/rollback purge the tag: purges are scoped to the Worker that cached, so the admin Worker calls the site Worker's authenticated `POST /__cloudhal/purge` over the `SITE` service binding (`SiteGraphStore.onPublished`). `caches.default` was rejected: no effect on workers.dev, per-colo only. -3. **Previews never touch the cache.** The cache key ignores the hostname and cookies, so previews are signed URL tokens (`?cloudhal-preview=`, HMAC, site-bound, expiring) answered with `no-store`; the entry rewrites internal links with HTMLRewriter to keep the token while navigating. Invalid tokens fall back to the published page, uncached. +3. **Previews never touch the cache.** The cache key ignores the hostname and cookies, so previews are signed URL tokens (`?cloudhal-preview=`, HMAC, site-bound, expiring) answered with `no-store`; the entry rewrites internal links with HTMLRewriter to keep the token while navigating. An invalid token (expired, forged, for another site, malformed, or previews off) never falls back to the published page: pages answer the 410 `preview-expired` status page and feeds, sitemaps and JSON paths a 410 plain answer (RFC-0043 §1.2, decision 12); media keep serving the published file, uncached. No answer says why the token failed. 4. **The deployment is the source of truth after the first deploy.** `cloudhal deploy` provisions D1/R2 (ids recorded in the wrangler configs), generates secrets once (`.cloudhal/deploy/secrets.json`, 0600), deploys site then admin with `wrangler deploy --secrets-file`, and pushes the local site only if the deployment does not have it. `cloudhal push` refuses to overwrite diverged content (agents edit production); `cloudhal pull` brings it home. Transfers are JSON site bundles verified by content hash, through `/api/admin/sites/:id/bundle` behind a deployment token — no Cloudflare API token handling in Cloudhal; wrangler uses the user's own login. 5. **Migrations run from the admin Worker** (our `_migrations` ledger, once per isolate), not `wrangler d1 migrations apply` — one ledger only. 6. **Testing:** both Workers run on workerd through wrangler's `createTestHarness` with local D1/R2 (`packages/cli/test/cloudflare*.test.ts`). `@cloudflare/vitest-pool-workers` is not used (needs Vitest 4). **Correction to ADR-0008:** Miniflare 5 alpha *does* expose `getD1Database`/`getR2Bucket`; its constructor options changed shape. We pin the last stable **Miniflare 4.20260730.0**, which needs no code change. diff --git a/docs/adr/0034-translation-platform.md b/docs/adr/0034-translation-platform.md index 8eb4f656..64e00f4f 100644 --- a/docs/adr/0034-translation-platform.md +++ b/docs/adr/0034-translation-platform.md @@ -10,7 +10,7 @@ - XLIFF 2.1: https://docs.oasis-open.org/xliff/xliff-core/v2.1/xliff-core-v2.1.html ## Context -Studio's catalogs are TypeScript modules: `apps/studio/src/i18n/.ts` and `lazy/..ts`. +Studio's catalogs are TypeScript modules: `apps/studio/src/i18n/.ts` and `lazy/..ts`. Since RFC-0043 §1.5 the renderer's status-page catalog (`packages/astro-renderer/src/status-strings/.ts`) goes through the same exchange (see the amendment below). - English is the source and the type. The other languages are typed against it, and since ADR-0032 they are ICU MessageFormat 1. - Checks guard keys, argument kinds, plural categories, declared forms and drops, unused keys, and English sentences left untranslated (`test/i18n*.test.ts`). - German was added as an AI draft (`reviewed: false`). Turkish is maintained by the owner. @@ -84,6 +84,12 @@ The owner delegated the open questions; decided in the session that coordinates - **Until then:** an AI second-pass consistency review (glossary, register, terms) may correct the drafts through the `translate` skill and `pnpm i18n:import`. It never sets `reviewed: true`. - **Visitor-facing strings** (themes, notices) stay out of scope for now. The catalogs remain Studio-only. +### Amendment (2026-11-08, RFC-0043 §1.5) +- One visitor-facing catalog joins the exchange: the site status pages (`packages/astro-renderer/src/status-strings/`). `pnpm i18n:export` writes it as `.i18n//status.json` beside Studio's files, `pnpm i18n:import` writes `status-strings/.ts` and the index `catalog.ts` with the same hash guard, and only `en.ts` is edited by hand. +- Its languages are Studio's `LOCALE_REGISTRY`, with the same review state (AI drafts stay `reviewed: false`). It is one more Weblate component (`docs/guides/translations-weblate.md`). +- Its messages take simple `{name}` arguments only, so the site Worker needs no message compiler. +- Theme strings and notices stay out of scope. + ## Open questions None left from this ADR; the questions below were answered above. - *(answered)* Hosting: Hosted Weblate, Libre plan. diff --git a/docs/design/DESIGN.md b/docs/design/DESIGN.md index bcca9fd3..1b3b490a 100644 --- a/docs/design/DESIGN.md +++ b/docs/design/DESIGN.md @@ -42,7 +42,7 @@ Keyboard parity with the dashboard: `⌘K` / `/` open the palette; `t s` toggles - **Languages.** `i18n/locales.ts` is the registry: one entry per language, keyed by its BCP 47 tag (also ``, every `Intl` formatter and `calendarLocaleProps`, so dates and numbers follow the Studio language, never the browser's), with its native `name`, `dir` (set on ``), `fallback` chain (always ending in English) `reviewed` and, where formatting needs it, an `intl` tag (Arabic and Persian `…-u-nu-latn`, Western digits, ADR-0033; `setIntlTags` makes every formatter of `@cloudhal/messages` use it). Besides English (the source) and Turkish (the owner's), every language is an AI translation awaiting review by a person who speaks it (`reviewed: false`, ADR-0034; the first right-to-left one is Arabic). The language menus show each name in itself only; that a language is an unreviewed AI translation is a comment on its registry entry, for maintainers. Loaders are not written by hand: `import.meta.glob` finds `i18n/.ts` (`export const messages: Messages`) and `i18n/lazy/..ts`; `bundle-budget.ts` keeps every core catalog but English out of the first load. Studio opens in English, its global default, for everyone: the browser's language is not consulted; a person's own choice in the language menu is stored and wins (`matchLocale` remains for matching a tag to a registered language). - **Pseudo-locale and catalog checks** (the i18n review's Phase 5). On a dev server (and in tests) `?pseudo` or `localStorage["cloudhal.studio.pseudo"] = "1"` shows every catalog message as "[Šààṽéé]" — accented, about a third longer, bracketed — generated from the compiled English messages (`@cloudhal/messages/pseudo`) with every argument and plural kept: plain English on screen is a hard-coded word, a missing "]" is a cut-off label, a broken layout has no room for German. It is never a language in the menus, and production builds do not contain it (`import.meta.env.DEV`; the bundle budget fails if the module reaches a chunk). `SHOTS_PSEUDO=1 pnpm design:shots -- …` takes the screenshots in it. `test/i18n-messages.test.ts` also fails on an **unused key**: `apps/studio/scripts/i18n-usage.ts` collects every literal and template key in Studio's code (string constants resolved, a literal naming a group reaches the group); `node apps/studio/scripts/i18n-usage.ts` lists them. Missing keys and placeholder parity were already checked (types, `test/i18n.test.tsx`). - **Right-to-left readiness** (ADR-0033 D and B). Studio and the `@cloudhal/ui` blocks use logical utilities only — `ms`/`me`, `ps`/`pe`, `start`/`end`, `text-start`/`text-end`, `border-s`/`border-e`, `rounded-s`/`rounded-e`/`rounded-ss`… — never `ml`/`mr`, `pl`/`pr`, `left`/`right`, `text-left`/`text-right`, `border-l`/`border-r`, `rounded-l`/`rounded-r` (lint `rtl-logical`; `scripts/i18n-codemod/logical-utilities.ts` converts; an element placed by Floating UI in physical coordinates keeps `left-0` with an `i18n-ignore` reason). `` and Base UI's `DirectionProvider` (in `KumoLocale`, through `@cloudhal/ui`) follow the locale registry's `dir`. `SHOTS_PSEUDO=rtl pnpm design:shots -- …` (or `?pseudo=rtl` on a dev server) renders the right-to-left pseudo-locale to find what does not mirror. Kumo's own components are made logical by the generated patch (`node scripts/kumo-patch.ts`, ADR-0033 A1; `kumo-upstream-notes.md` §7); Icons that mean a direction in reading order — back and forward, previous and next, "from → to", a closed tree row, opening a row, reply, indent, opening elsewhere, signing in and out, sending — go through `DirectionalIcon` (``, or `directional(ArrowRight)` where a prop or table takes an icon component), which sets Phosphor's `mirrored` from Base UI's direction (ADR-0033 C1, lint `rtl-icon`). Physical and temporal directions stay as they are: up and down, sort order, clocks, refresh and undo circles, a chart's trend; such an icon among the matched names carries an `i18n-ignore` reason. Kumo's own ten directional icons (submenu, sidebar, pagination, date range, command palette) and the breadcrumb separator mirror through `rtl:-scale-x-100` from the hand-written `patches/kumo/rtl-icons.patch`, which reads `` rather than Base UI's context (`kumo-upstream-notes.md` §7). Arrow keys follow the direction too: blocks whose Left and Right mean previous and next, open and close, or nest and move out (`MenuTree`, `CalendarMonth`) read the key through `readingKey(key, useDirection())`, and their help text names the keys as they work in the language (`menus.tree.instructionsRtl`). -- **Translation workflow** (ADR-0034). **Only English is edited by hand** (`en.ts`, `lazy/.en.ts`). Every other catalog is generated by `pnpm i18n:import` from translation files — nested ICU JSON, `.i18n//.json`, written by `pnpm i18n:export` with `.i18n/descriptions.json` (English text and the note above each key) — and carries a hash of its messages: a hand edit fails `pnpm check` (`node scripts/i18n-exchange.ts check`, run by `test/i18n-messages.test.ts`). The import checks keys and ICU syntax before writing anything, keeps a catalog's own exports after its messages (Turkish `dropped`), formats the files and runs the catalog tests. New English keys: export, translate the new keys in each language's JSON (by hand or with the `translate` skill, `.agents/skills/translate`), import. The translation platform (Weblate or Tolgee) and its hosting wait for the owner; the exchange format is the one it will read. +- **Translation workflow** (ADR-0034). **Only English is edited by hand** (`en.ts`, `lazy/.en.ts`, and the renderer's status-page catalog `packages/astro-renderer/src/status-strings/en.ts`, RFC-0043 §1.5, which visitors read and which goes through the same exchange as `status`). Every other catalog is generated by `pnpm i18n:import` from translation files — nested ICU JSON, `.i18n//.json`, written by `pnpm i18n:export` with `.i18n/descriptions.json` (English text and the note above each key) — and carries a hash of its messages: a hand edit fails `pnpm check` (`node scripts/i18n-exchange.ts check`, run by `test/i18n-messages.test.ts`). The import checks keys and ICU syntax before writing anything, keeps a catalog's own exports after its messages (Turkish `dropped`), formats the files and runs the catalog tests. New English keys: export, translate the new keys in each language's JSON (by hand or with the `translate` skill, `.agents/skills/translate`), import. The translation platform (Weblate or Tolgee) and its hosting wait for the owner; the exchange format is the one it will read. - **Adding a language** (the i18n review's Phase 6): (1) **prerequisites** — a language with plural categories beyond one/other (ar, ru, uk, pl, cs, …: `Intl.PluralRules(…).resolvedOptions().pluralCategories`) waits for ICU plurals (the review's Phase 1), a right-to-left one (ar, he, fa, ur) for logical CSS and Kumo's direction support (Phase 3); (2) add the registry entry with `reviewed: false`; (3) `pnpm i18n:export`, write `.i18n//*.json` (the `translate` skill drafts them; an AI translation is fine as a first draft; its registry entry says so in a comment until reviewed), `pnpm i18n:import --locale ` — it generates `i18n/.ts` and every `lazy/..ts`; (4) `pnpm --filter @cloudhal/studio typecheck` and `test/i18n.test.tsx` (keys, placeholders, no English sentences left, every namespace present); (5) `vite build` — the bundle budget must not move (the language loads only for its users); (6) `pnpm design:shots` in the language at 1440 and 390 px (German and Finnish run long); (7) a speaker reviews in context, then `reviewed: true`. ## 2. Page templates diff --git a/docs/guides/translations-weblate.md b/docs/guides/translations-weblate.md index 5c2d1bc5..844bd7e4 100644 --- a/docs/guides/translations-weblate.md +++ b/docs/guides/translations-weblate.md @@ -29,12 +29,14 @@ One Weblate component per catalog file, all in the same project: | Setting | Value | |---|---| | File format | JSON nested structure file (`json-nested`) | -| Monolingual base (template) | `i18n/en/.json`; `` is `core` or a namespace (`analytics`, `comments`, …) | +| Monolingual base (template) | `i18n/en/.json`; `` is `core`, a namespace (`analytics`, `comments`, …) or `status` | | File mask | `i18n/*/.json` | | Source language | English | | Quality checks (flags) | `icu-message-format` (ICU syntax and placeholders, RFC-0032) | | Languages | every language in `apps/studio/src/i18n/locales.ts`: Turkish (reviewed, by you), the others machine drafts for community review | +The `status` component is the one visitor-facing catalog: the site status pages (coming soon, maintenance, unavailable, error, expired preview link) from `packages/astro-renderer/src/status-strings/` (RFC-0043 §1.5), about 14 keys. Its messages take simple `{name}` arguments only, never `plural` or `select`; give it the same flags. + Turn on **reviews** (translated → approved), so "approved by a reviewer" is what makes a language `reviewed: true` in `apps/studio/src/i18n/locales.ts`. ## 4. What agents do, and what stays yours diff --git a/docs/rfcs/0043-status-pages-and-maintenance.md b/docs/rfcs/0043-status-pages-and-maintenance.md index 570c7423..2bc6ac4f 100644 --- a/docs/rfcs/0043-status-pages-and-maintenance.md +++ b/docs/rfcs/0043-status-pages-and-maintenance.md @@ -185,7 +185,7 @@ an RFC change, so themes can switch over it exhaustively. | `not-published` | the site has no `publishedSnapshotId` and the request carries no valid preview | 503 | `3600` | `noindex` | | `maintenance` | §2 is in effect and the request has no valid preview or bypass link | 503 | seconds to the end (≥ 60); `3600` when open-ended | **none** | | `unavailable` | the Worker serves no site yet (`siteId` undefined), the site row is missing (deleted, RFC-0034), or the store cannot be read before rendering | 503 | `300` | none | -| `error` | an exception reached the entry's top-level catch, or Astro answered 5xx with no themed error page | 500 | — | none | +| `error` | an exception reached the entry's catch, or Astro answered its own 500 page (no status page, no themed error page) | 500 | — | none | | `preview-expired` | the URL carries `cloudhal-preview` and the token is expired, forged, for another site, malformed, or previews are off | 410 | — | `noindex` | - **Every state:** `cache-control: no-store`, `cloudflare-cdn-cache-control: no-store`, @@ -213,8 +213,11 @@ an RFC change, so themes can switch over it exhaustively. end in the past or within a minute gives `60`. - **`preview-expired` changes behaviour on purpose:** an invalid preview token no longer falls back to the published page. The reviewer learns that the link is dead instead of reviewing the - wrong version. `?cloudhal-preview=exit` and the dev preview cookie keep their behaviour; media - URLs keep their short plain answers (§1.8), since nobody views an image as a page. + wrong version. Feeds, sitemaps, `llms.txt` and JSON paths answer `statusPlain`'s 410 (#167), + never the published content. `?cloudhal-preview=exit` and the dev preview cookie keep their + behaviour; media URLs keep their short plain answers (§1.8), since nobody views an image as a + page: the published file, `no-store`, with `x-cloudhal-preview-error: invalid`. No answer says + why a token failed (decision 12). #### 1.3 Design of the page @@ -346,9 +349,13 @@ export interface StatusContext extends StatusPageInput { - **Site Worker (`cloudflare-entry.ts`):** a new step between host dispatch and `serveSite` decides the state (§2.5 has the order). The whole `route()` is wrapped in `try/catch` → - `error`. A 5xx HTML response from Astro is replaced by the `error` page unless the theme - marked it as its own (`x-cloudhal-error-page: theme`, set by a theme's `500.astro`; no starter - has one today). Cloudflare-level failures (CPU limit, out of memory, exception 1101) cannot be + `error`. Astro's own 500 HTML page (a render that threw) is replaced by the `error` page unless + the theme marked it as its own (`x-cloudhal-error-page: theme`, set by a theme's `500.astro`; + no starter has one today). A response that carries `x-cloudhal-state` (the starter's + `not-published` 503 for a request that reaches Astro, e.g. a `POST`) and any other 5xx the theme + answers on purpose pass unchanged and log nothing (#167). A store that cannot be read in the + state step is `unavailable` (503, `Retry-After: 300`), as health reports it. The `error` page + uses the locale and name of a site the request has already read (§1.4, §1.6), else English. Cloudflare-level failures (CPU limit, out of memory, exception 1101) cannot be caught by the Worker and keep Cloudflare's page. The resolver's verdict (pointer, preview token) is memoised per request, so the entry's state step and Astro's `getSource` share one pointer read. @@ -395,6 +402,9 @@ Worker): proves that the Worker runs and its store answers. `200` whenever the Worker can answer; `503` with `{ "ok": false }` when the store cannot be read. It never exposes snapshot ids, versions, hosts or messages. +- **A host that maps to no site** on a multi-site Worker never reaches health: it gets the plain + 404 cached for 60 s (`unknownHost()`, RFC-0034 §4; step 3 of §2.5), not `{ ok, state }`. A + monitor watches each site's own hostname. - **The shape is the admin's `/api/health`** (`{ "ok": true }`, #148) with `state` added, so one rule reads both: the Docker image's `HEALTHCHECK`, Caddy, uptime monitors and cloudhal.com's post-deploy check look at `ok` and treat any `state` as "up". Rationale: two health endpoints @@ -915,7 +925,7 @@ contract. - **Behaviour changes on upgrade** (changeset for `@cloudhal/astro-renderer`): - an unpublished site shows the styled page instead of plain text (same 503); - an invalid preview link answers 410 instead of the published page; - - Astro 5xx HTML becomes the `error` page; + - Astro's own 500 HTML page becomes the `error` page (other 5xx and status pages pass); - `/__cloudhal/health` exists. - **Themes:** no change needed. Old copies get every status page (the entry answers before Astro); their own plain 503s stay as the fallback on Node and static targets. The `statusPage` diff --git a/packages/astro-renderer/src/cloudflare-entry.ts b/packages/astro-renderer/src/cloudflare-entry.ts index ef42ec32..fd20c785 100644 --- a/packages/astro-renderer/src/cloudflare-entry.ts +++ b/packages/astro-renderer/src/cloudflare-entry.ts @@ -36,8 +36,12 @@ * before the first publish, `preview-expired` for a preview token that does not verify, * `unavailable` for a Worker that serves no site — and answers page paths with the renderer's * own page (`status-page.ts`: 503/410, `Retry-After`, `no-store`, its own CSP), restyled by the - * theme's optional `statusPage` hook. A failure anywhere becomes the `error` page (500) with the - * `cf-ray` as its reference. `GET /__cloudhal/health` answers `{ ok, state }` for monitors. + * theme's optional `statusPage` hook. Machine paths get a short plain answer for an invalid + * preview token or an unreadable store (`status-entry.ts`). A store that cannot be read before + * rendering is `unavailable` (503, `Retry-After: 300`); a thrown error or Astro's own 500 page + * becomes the `error` page (500) with the `cf-ray` as its reference, in the site's language + * once the request has read the site; any 5xx the theme or renderer answers on purpose passes. + * `GET /__cloudhal/health` answers `{ ok, state }` for monitors. */ import { cache } from "cloudflare:workers"; @@ -48,8 +52,8 @@ import { type WaeDataset, WaeWriter } from "@cloudhal/analytics/wae"; import { analyticsBackend, CACHE_TAG_PREFIX, + loadedSite, resetSource, - type SiteStateResult, serveMedia, siteState, withRequestSite, @@ -72,9 +76,10 @@ import { hostRedirect, type RedirectEnv } from "./host-redirect.ts"; import { PREVIEW_PARAM } from "./resolver.ts"; import { SearchLimiter, searchCost } from "./search-limit.ts"; import { SiteDispatch, unknownHost, withNoindex } from "./site-dispatch.ts"; +import { errorPage, type StatusState, statusStep, withErrorPage } from "./status-entry.ts"; import { renderStatus, themeStatusHook } from "./status-hook.ts"; -import { type SiteState, statusPage } from "./status-page.ts"; -import { isStatusPagePath, statusInput } from "./status-serve.ts"; +import type { SiteState } from "./status-page.ts"; +import { statusInput } from "./status-serve.ts"; interface Env extends RedirectEnv { CLOUDHAL_SITE_ID?: string; @@ -157,37 +162,6 @@ let dispatch: SiteDispatch | undefined; /** RFC-0043 §1.7: the theme's `statusPage` hook, loaded the first time a status page is drawn. */ const statusHook = themeStatusHook(loadTheme, (m, e) => console.error(m, e)); -/** RFC-0043 §1.3: the `error` page's reference — Cloudflare's ray id, else a random one. */ -function referenceId(request: Request): string { - return ( - request.headers.get("cf-ray") ?? - Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => - b.toString(16).padStart(2, "0"), - ).join("") - ); -} - -/** The `error` page (RFC-0043 §1.2): never the theme's hook, nothing read from the store. */ -function errorPage(request: Request, error: unknown): Response { - const rayId = referenceId(request); - console.error(`cloudhal: request failed (reference ${rayId})`, error); - return statusPage("error", { locale: "en", now: Date.now(), rayId }, { method: request.method }); -} - -/** - * A 5xx HTML answer from Astro becomes the `error` page unless the theme marked it as its own - * (`x-cloudhal-error-page: theme`, a theme's `500.astro`). - */ -function withErrorPage(response: Response, request: Request): Response { - if ( - response.status < 500 || - !(response.headers.get("content-type") ?? "").includes("text/html") || - response.headers.get("x-cloudhal-error-page") === "theme" - ) - return response; - return errorPage(request, new Error(`Astro answered ${response.status}`)); -} - /** * RFC-0043 §1.9: `{ ok, state }`, the admin's `/api/health` shape plus the site's state, so one * monitor rule reads both. Public, never cached, never indexed; reads only the memoised pointer. @@ -582,6 +556,7 @@ async function serveSite( cacheOnlySuccess((await handle(request, env as never, ctx)) as unknown as Response), ), request, + { site: loadedSite() }, ); // Server-side render events (§5.4), off the response path: published HTML 200s (cache // misses), redirects, 404, 410, 451 and 5xx; never previews or HEAD. @@ -611,7 +586,7 @@ async function serveSite( */ async function statusResponse( call: { request: Request; env: Env; ctx: ExecutionContext }, - state: Exclude, + state: StatusState, siteId: string, ): Promise { const { request, env, ctx } = call; @@ -660,12 +635,17 @@ export function createSiteWorker(options: { siteId?: string } = {}) { const siteId = choice.siteId ?? config.siteId; const call = { request, env, ctx }; const run = async () => { - if (url.pathname === "/__cloudhal/health") return health(request); - const page = - (request.method === "GET" || request.method === "HEAD") && isStatusPagePath(url.pathname); - const state = page ? await siteState(request) : undefined; - if (state && state.kind !== "serve") return statusResponse(call, state, siteId); - return serveSite(call, siteId, searchLimiter); + try { + if (url.pathname === "/__cloudhal/health") return await health(request); + const status = await statusStep(request, { + state: () => siteState(request), + page: (state) => statusResponse(call, state, siteId), + }); + return status ?? (await serveSite(call, siteId, searchLimiter)); + } catch (error) { + // RFC-0043 §1.6: inside the request's site, so the page can use what it already read. + return errorPage(request, error, { site: loadedSite() }); + } }; const response = await withRequestSite(siteId, run); return choice.noindex ? withNoindex(response) : response; diff --git a/packages/astro-renderer/src/resolver.ts b/packages/astro-renderer/src/resolver.ts index fe81b42d..e8a93820 100644 --- a/packages/astro-renderer/src/resolver.ts +++ b/packages/astro-renderer/src/resolver.ts @@ -115,6 +115,11 @@ export class SiteResolver { return this.#pointer.site; } + /** The site row the last `site()` read, without reading again (`undefined` before one). */ + loadedSite(): Site | undefined { + return this.#pointer?.site; + } + snapshot(id: string): Promise { let entry = this.#snapshots.get(id); if (entry) { diff --git a/packages/astro-renderer/src/runtime-workerd.ts b/packages/astro-renderer/src/runtime-workerd.ts index 7d9608a1..0d1ed943 100644 --- a/packages/astro-renderer/src/runtime-workerd.ts +++ b/packages/astro-renderer/src/runtime-workerd.ts @@ -25,6 +25,7 @@ import type { PeerEdition } from "./locales.ts"; import { matchMediaRoute, serveMediaRequest } from "./media-route.ts"; import { PREVIEW_PARAM, type Resolution, SiteResolver } from "./resolver.ts"; import type { GraphSource } from "./source.ts"; +import { markInvalidPreview } from "./status-serve.ts"; /** RFC-0029 §3: `AI_SEARCH`, `SEARCH_BUCKET` and `CLOUDHAL_SEARCH_*` turn on semantic search. */ export interface SiteWorkerEnv extends AiSearchEnv { @@ -49,11 +50,20 @@ export interface AstroLike { * Worker) around everything the request runs, Astro's render included. Module state, not a header: * a client cannot choose it. */ -const requestSite = new AsyncLocalStorage(); +const requestSite = new AsyncLocalStorage<{ readonly siteId: string; site?: Site }>(); /** Runs `fn` (and everything it awaits) as a request for `siteId`. */ export function withRequestSite(siteId: string, fn: () => T): T { - return requestSite.run(siteId, fn); + return requestSite.run({ siteId }, fn); +} + +/** + * RFC-0043 §1.6: the site node this request already read (its name and locales), for the + * `error` page; `undefined` before a successful read. Never reads the store: it may be what + * failed. Only inside `withRequestSite`. + */ +export function loadedSite(): Site | undefined { + return requestSite.getStore()?.site; } /** @@ -73,7 +83,7 @@ export function withRequestStore(store: SiteGraphStore, siteId: string, fn: ( function siteOf(fallback: string | undefined): string | undefined { return ( requestStore.getStore()?.siteId ?? - requestSite.getStore() ?? + requestSite.getStore()?.siteId ?? (env as unknown as SiteWorkerEnv).CLOUDHAL_SITE_ID ?? fallback ); @@ -153,7 +163,13 @@ const resolutions = new WeakMap>(); function resolveRequest(r: SiteResolver, request: Request): Promise { let pending = resolutions.get(request); if (!pending) { - pending = r.resolve(request); + const context = requestSite.getStore(); + pending = r.resolve(request).then((resolution) => { + // A preview resolves without reading the site row; anything else just read it. + const site = resolution.kind === "preview" ? undefined : r.loadedSite(); + if (context && site?.id === context.siteId) context.site = site; + return resolution; + }); resolutions.set(request, pending); } return pending; @@ -335,11 +351,7 @@ export async function getSource( h.set("cache-tag", siteCacheTag(siteId)); h.set("x-cloudhal-mode", "published"); h.set("x-cloudhal-snapshot", resolution.snapshotId); - if (resolution.previewError) { - // An invalid preview link must not poison the cache entry for this URL. - h.set("cloudflare-cdn-cache-control", "no-store"); - h.set("x-cloudhal-preview-error", resolution.previewError); - } + if (resolution.previewError) markInvalidPreview(h); return resolution.source; } @@ -374,10 +386,7 @@ export async function serveMedia( blobs: store.blobs, ...(preview && token ? { keepQuery: `${PREVIEW_PARAM}=${encodeURIComponent(token)}` } : {}), }); - if (response && resolution.kind === "published" && resolution.previewError) { - // An invalid preview link must not poison the edge cache entry for this URL. - response.headers.set("cloudflare-cdn-cache-control", "no-store"); - response.headers.set("x-cloudhal-preview-error", resolution.previewError); - } + if (response && resolution.kind === "published" && resolution.previewError) + markInvalidPreview(response.headers); return response; } diff --git a/packages/astro-renderer/src/runtime.ts b/packages/astro-renderer/src/runtime.ts index ae913ac4..1b28ea7d 100644 --- a/packages/astro-renderer/src/runtime.ts +++ b/packages/astro-renderer/src/runtime.ts @@ -58,6 +58,11 @@ export function withRequestSite(_siteId: string, fn: () => T): T { return fn(); } +/** RFC-0043 §1.6: the `workerd` build's per-request site; `cloudhal dev` keeps none. */ +export function loadedSite(): Site | undefined { + return undefined; +} + /** * RFC-0036 §4: on workerd a playground runs each request over its session's store; `cloudhal dev` * renders from the project's local store, so this only runs `fn`. diff --git a/packages/astro-renderer/src/status-entry.ts b/packages/astro-renderer/src/status-entry.ts new file mode 100644 index 00000000..1f5606d8 --- /dev/null +++ b/packages/astro-renderer/src/status-entry.ts @@ -0,0 +1,125 @@ +/** + * The Site Worker entry's status step and `error` page (RFC-0043 §1.2, §1.8, §2.5), apart from + * `cloudflare-entry.ts` so unit tests run them: no bindings, no Astro, no `cloudflare:` imports. + * Worker-safe. + */ +import type { Site } from "@cloudhal/core-contracts"; +import type { SiteStateResult } from "./runtime-workerd.ts"; +import { type StatusPageInput, statusPage, statusPlain } from "./status-page.ts"; +import { statusInput, statusPathKind } from "./status-serve.ts"; + +/** A state the entry answers itself. */ +export type StatusState = Exclude; + +type Log = (message: string, error?: unknown) => void; + +/** §1.3: the `error` page's reference — Cloudflare's ray id, else a random one. */ +export function referenceId(request: Request): string { + return ( + request.headers.get("cf-ray") ?? + Array.from(crypto.getRandomValues(new Uint8Array(8)), (b) => + b.toString(16).padStart(2, "0"), + ).join("") + ); +} + +export interface ErrorPageOptions { + /** The site node this request already read (`loadedSite()`); never read here (§1.6). */ + readonly site?: Site | undefined; + readonly log?: Log | undefined; + readonly now?: number | undefined; +} + +/** + * The `error` page (§1.2): never the theme's hook, nothing read from the store. In the site's + * locale and with its name when this request already read the site (§1.4, §1.6), else English. + */ +export function errorPage( + request: Request, + error: unknown, + options: ErrorPageOptions = {}, +): Response { + const rayId = referenceId(request); + (options.log ?? console.error)(`cloudhal: request failed (reference ${rayId})`, error); + const now = options.now ?? Date.now(); + let input: StatusPageInput = { locale: "en", now }; + try { + const url = new URL(request.url); + input = statusInput( + "error", + { live: options.site }, + { host: url.host, pathname: url.pathname }, + now, + ); + } catch { + // a malformed site node: the English page without a name + } + return statusPage("error", { ...input, rayId }, { method: request.method }); +} + +/** + * §1.8: Astro's own 500 HTML (a render that threw) becomes the `error` page. What the renderer + * or the theme answered on purpose passes unchanged and logs nothing: a status page + * (`x-cloudhal-state`, e.g. the starter's `not-published` 503 for a request that reaches Astro), + * a theme's `500.astro` (`x-cloudhal-error-page: theme`) and any other 5xx. + */ +export function withErrorPage( + response: Response, + request: Request, + options: ErrorPageOptions = {}, +): Response { + if ( + response.status !== 500 || + !(response.headers.get("content-type") ?? "").includes("text/html") || + response.headers.has("x-cloudhal-state") || + response.headers.get("x-cloudhal-error-page") === "theme" + ) + return response; + return errorPage(request, new Error("Astro answered 500"), options); +} + +export interface StatusStepOptions { + /** The request's verdict (the runtime's `siteState`); a throw is a store read failure. */ + readonly state: () => Promise; + /** Draws the status page for a page path (theme hook, render event). */ + readonly page: (state: StatusState) => Promise; + readonly log?: Log | undefined; + readonly now?: (() => number) | undefined; +} + +/** + * §2.5 step 6, for `GET`/`HEAD`: the status answer for this request, or `undefined` to serve the + * site. Page paths get the page of every state. Machine paths (§2.3) get a short plain or JSON + * answer for `preview-expired` (decision 12: never the published content) and `unavailable`; + * before the first publish they keep the theme's own `statusPlain`. A store that cannot be read + * is `unavailable` (503, `Retry-After: 300`), as `/__cloudhal/health` reports it. + */ +export async function statusStep( + request: Request, + options: StatusStepOptions, +): Promise { + if (request.method !== "GET" && request.method !== "HEAD") return undefined; + const url = new URL(request.url); + const kind = statusPathKind(url.pathname); + if (!kind) return undefined; + let state: SiteStateResult; + try { + state = await options.state(); + } catch (error) { + (options.log ?? console.error)("cloudhal: the store could not be read before rendering", error); + state = { kind: "unavailable" }; + } + if (state.kind === "serve") return undefined; + if (kind === "page") return options.page(state); + if (state.kind === "not-published") return undefined; + const sites = + state.kind === "unavailable" ? {} : { live: state.site, published: state.published }; + const input = statusInput( + state.kind, + sites, + { host: url.host, pathname: url.pathname }, + (options.now ?? Date.now)(), + ); + const as = url.pathname.toLowerCase().endsWith(".json") ? "json" : "text"; + return statusPlain(state.kind, input, as, { method: request.method }); +} diff --git a/packages/astro-renderer/src/status-serve.ts b/packages/astro-renderer/src/status-serve.ts index 9a117952..77b5808b 100644 --- a/packages/astro-renderer/src/status-serve.ts +++ b/packages/astro-renderer/src/status-serve.ts @@ -18,11 +18,37 @@ export const MACHINE_PATH = /\.(?:xml|txt|json|rss|atom|webmanifest)$/i; * operator endpoints, comment endpoints and machine paths keep their own answers. */ export function isStatusPagePath(pathname: string): boolean { - if (pathname === "/robots.txt") return false; - if (matchMediaRoute(pathname)) return false; - if (/^\/(?:_astro|_cloudhal|__cloudhal|\.well-known)\//.test(pathname)) return false; - if (isCommentsPath(pathname)) return false; - return !MACHINE_PATH.test(pathname); + return !keepsOwnAnswer(pathname) && !MACHINE_PATH.test(pathname); +} + +/** `robots.txt`, media, static assets, `/.well-known/`, operator and comment endpoints. */ +function keepsOwnAnswer(pathname: string): boolean { + return ( + pathname === "/robots.txt" || + matchMediaRoute(pathname) !== undefined || + /^\/(?:_astro|_cloudhal|__cloudhal|\.well-known)\//.test(pathname) || + isCommentsPath(pathname) + ); +} + +/** + * How the entry's state step treats a path (§2.3, §2.5): `page` gets the status page, `machine` + * (feeds, sitemaps, `llms.txt`, search JSON) a short plain or JSON answer where the state calls + * for one, `undefined` keeps its own answer. + */ +export function statusPathKind(pathname: string): "page" | "machine" | undefined { + if (keepsOwnAnswer(pathname)) return undefined; + return MACHINE_PATH.test(pathname) ? "machine" : "page"; +} + +/** + * Decision 12: a response served despite a preview token that did not verify (media, and paths + * the state step leaves to the theme) is kept out of the edge cache and says only that the link + * is invalid; why it failed (expired, signature, another site, previews off) is never echoed. + */ +export function markInvalidPreview(headers: Headers): void { + headers.set("cloudflare-cdn-cache-control", "no-store"); + headers.set("x-cloudhal-preview-error", "invalid"); } /** What choosing the locale needs of a site (`localeRequest` reads nothing else). */ @@ -98,7 +124,9 @@ export function statusInput( ): StatusPageInput { const site = sites.published ?? sites.live; const locale = site ? statusLocale(site, request.host, request.pathname) : "en"; - if (state === "unavailable" || state === "error" || !site) return { locale, now }; + if (state === "unavailable" || !site) return { locale, now }; + // `error`: the name of a site this request already read, nothing else (§1.6). + if (state === "error") return { locale, now, site: { name: site.name } }; const showsBrand = sites.published !== undefined && state !== "not-published"; return { locale, diff --git a/packages/astro-renderer/test/status-entry.test.ts b/packages/astro-renderer/test/status-entry.test.ts new file mode 100644 index 00000000..001d0e98 --- /dev/null +++ b/packages/astro-renderer/test/status-entry.test.ts @@ -0,0 +1,196 @@ +/** + * RFC-0043 §1.2, §1.6, §1.8, §2.5 and decision 12 on the Site Worker entry's status step (#167): + * a 5xx the renderer or theme answers on purpose is never turned into the `error` page; a store + * that cannot be read before rendering answers `unavailable`; the `error` page uses the site's + * locale and name once the request has read it; an invalid preview token on a machine path + * never serves the published content, and no answer says why the token failed. + */ +import type { Site } from "@cloudhal/core-contracts"; +import { describe, expect, it, vi } from "vitest"; +import type { SiteStateResult } from "../src/runtime-workerd.ts"; +import { errorPage, type StatusState, statusStep, withErrorPage } from "../src/status-entry.ts"; +import { statusPage, statusPlain } from "../src/status-page.ts"; +import { markInvalidPreview, statusPathKind } from "../src/status-serve.ts"; +import { en } from "../src/status-strings/en.ts"; +import { messages as tr } from "../src/status-strings/tr.ts"; + +const NOW = Date.parse("2026-11-08T10:00:00Z"); +const site = { + id: "site_status01", + name: "Kahve ", + locales: ["tr", "en"], + defaultLocale: "tr", + theme: { name: "blog", config: { brand: "#7c3aed" } }, + brand: { logo: { src: "/media/ab12/logo.png", alt: "" } }, +} as unknown as Site; + +const request = (path: string, init?: RequestInit) => + new Request(`https://kahve.example${path}`, { headers: { "cf-ray": "8f00ray" }, ...init }); + +const html = (status: number, headers: Record = {}) => + new Response("

theme

", { + status, + headers: { "content-type": "text/html; charset=utf-8", ...headers }, + }); + +describe("withErrorPage (§1.8)", () => { + it("passes the starter's not-published 503 through, with no error log", async () => { + const log = vi.fn(); + const starter = statusPage("not-published", { locale: "en", now: NOW, site: { name: "S" } }); + const out = withErrorPage(starter, request("/", { method: "POST" }), { log }); + expect(out).toBe(starter); + expect(out.status).toBe(503); + expect(out.headers.get("x-cloudhal-state")).toBe("not-published"); + expect(out.headers.get("retry-after")).toBe("3600"); + expect(await out.text()).toContain(en.notPublished.title); + expect(log).not.toHaveBeenCalled(); + }); + + it("passes a theme's own 5xx and its themed 500 through", () => { + const log = vi.fn(); + for (const res of [ + html(503), + html(502), + html(500, { "x-cloudhal-error-page": "theme" }), + new Response("down", { status: 500, headers: { "content-type": "text/plain" } }), + ]) + expect(withErrorPage(res, request("/"), { log })).toBe(res); + expect(log).not.toHaveBeenCalled(); + }); + + it("turns Astro's own 500 page into the error page, logged with the reference", async () => { + const log = vi.fn(); + const out = withErrorPage(html(500), request("/about"), { log }); + expect(out.status).toBe(500); + expect(out.headers.get("x-cloudhal-state")).toBe("error"); + expect(out.headers.get("cache-control")).toBe("no-store"); + expect(await out.text()).toContain("8f00ray"); + expect(log).toHaveBeenCalledOnce(); + expect(log.mock.calls[0]?.[0]).toContain("reference 8f00ray"); + }); +}); + +describe("errorPage (§1.4, §1.6)", () => { + it("uses the site's locale and name when the request already read the site", async () => { + const out = errorPage(request("/"), new Error("boom"), { site, log: () => {}, now: NOW }); + const body = await out.text(); + expect(out.status).toBe(500); + expect(body).toContain('lang="tr"'); + expect(body).toContain(tr.error.title); + expect(body).toContain("Kahve <Evi>"); + // name only: no logo, no theme tokens, no error text + expect(body).not.toContain("/media/ab12/logo.png"); + expect(body).not.toContain("#7c3aed"); + expect(body).not.toContain("boom"); + }); + + it("follows the request's path prefix to another site locale", async () => { + const body = await errorPage(request("/en/about"), new Error("x"), { + site, + log: () => {}, + }).text(); + expect(body).toContain(en.error.title); + expect(body).toContain("Kahve <Evi>"); + }); + + it("is English and nameless before the site is known", async () => { + const body = await errorPage(request("/"), new Error("x"), { log: () => {} }).text(); + expect(body).toContain('lang="en"'); + expect(body).toContain(en.error.title); + expect(body).not.toContain("Kahve"); + }); +}); + +describe("statusStep (§2.5)", () => { + const drawn = (state: StatusState) => + Promise.resolve(new Response(`page:${state.kind}`, { status: 299 })); + const step = (path: string, state: () => Promise, init?: RequestInit) => + statusStep(request(path, init), { state, page: drawn, log: () => {}, now: () => NOW }); + + it("a store read failure is unavailable: 503 with Retry-After 300, logged", async () => { + const log = vi.fn(); + const page = vi.fn((state: StatusState) => + Promise.resolve(statusPage(state.kind, { locale: "en", now: NOW })), + ); + const failing = () => Promise.reject(new Error("D1_ERROR: network lost")); + const out = await statusStep(request("/"), { state: failing, page, log }); + expect(page).toHaveBeenCalledWith({ kind: "unavailable" }); + expect(out?.status).toBe(503); + expect(out?.headers.get("retry-after")).toBe("300"); + expect(out?.headers.get("x-cloudhal-state")).toBe("unavailable"); + expect(log).toHaveBeenCalledOnce(); + // machine paths say the same in plain text + const feed = await step("/rss.xml", failing); + expect(feed?.status).toBe(503); + expect(feed?.headers.get("retry-after")).toBe("300"); + expect(feed?.headers.get("content-type")).toContain("text/plain"); + expect(await feed?.text()).not.toContain("D1_ERROR"); + }); + + it("an invalid preview token on a machine path answers 410, never the published content", async () => { + const expired = () => + Promise.resolve({ kind: "preview-expired", site, published: site }); + for (const path of ["/rss.xml", "/sitemap.xml", "/llms.txt", "/feed.atom"]) { + const out = await step(`${path}?cloudhal-preview=forged`, expired); + expect(out?.status, path).toBe(410); + expect(out?.headers.get("x-cloudhal-state")).toBe("preview-expired"); + expect(out?.headers.get("x-robots-tag")).toBe("noindex"); + expect(out?.headers.get("cache-control")).toBe("no-store"); + expect(out?.headers.get("content-type")).toContain("text/plain"); + expect(out?.headers.has("x-cloudhal-preview-error")).toBe(false); + // in the site's language, and with nothing about why the token failed + expect(await out?.text()).toBe(`${tr.previewExpired.title}. ${tr.previewExpired.body}\n`); + } + const json = await step("/search.json?q=a&cloudhal-preview=forged", expired); + expect(json?.status).toBe(410); + expect(await json?.json()).toEqual({ + error: "preview-expired", + message: `${tr.previewExpired.title}. ${tr.previewExpired.body}`, + }); + const head = await step("/rss.xml?cloudhal-preview=x", expired, { method: "HEAD" }); + expect(head?.status).toBe(410); + expect(await head?.text()).toBe(""); + }); + + it("page paths get the drawn page; serve, other methods and own answers pass", async () => { + const expired = () => Promise.resolve({ kind: "preview-expired", site }); + expect(await (await step("/about?cloudhal-preview=x", expired))?.text()).toBe( + "page:preview-expired", + ); + const serve = () => Promise.resolve({ kind: "serve" }); + expect(await step("/rss.xml", serve)).toBeUndefined(); + expect(await step("/", serve)).toBeUndefined(); + // before the first publish a machine path keeps the theme's own statusPlain + const unpublished = () => Promise.resolve({ kind: "not-published", site }); + expect(await step("/rss.xml", unpublished)).toBeUndefined(); + const state = vi.fn(expired); + expect(await step("/", state, { method: "POST" })).toBeUndefined(); + for (const own of ["/robots.txt", "/media/0123456789abcdef/a.png", "/.well-known/security.txt"]) + expect(await step(`${own}?cloudhal-preview=x`, state)).toBeUndefined(); + expect(state).not.toHaveBeenCalled(); + }); + + it("classifies paths as the state step reads them", () => { + expect(statusPathKind("/")).toBe("page"); + expect(statusPathKind("/posts/hello")).toBe("page"); + expect(statusPathKind("/rss.xml")).toBe("machine"); + expect(statusPathKind("/manifest.webmanifest")).toBe("machine"); + expect(statusPathKind("/robots.txt")).toBeUndefined(); + expect(statusPathKind("/_astro/a.css")).toBeUndefined(); + expect(statusPathKind("/__cloudhal/health")).toBeUndefined(); + }); +}); + +describe("markInvalidPreview (decision 12)", () => { + it("keeps the response out of the edge cache and never says why the token failed", () => { + const headers = new Headers({ "cloudflare-cdn-cache-control": "public, max-age=3600" }); + markInvalidPreview(headers); + expect(headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); + expect(headers.get("x-cloudhal-preview-error")).toBe("invalid"); + }); + + it("statusPlain bodies are the same for every failure", async () => { + const a = await statusPlain("preview-expired", { locale: "en", now: NOW }, "text").text(); + expect(a).not.toMatch(/signature|expired token|another site|not enabled/i); + }); +}); diff --git a/packages/cli/test/cloudflare-status.test.ts b/packages/cli/test/cloudflare-status.test.ts index 4e724495..1204f954 100644 --- a/packages/cli/test/cloudflare-status.test.ts +++ b/packages/cli/test/cloudflare-status.test.ts @@ -3,8 +3,9 @@ * Worker answers page paths with the renderer's status page (503, `Retry-After`, `no-store`, * `noindex`, its own CSP) before Astro runs, restyled by the project's `statusPage` theme hook; * machine paths get the starter's plain answer; `/__cloudhal/health` says `not-published`; a - * preview token that does not verify answers 410 instead of the published page. A publish turns - * all of it into the real site. + * preview token that does not verify answers 410 instead of the published page, on feeds and + * the sitemap too; a request that reaches Astro keeps the starter's own 503. A publish turns all + * of it into the real site. */ import { appendFile, mkdtemp, rm, symlink } from "node:fs/promises"; import os from "node:os"; @@ -111,6 +112,10 @@ describe("Site Worker status pages on workerd (RFC-0043 §1)", () => { const head = await get("/", { method: "HEAD" }); expect(head.status).toBe(503); expect(await head.text()).toBe(""); + // #167: a request that reaches Astro keeps the starter's own 503, never the error page + const post = await get("/", { method: "POST" }); + expect(post.status).toBe(503); + expect(post.headers.get("x-cloudhal-state")).toBe("not-published"); }); it("machine paths and robots.txt keep their own answers", async () => { @@ -168,5 +173,13 @@ describe("Site Worker status pages on workerd (RFC-0043 §1)", () => { const bad = await get("/about?cloudhal-preview=expired"); expect(bad.status).toBe(410); expect(await bad.text()).toContain("Edge Status <Site>"); + // nor on a feed or the sitemap (decision 12), and no answer says why the token failed + for (const p of ["/rss.xml", "/sitemap.xml"]) { + const feed = await get(`${p}?cloudhal-preview=expired`); + expect(feed.status, p).toBe(410); + expect(feed.headers.get("content-type")).toContain("text/plain"); + expect(feed.headers.get("x-cloudhal-state")).toBe("preview-expired"); + expect(await feed.text()).not.toMatch(/ Date: Sun, 8 Nov 2026 19:05:26 +0300 Subject: [PATCH 2/3] fix(astro-renderer): theme crashes, published site name, host lookup failure (#167 review) - withErrorPage also rewrites Astro's empty 500 fallback (no body, no content type); a 500 with an explicit non-HTML type still passes. - The blog starter's catch-all rethrows when Astro renders it as the error route, so a page that throws gets the error page with a reference and a log line instead of the catch-all's 404 for /500. - The error page names the published snapshot's site node (SiteResolver.publicSite), the live row only before the first publish. - A failed multi-site hostname lookup is a store failure: health answers 503 { ok: false }, page and machine paths the English unavailable answer (503, Retry-After: 300). health moved to status-entry.ts. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/status-page-fixes-167.md | 2 +- .../rfcs/0043-status-pages-and-maintenance.md | 13 ++- .../astro-renderer/src/cloudflare-entry.ts | 64 ++++++------- packages/astro-renderer/src/resolver.ts | 13 ++- .../astro-renderer/src/runtime-workerd.ts | 5 +- packages/astro-renderer/src/status-entry.ts | 80 ++++++++++++++-- packages/astro-renderer/test/resolver.test.ts | 16 ++++ .../astro-renderer/test/status-entry.test.ts | 96 ++++++++++++++++++- packages/cli/test/cloudflare-status.test.ts | 27 +++++- starters/blog/src/pages/[...path].astro | 5 + 10 files changed, 269 insertions(+), 52 deletions(-) diff --git a/.changeset/status-page-fixes-167.md b/.changeset/status-page-fixes-167.md index 7e976f51..6148d0e9 100644 --- a/.changeset/status-page-fixes-167.md +++ b/.changeset/status-page-fixes-167.md @@ -2,4 +2,4 @@ "@cloudhal/astro-renderer": patch --- -Status page fixes after RFC-0043 PR 1 (#167). The Site Worker no longer turns a 5xx the renderer or the theme answers on purpose into the `error` page: the starter's own `not-published` 503 for a request that reaches Astro (a `POST`, say) passes unchanged and logs nothing; only Astro's own 500 page and thrown errors become the `error` page. A store that cannot be read before rendering answers 503 `unavailable` with `Retry-After: 300`, as `/__cloudhal/health` reports it, instead of a 500. The `error` page uses the site's language and name when the request has already read the site. An invalid preview token on a feed, sitemap, `llms.txt` or JSON path answers 410 instead of the published content, and no response says why a token failed any more (`x-cloudhal-preview-error` is now always `invalid`). +Status page fixes after RFC-0043 PR 1 (#167). The Site Worker no longer turns a 5xx the renderer or the theme answers on purpose into the `error` page: the starter's own `not-published` 503 for a request that reaches Astro (a `POST`, say) passes unchanged and logs nothing; only Astro's own 500 page (HTML, or its empty fallback when no 500 page renders) and thrown errors become the `error` page. A theme page that throws now gets that page with a reference and a `cloudhal: request failed` log line: the blog starter's catch-all route rethrows when Astro renders it as the error route, instead of answering its own 404 for `/500` (theme copies made from an earlier starter can add the same two lines at the top of `[...path].astro`). A store that cannot be read before rendering answers 503 `unavailable` with `Retry-After: 300`, as `/__cloudhal/health` reports it, instead of a 500; so does a multi-site Worker whose hostname map cannot be read (health `503 { ok: false }`, pages the English `unavailable` page). The `error` page uses the site's language and name when the request has already read the site, taken from the published snapshot (an unpublished rename never shows). An invalid preview token on a feed, sitemap, `llms.txt` or JSON path answers 410 instead of the published content, and no response says why a token failed any more (`x-cloudhal-preview-error` is now always `invalid`). diff --git a/docs/rfcs/0043-status-pages-and-maintenance.md b/docs/rfcs/0043-status-pages-and-maintenance.md index 2bc6ac4f..49f8c5b8 100644 --- a/docs/rfcs/0043-status-pages-and-maintenance.md +++ b/docs/rfcs/0043-status-pages-and-maintenance.md @@ -349,13 +349,18 @@ export interface StatusContext extends StatusPageInput { - **Site Worker (`cloudflare-entry.ts`):** a new step between host dispatch and `serveSite` decides the state (§2.5 has the order). The whole `route()` is wrapped in `try/catch` → - `error`. Astro's own 500 HTML page (a render that threw) is replaced by the `error` page unless - the theme marked it as its own (`x-cloudhal-error-page: theme`, set by a theme's `500.astro`; - no starter has one today). A response that carries `x-cloudhal-state` (the starter's + `error`. Astro's own 500 (a render that threw: HTML, or its empty fallback with no body and no + content type when no `500.astro` renders) is replaced by the `error` page unless the theme + marked it as its own (`x-cloudhal-error-page: theme`, set by a theme's `500.astro`; no starter + has one today). The blog starter's catch-all route rethrows when Astro renders it as the error + route (`Astro.props.error`), so a crash never becomes its 404 for `/500`. A response that carries `x-cloudhal-state` (the starter's `not-published` 503 for a request that reaches Astro, e.g. a `POST`) and any other 5xx the theme answers on purpose pass unchanged and log nothing (#167). A store that cannot be read in the state step is `unavailable` (503, `Retry-After: 300`), as health reports it. The `error` page - uses the locale and name of a site the request has already read (§1.4, §1.6), else English. Cloudflare-level failures (CPU limit, out of memory, exception 1101) cannot be + uses the locale and name of a site the request has already read (§1.4, §1.6): the published + snapshot's site node once published, the live row before, else English. On a multi-site + Worker a hostname map that cannot be read is a store failure too: health answers + `503 { ok: false }`, page and machine paths the English `unavailable` answer. Cloudflare-level failures (CPU limit, out of memory, exception 1101) cannot be caught by the Worker and keep Cloudflare's page. The resolver's verdict (pointer, preview token) is memoised per request, so the entry's state step and Astro's `getSource` share one pointer read. diff --git a/packages/astro-renderer/src/cloudflare-entry.ts b/packages/astro-renderer/src/cloudflare-entry.ts index fd20c785..fb7a66bc 100644 --- a/packages/astro-renderer/src/cloudflare-entry.ts +++ b/packages/astro-renderer/src/cloudflare-entry.ts @@ -75,8 +75,16 @@ import { EMBED_HEADER, withEmbedFrames } from "./embed-client.ts"; import { hostRedirect, type RedirectEnv } from "./host-redirect.ts"; import { PREVIEW_PARAM } from "./resolver.ts"; import { SearchLimiter, searchCost } from "./search-limit.ts"; -import { SiteDispatch, unknownHost, withNoindex } from "./site-dispatch.ts"; -import { errorPage, type StatusState, statusStep, withErrorPage } from "./status-entry.ts"; +import { type SiteChoice, SiteDispatch, unknownHost, withNoindex } from "./site-dispatch.ts"; +import { + errorPage, + HEALTH_PATH, + health, + hostLookupFailed, + type StatusState, + statusStep, + withErrorPage, +} from "./status-entry.ts"; import { renderStatus, themeStatusHook } from "./status-hook.ts"; import type { SiteState } from "./status-page.ts"; import { statusInput } from "./status-serve.ts"; @@ -162,34 +170,6 @@ let dispatch: SiteDispatch | undefined; /** RFC-0043 §1.7: the theme's `statusPage` hook, loaded the first time a status page is drawn. */ const statusHook = themeStatusHook(loadTheme, (m, e) => console.error(m, e)); -/** - * RFC-0043 §1.9: `{ ok, state }`, the admin's `/api/health` shape plus the site's state, so one - * monitor rule reads both. Public, never cached, never indexed; reads only the memoised pointer. - */ -async function health(request: Request): Promise { - const headers = { - "cache-control": "no-store", - "cloudflare-cdn-cache-control": "no-store", - "x-robots-tag": "noindex", - }; - if (request.method !== "GET" && request.method !== "HEAD") - return Response.json( - { error: "method_not_allowed" }, - { status: 405, headers: { ...headers, allow: "GET" } }, - ); - try { - const state = await siteState(request); - if (state.kind === "unavailable") return Response.json({ ok: false }, { status: 503, headers }); - return Response.json( - { ok: true, state: state.kind === "not-published" ? "not-published" : "published" }, - { headers }, - ); - } catch (error) { - console.error("cloudhal: health check could not read the store", error); - return Response.json({ ok: false }, { status: 503, headers }); - } -} - /** One per isolate: which site a request is for (RFC-0034 §4). */ function siteDispatch(env: Env, siteIdFromBuild: string | undefined): SiteDispatch { dispatch ??= new SiteDispatch({ @@ -618,6 +598,20 @@ async function statusResponse( return response; } +/** A status page before any site is known (the hostname map failed): English, no site data. */ +async function siteless(request: Request, state: StatusState): Promise { + return renderStatus( + state.kind, + { locale: "en", now: Date.now() }, + { + hook: await statusHook(), + method: request.method, + csp: SITE_CSP, + onFailure: (m, e) => console.error(m, e), + }, + ); +} + export function createSiteWorker(options: { siteId?: string } = {}) { const searchLimiter = new SearchLimiter(); async function route(request: Request, env: Env, ctx: ExecutionContext): Promise { @@ -628,7 +622,13 @@ export function createSiteWorker(options: { siteId?: string } = {}) { if (moved) return moved; // RFC-0034 §4: the hostname picks the site on a multi-site Worker; everything the request // runs (Astro's render included) then sees that site. - const choice = await siteDispatch(env, options.siteId).choose(request); + let choice: SiteChoice; + try { + choice = await siteDispatch(env, options.siteId).choose(request); + } catch (error) { + // A store that cannot be read is `unavailable` (503), as health says, never `error`. + return hostLookupFailed(request, error, { page: (state) => siteless(request, state) }); + } if (choice.kind === "unknown") return unknownHost(); // Without `CLOUDHAL_SITE_ID` a single-site Worker serves the site it was built for, as // Astro's `getSource` always did; `unavailable` is for a site row that is gone (§2.5). @@ -636,7 +636,7 @@ export function createSiteWorker(options: { siteId?: string } = {}) { const call = { request, env, ctx }; const run = async () => { try { - if (url.pathname === "/__cloudhal/health") return await health(request); + if (url.pathname === HEALTH_PATH) return await health(request, () => siteState(request)); const status = await statusStep(request, { state: () => siteState(request), page: (state) => statusResponse(call, state, siteId), diff --git a/packages/astro-renderer/src/resolver.ts b/packages/astro-renderer/src/resolver.ts index e8a93820..b04e1262 100644 --- a/packages/astro-renderer/src/resolver.ts +++ b/packages/astro-renderer/src/resolver.ts @@ -115,9 +115,16 @@ export class SiteResolver { return this.#pointer.site; } - /** The site row the last `site()` read, without reading again (`undefined` before one). */ - loadedSite(): Site | undefined { - return this.#pointer?.site; + /** + * RFC-0043 §1.6: the site node a public page may name for `resolution`, never a new store + * read: the published snapshot's (already loaded, or loaded for the render anyway) for a + * published site, so an unpublished rename or locale never shows; the live row `resolve` just + * read before the first publish; nothing for a preview. `undefined` when it cannot be read. + */ + async publicSite(resolution: Resolution): Promise { + if (resolution.kind === "preview") return undefined; + if (resolution.kind === "unpublished") return this.#pointer?.site; + return resolution.source.site().catch(() => undefined); } snapshot(id: string): Promise { diff --git a/packages/astro-renderer/src/runtime-workerd.ts b/packages/astro-renderer/src/runtime-workerd.ts index 0d1ed943..c2fe0135 100644 --- a/packages/astro-renderer/src/runtime-workerd.ts +++ b/packages/astro-renderer/src/runtime-workerd.ts @@ -164,9 +164,8 @@ function resolveRequest(r: SiteResolver, request: Request): Promise let pending = resolutions.get(request); if (!pending) { const context = requestSite.getStore(); - pending = r.resolve(request).then((resolution) => { - // A preview resolves without reading the site row; anything else just read it. - const site = resolution.kind === "preview" ? undefined : r.loadedSite(); + pending = r.resolve(request).then(async (resolution) => { + const site = context ? await r.publicSite(resolution) : undefined; if (context && site?.id === context.siteId) context.site = site; return resolution; }); diff --git a/packages/astro-renderer/src/status-entry.ts b/packages/astro-renderer/src/status-entry.ts index 1f5606d8..e3add99a 100644 --- a/packages/astro-renderer/src/status-entry.ts +++ b/packages/astro-renderer/src/status-entry.ts @@ -1,5 +1,6 @@ /** - * The Site Worker entry's status step and `error` page (RFC-0043 §1.2, §1.8, §2.5), apart from + * The Site Worker entry's status step, `error` page, health endpoint and the answer when the + * hostname cannot be mapped to a site (RFC-0043 §1.2, §1.8, §1.9, §2.5), apart from * `cloudflare-entry.ts` so unit tests run them: no bindings, no Astro, no `cloudflare:` imports. * Worker-safe. */ @@ -58,19 +59,22 @@ export function errorPage( } /** - * §1.8: Astro's own 500 HTML (a render that threw) becomes the `error` page. What the renderer - * or the theme answered on purpose passes unchanged and logs nothing: a status page - * (`x-cloudhal-state`, e.g. the starter's `not-published` 503 for a request that reaches Astro), - * a theme's `500.astro` (`x-cloudhal-error-page: theme`) and any other 5xx. + * §1.8: Astro's own 500 (a render that threw) becomes the `error` page — HTML, or Astro's empty + * fallback (`new Response(null, { status: 500 })` when no `500.astro` renders: the starter's + * catch-all route matches `/500` and throws again). What the renderer or the theme answered on + * purpose passes unchanged and logs nothing: a status page (`x-cloudhal-state`, e.g. the + * starter's `not-published` 503 for a request that reaches Astro), a theme's `500.astro` + * (`x-cloudhal-error-page: theme`), a 500 with an explicit non-HTML type, and any other 5xx. */ export function withErrorPage( response: Response, request: Request, options: ErrorPageOptions = {}, ): Response { + const type = response.headers.get("content-type") ?? ""; if ( response.status !== 500 || - !(response.headers.get("content-type") ?? "").includes("text/html") || + (type !== "" && !type.includes("text/html")) || response.headers.has("x-cloudhal-state") || response.headers.get("x-cloudhal-error-page") === "theme" ) @@ -123,3 +127,67 @@ export async function statusStep( const as = url.pathname.toLowerCase().endsWith(".json") ? "json" : "text"; return statusPlain(state.kind, input, as, { method: request.method }); } + +export const HEALTH_PATH = "/__cloudhal/health"; + +const HEALTH_HEADERS = { + "cache-control": "no-store", + "cloudflare-cdn-cache-control": "no-store", + "x-robots-tag": "noindex", +} as const; + +/** §1.9: the store cannot be read, so the site is down for monitors. */ +function healthDown(): Response { + return Response.json({ ok: false }, { status: 503, headers: HEALTH_HEADERS }); +} + +/** + * §1.9: `{ ok, state }`, the admin's `/api/health` shape plus the site's state, so one monitor + * rule reads both. Public, never cached, never indexed; reads only the memoised pointer. + */ +export async function health( + request: Request, + state: () => Promise, + log: Log = console.error, +): Promise { + if (request.method !== "GET" && request.method !== "HEAD") + return Response.json( + { error: "method_not_allowed" }, + { status: 405, headers: { ...HEALTH_HEADERS, allow: "GET" } }, + ); + try { + const result = await state(); + if (result.kind === "unavailable") return healthDown(); + return Response.json( + { ok: true, state: result.kind === "not-published" ? "not-published" : "published" }, + { headers: HEALTH_HEADERS }, + ); + } catch (error) { + log("cloudhal: health check could not read the store", error); + return healthDown(); + } +} + +/** + * RFC-0034 §4 with §1.2: on a multi-site Worker the hostname map could not be read (the store + * failed past the map's TTL), so no site is known. Treated as a store failure, never as `error`: + * health answers `503 { ok: false }`; `GET`/`HEAD` page paths the `unavailable` page and machine + * paths its plain answer (503, `Retry-After: 300`, English, no site name); anything else a + * plain 503. + */ +export async function hostLookupFailed( + request: Request, + error: unknown, + options: Pick, +): Promise { + (options.log ?? console.error)("cloudhal: the site's hostnames could not be read", error); + if (new URL(request.url).pathname === HEALTH_PATH) return healthDown(); + const answer = await statusStep(request, { + state: () => Promise.reject(error), + page: options.page, + log: () => {}, + now: options.now, + }); + const input = { locale: "en", now: (options.now ?? Date.now)() }; + return answer ?? statusPlain("unavailable", input, "text", { method: request.method }); +} diff --git a/packages/astro-renderer/test/resolver.test.ts b/packages/astro-renderer/test/resolver.test.ts index 37bf6e54..9c9c7a89 100644 --- a/packages/astro-renderer/test/resolver.test.ts +++ b/packages/astro-renderer/test/resolver.test.ts @@ -133,6 +133,22 @@ describe("SiteResolver", () => { expect((r as { previewError?: string }).previewError).toMatch(/not enabled/); }); + it("publicSite names the published site node, never an unpublished rename (RFC-0043 §1.6)", async () => { + const before = await resolver.resolve(req("/")); + expect((await resolver.publicSite(before))?.name).toBe("Resolver"); + await publish("v1"); + const live = await store.getSite(siteId); + await store.updateSite({ ...(live as NonNullable), name: "Renamed draft" }); + clock += 1001; + const published = await resolver.resolve(req("/")); + expect(published.kind).toBe("published"); + expect((await resolver.publicSite(published))?.name).toBe("Resolver"); + const token = await mintPreviewToken(SECRET, { site: siteId, target: "live", now: clock }); + const preview = await resolver.resolve(req(`/?cloudhal-preview=${token}`)); + expect(preview.kind).toBe("preview"); + expect(await resolver.publicSite(preview)).toBeUndefined(); + }); + it("caches immutable snapshot sources with an LRU bound", async () => { const small = new SiteResolver({ store, siteId, snapshotCacheSize: 2 }); const ids: string[] = []; diff --git a/packages/astro-renderer/test/status-entry.test.ts b/packages/astro-renderer/test/status-entry.test.ts index 001d0e98..980ac7b4 100644 --- a/packages/astro-renderer/test/status-entry.test.ts +++ b/packages/astro-renderer/test/status-entry.test.ts @@ -6,9 +6,18 @@ * never serves the published content, and no answer says why the token failed. */ import type { Site } from "@cloudhal/core-contracts"; +import type { SqlDriver } from "@cloudhal/storage"; import { describe, expect, it, vi } from "vitest"; import type { SiteStateResult } from "../src/runtime-workerd.ts"; -import { errorPage, type StatusState, statusStep, withErrorPage } from "../src/status-entry.ts"; +import { SiteDispatch } from "../src/site-dispatch.ts"; +import { + errorPage, + health, + hostLookupFailed, + type StatusState, + statusStep, + withErrorPage, +} from "../src/status-entry.ts"; import { statusPage, statusPlain } from "../src/status-page.ts"; import { markInvalidPreview, statusPathKind } from "../src/status-serve.ts"; import { en } from "../src/status-strings/en.ts"; @@ -58,6 +67,21 @@ describe("withErrorPage (§1.8)", () => { expect(log).not.toHaveBeenCalled(); }); + it("turns Astro's empty fallback 500 (no body, no type) into the error page", async () => { + // Astro 7.4 `renderDefaultError`: the catch-all route matches `/500` and throws again + const log = vi.fn(); + const out = withErrorPage(new Response(null, { status: 500 }), request("/broken"), { log }); + expect(out.status).toBe(500); + expect(out.headers.get("x-cloudhal-state")).toBe("error"); + expect(out.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(out.headers.get("content-security-policy")).toContain("default-src 'none'"); + expect(await out.text()).toContain("8f00ray"); + expect(log).toHaveBeenCalledOnce(); + // an explicit non-HTML 500 is the theme's own answer + const json = Response.json({ error: "x" }, { status: 500 }); + expect(withErrorPage(json, request("/a.json"), { log })).toBe(json); + }); + it("turns Astro's own 500 page into the error page, logged with the reference", async () => { const log = vi.fn(); const out = withErrorPage(html(500), request("/about"), { log }); @@ -194,3 +218,73 @@ describe("markInvalidPreview (decision 12)", () => { expect(a).not.toMatch(/signature|expired token|another site|not enabled/i); }); }); + +describe("health and a failed hostname lookup (§1.9, RFC-0034 §4)", () => { + /** A multi-site dispatch whose store is down past the host map's TTL. */ + const downDispatch = () => + new SiteDispatch({ + siteId: undefined, + siteIds: "site_a,site_b", + // every query rejects, as D1 does during an outage + sql: () => + new Proxy({} as SqlDriver, { + get: () => () => Promise.reject(new Error("D1_ERROR: storage down")), + }), + }); + const failed = async (path: string, init?: RequestInit) => { + const req = new Request(`https://a.example${path}`, init); + const error = await downDispatch() + .choose(req) + .then( + () => undefined, + (e: unknown) => e, + ); + expect((error as Error).message).toBe("D1_ERROR: storage down"); + const page = vi.fn((state: StatusState) => + Promise.resolve(statusPage(state.kind, { locale: "en", now: NOW })), + ); + const log = vi.fn(); + const res = await hostLookupFailed(req, error, { page, log, now: () => NOW }); + expect(log).toHaveBeenCalledOnce(); + return { res, page }; + }; + + it("health answers 503 { ok: false }, never cached", async () => { + const { res, page } = await failed("/__cloudhal/health"); + expect(res.status).toBe(503); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(res.headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); + expect(await res.json()).toEqual({ ok: false }); + expect(page).not.toHaveBeenCalled(); + }); + + it("pages and machine paths answer unavailable: 503, Retry-After 300, English, no name", async () => { + const { res, page } = await failed("/about"); + expect(page).toHaveBeenCalledWith({ kind: "unavailable" }); + expect(res.status).toBe(503); + expect(res.headers.get("retry-after")).toBe("300"); + expect(res.headers.get("x-cloudhal-state")).toBe("unavailable"); + expect(await res.text()).toContain(en.unavailable.title); + const feed = (await failed("/rss.xml")).res; + expect(feed.status).toBe(503); + expect(feed.headers.get("retry-after")).toBe("300"); + expect(await feed.text()).toBe(`${en.unavailable.title}. ${en.unavailable.body}\n`); + const post = (await failed("/", { method: "POST" })).res; + expect(post.status).toBe(503); + expect(post.headers.get("x-cloudhal-state")).toBe("unavailable"); + }); + + it("health reports the state, and a store failure as 503", async () => { + const ok = await health(request("/__cloudhal/health"), () => + Promise.resolve({ kind: "not-published", site }), + ); + expect(await ok.json()).toEqual({ ok: true, state: "not-published" }); + const down = await health( + request("/__cloudhal/health"), + () => Promise.reject(new Error("x")), + () => {}, + ); + expect(down.status).toBe(503); + expect(await down.json()).toEqual({ ok: false }); + }); +}); diff --git a/packages/cli/test/cloudflare-status.test.ts b/packages/cli/test/cloudflare-status.test.ts index 1204f954..92123e14 100644 --- a/packages/cli/test/cloudflare-status.test.ts +++ b/packages/cli/test/cloudflare-status.test.ts @@ -7,7 +7,7 @@ * the sitemap too; a request that reaches Astro keeps the starter's own 503. A publish turns all * of it into the real site. */ -import { appendFile, mkdtemp, rm, symlink } from "node:fs/promises"; +import { appendFile, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,7 +17,7 @@ import { openLocalStore } from "@cloudhal/core-ts/local"; import { migrate, migrations } from "@cloudhal/storage"; import { D1Driver } from "@cloudhal/storage/d1"; import { R2BlobStore } from "@cloudhal/storage/r2"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { createTestHarness } from "wrangler"; import { buildSiteWorker } from "../src/cloudflare/build.ts"; import { siteConfigPath, siteWorkerConfig, writeWranglerConfig } from "../src/cloudflare/config.ts"; @@ -49,6 +49,11 @@ describe("Site Worker status pages on workerd (RFC-0043 §1)", () => { siteId = (await init({ dir: projectDir, name: "Edge Status " })).siteId; await symlink(STARTER_NODE_MODULES, path.join(projectDir, "node_modules"), "dir"); await appendFile(path.join(projectDir, "src/theme.ts"), THEME_HOOK); + // #167: a theme page whose frontmatter throws (Astro then has no 500 page to render) + await writeFile( + path.join(projectDir, "src/pages/broken.astro"), + '---\nthrow new Error("theme crash for #167");\n---\n

never

\n', + ); await writeWranglerConfig( siteConfigPath(projectDir), siteWorkerConfig({ @@ -182,4 +187,22 @@ describe("Site Worker status pages on workerd (RFC-0043 §1)", () => { expect(await feed.text()).not.toMatch(/ { + harness.clearLogs(); + const res = await get("/broken"); + const html = await res.text(); + expect(res.status, html.slice(0, 300)).toBe(500); + expect(res.headers.get("x-cloudhal-state")).toBe("error"); + expect(res.headers.get("content-type")).toContain("text/html"); + expect(res.headers.get("cache-control")).toBe("no-store"); + const reference = /Reference: ([0-9a-f]{16})<\/code>/.exec(html)?.[1]; + expect(reference, html).toBeDefined(); + expect(html).not.toContain("theme crash"); + // the Worker logged the failure with the same reference (Workers Logs) + await vi.waitFor(() => + expect(harness.getLogs().map((l) => l.message)).toContainEqual( + expect.stringContaining(`cloudhal: request failed (reference ${reference})`), + ), + ); + }); }); diff --git a/starters/blog/src/pages/[...path].astro b/starters/blog/src/pages/[...path].astro index e08aa75d..bcb1ae87 100644 --- a/starters/blog/src/pages/[...path].astro +++ b/starters/blog/src/pages/[...path].astro @@ -114,6 +114,11 @@ export async function getStaticPaths() { })); } +// RFC-0043 §1.8 (#167): after a page threw, Astro renders this catch-all as its error route +// (`/500`). Throw again: Astro then answers an empty 500 that the Site Worker turns into the +// `error` page with a reference, instead of this route's 404 for `/500`. +const renderError = (Astro.props as { error?: unknown }).error; +if (renderError !== undefined) throw renderError; const source = await getSource(config, Astro); // RFC-0043 §1.8: on Cloudflare and in dev the renderer answers before this; the fallback is its page. if (!source) return statusPage("not-published", { locale: "en", now: Date.now() }); From 0c1946f70b1e12836208fee936ab62989cc11785 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:17:32 +0300 Subject: [PATCH 3/3] fix(astro-renderer): error-page site off the request path, wiring tests (#167 review) - resolveRequest no longer awaits publicSite: the error page's site is filled in the background (a failing render has already awaited the same snapshot load), and not at all for /__cloudhal/health, which reads only the pointer. - Tests: publicSite on an unreadable snapshot returns undefined; on workerd, a multi-site Worker whose site_hosts cannot be read answers health 503 { ok: false } and the English unavailable page and feed (fails with a 500 when the choose() catch is removed). - Wording: Astro renders its error route with the original path, so the starter's catch-all answered its 404 for that path. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/status-page-fixes-167.md | 2 +- .../rfcs/0043-status-pages-and-maintenance.md | 3 +- .../astro-renderer/src/runtime-workerd.ts | 20 +++++++++---- packages/astro-renderer/test/resolver.test.ts | 12 +++++++- .../cli/test/cloudflare-multisite.test.ts | 29 +++++++++++++++++++ starters/blog/src/pages/[...path].astro | 4 +-- 6 files changed, 59 insertions(+), 11 deletions(-) diff --git a/.changeset/status-page-fixes-167.md b/.changeset/status-page-fixes-167.md index 6148d0e9..26006e3b 100644 --- a/.changeset/status-page-fixes-167.md +++ b/.changeset/status-page-fixes-167.md @@ -2,4 +2,4 @@ "@cloudhal/astro-renderer": patch --- -Status page fixes after RFC-0043 PR 1 (#167). The Site Worker no longer turns a 5xx the renderer or the theme answers on purpose into the `error` page: the starter's own `not-published` 503 for a request that reaches Astro (a `POST`, say) passes unchanged and logs nothing; only Astro's own 500 page (HTML, or its empty fallback when no 500 page renders) and thrown errors become the `error` page. A theme page that throws now gets that page with a reference and a `cloudhal: request failed` log line: the blog starter's catch-all route rethrows when Astro renders it as the error route, instead of answering its own 404 for `/500` (theme copies made from an earlier starter can add the same two lines at the top of `[...path].astro`). A store that cannot be read before rendering answers 503 `unavailable` with `Retry-After: 300`, as `/__cloudhal/health` reports it, instead of a 500; so does a multi-site Worker whose hostname map cannot be read (health `503 { ok: false }`, pages the English `unavailable` page). The `error` page uses the site's language and name when the request has already read the site, taken from the published snapshot (an unpublished rename never shows). An invalid preview token on a feed, sitemap, `llms.txt` or JSON path answers 410 instead of the published content, and no response says why a token failed any more (`x-cloudhal-preview-error` is now always `invalid`). +Status page fixes after RFC-0043 PR 1 (#167). The Site Worker no longer turns a 5xx the renderer or the theme answers on purpose into the `error` page: the starter's own `not-published` 503 for a request that reaches Astro (a `POST`, say) passes unchanged and logs nothing; only Astro's own 500 page (HTML, or its empty fallback when no 500 page renders) and thrown errors become the `error` page. A theme page that throws now gets that page with a reference and a `cloudhal: request failed` log line: the blog starter's catch-all route rethrows when Astro renders it as the error route, instead of answering its own 404 for the page's path (theme copies made from an earlier starter can add the same two lines at the top of `[...path].astro`). A store that cannot be read before rendering answers 503 `unavailable` with `Retry-After: 300`, as `/__cloudhal/health` reports it, instead of a 500; so does a multi-site Worker whose hostname map cannot be read (health `503 { ok: false }`, pages the English `unavailable` page). The `error` page uses the site's language and name when the request has already read the site, taken from the published snapshot (an unpublished rename never shows). An invalid preview token on a feed, sitemap, `llms.txt` or JSON path answers 410 instead of the published content, and no response says why a token failed any more (`x-cloudhal-preview-error` is now always `invalid`). diff --git a/docs/rfcs/0043-status-pages-and-maintenance.md b/docs/rfcs/0043-status-pages-and-maintenance.md index 49f8c5b8..0c6e2a4f 100644 --- a/docs/rfcs/0043-status-pages-and-maintenance.md +++ b/docs/rfcs/0043-status-pages-and-maintenance.md @@ -353,7 +353,8 @@ export interface StatusContext extends StatusPageInput { content type when no `500.astro` renders) is replaced by the `error` page unless the theme marked it as its own (`x-cloudhal-error-page: theme`, set by a theme's `500.astro`; no starter has one today). The blog starter's catch-all route rethrows when Astro renders it as the error - route (`Astro.props.error`), so a crash never becomes its 404 for `/500`. A response that carries `x-cloudhal-state` (the starter's + route (`Astro.props.error`, rendered with the original path), so a crash never becomes its + 404 for that path. A response that carries `x-cloudhal-state` (the starter's `not-published` 503 for a request that reaches Astro, e.g. a `POST`) and any other 5xx the theme answers on purpose pass unchanged and log nothing (#167). A store that cannot be read in the state step is `unavailable` (503, `Retry-After: 300`), as health reports it. The `error` page diff --git a/packages/astro-renderer/src/runtime-workerd.ts b/packages/astro-renderer/src/runtime-workerd.ts index c2fe0135..b64caa0c 100644 --- a/packages/astro-renderer/src/runtime-workerd.ts +++ b/packages/astro-renderer/src/runtime-workerd.ts @@ -163,13 +163,21 @@ const resolutions = new WeakMap>(); function resolveRequest(r: SiteResolver, request: Request): Promise { let pending = resolutions.get(request); if (!pending) { - const context = requestSite.getStore(); - pending = r.resolve(request).then(async (resolution) => { - const site = context ? await r.publicSite(resolution) : undefined; - if (context && site?.id === context.siteId) context.site = site; - return resolution; - }); + pending = r.resolve(request); resolutions.set(request, pending); + const context = requestSite.getStore(); + // RFC-0043 §1.6: the `error` page's site, filled in the background, never awaited here (a + // published site's node comes with the snapshot load, which a render awaits anyway, so it is + // known by the time one fails). Health renders no page and reads only the pointer. + if (context && new URL(request.url).pathname !== "/__cloudhal/health") + pending + .then((resolution) => r.publicSite(resolution)) + .then( + (site) => { + if (site?.id === context.siteId) context.site = site; + }, + () => {}, + ); } return pending; } diff --git a/packages/astro-renderer/test/resolver.test.ts b/packages/astro-renderer/test/resolver.test.ts index 9c9c7a89..4a177078 100644 --- a/packages/astro-renderer/test/resolver.test.ts +++ b/packages/astro-renderer/test/resolver.test.ts @@ -2,7 +2,7 @@ import { mintPreviewToken, SiteGraphStore, seedStarterSite } from "@cloudhal/cor import { MemoryBlobStore } from "@cloudhal/storage"; import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; import { beforeEach, describe, expect, it } from "vitest"; -import { PREVIEW_COOKIE, SiteResolver } from "../src/resolver.ts"; +import { PREVIEW_COOKIE, type Resolution, SiteResolver } from "../src/resolver.ts"; const SECRET = "resolver-preview-secret-at-least-32-bytes"; const actor = { kind: "human" as const, id: "user_resolver01", name: "Halil" }; @@ -149,6 +149,16 @@ describe("SiteResolver", () => { expect(await resolver.publicSite(preview)).toBeUndefined(); }); + it("publicSite is undefined, never a throw, when the snapshot cannot be read", async () => { + const unreadable = { + kind: "published", + source: { site: () => Promise.reject(new Error("R2 down")) }, + snapshotId: "snap_unreadable", + at: clock, + } as unknown as Resolution; + await expect(resolver.publicSite(unreadable)).resolves.toBeUndefined(); + }); + it("caches immutable snapshot sources with an LRU bound", async () => { const small = new SiteResolver({ store, siteId, snapshotCacheSize: 2 }); const ids: string[] = []; diff --git a/packages/cli/test/cloudflare-multisite.test.ts b/packages/cli/test/cloudflare-multisite.test.ts index 94645ceb..af926d58 100644 --- a/packages/cli/test/cloudflare-multisite.test.ts +++ b/packages/cli/test/cloudflare-multisite.test.ts @@ -185,4 +185,33 @@ describe("multi-site Site Worker on workerd (RFC-0034 §4)", () => { ].sort(), ); }); + it("a hostname map it cannot read is unavailable, never the error page (#167)", async () => { + const env = (await harness.getWorker().getEnv()) as { DB: D1Database }; + // the store fails for this table only, as D1 does past the host map's 60 s TTL + await env.DB.exec("ALTER TABLE site_hosts RENAME TO site_hosts_down"); + try { + const reset = await harness.fetch("https://en.example.test/__cloudhal/purge", { + method: "POST", + headers: { authorization: `Bearer ${PURGE_TOKEN}` }, + }); + expect(reset.status).toBe(200); + const health = await harness.fetch("https://en.example.test/__cloudhal/health"); + expect(health.status).toBe(503); + expect(health.headers.get("cache-control")).toBe("no-store"); + expect(await health.json()).toEqual({ ok: false }); + const page = await harness.fetch("https://en.example.test/about"); + const html = await page.text(); + expect(page.status, html.slice(0, 200)).toBe(503); + expect(page.headers.get("x-cloudhal-state")).toBe("unavailable"); + expect(page.headers.get("retry-after")).toBe("300"); + expect(html).toContain('lang="en"'); + expect(html).not.toContain("English Edition"); + const feed = await harness.fetch("https://en.example.test/rss.xml"); + expect(feed.status).toBe(503); + expect(feed.headers.get("retry-after")).toBe("300"); + expect(feed.headers.get("content-type")).toContain("text/plain"); + } finally { + await env.DB.exec("ALTER TABLE site_hosts_down RENAME TO site_hosts"); + } + }); }); diff --git a/starters/blog/src/pages/[...path].astro b/starters/blog/src/pages/[...path].astro index bcb1ae87..9a4a8249 100644 --- a/starters/blog/src/pages/[...path].astro +++ b/starters/blog/src/pages/[...path].astro @@ -115,8 +115,8 @@ export async function getStaticPaths() { } // RFC-0043 §1.8 (#167): after a page threw, Astro renders this catch-all as its error route -// (`/500`). Throw again: Astro then answers an empty 500 that the Site Worker turns into the -// `error` page with a reference, instead of this route's 404 for `/500`. +// (`/500` matches it) with the original path. Throw again: Astro then answers an empty 500 that +// the Site Worker turns into the `error` page with a reference, instead of this route's 404. const renderError = (Astro.props as { error?: unknown }).error; if (renderError !== undefined) throw renderError; const source = await getSource(config, Astro);