Repository navigation
Prerendered 404 leaks X-Astro-Error from a custom worker #18232
Description
Astro Info
Astro v7.4.0-beta.1
Node v26.10.0
System Linux (x64)
Package Manager pnpm
Output server
Adapter @astrojs/cloudflare
Integrations none
Also installed: @astrojs/[email protected], [email protected].
If this issue only occurs in one browser, which browser is a problem?
None. The failure is in the Cloudflare worker. astro dev runs that worker.
Describe the Bug
When routeData is missing, pages() and middleware() respond with status 404, a null body, and X-Astro-Error: true. The source comment says App.render consumes that header: it deletes the header and calls renderError. Nothing in astro/hono or @astrojs/cloudflare reads it. cf() only copies cookies and CDN cache headers.
FetchState uses an SSR /404 as routeData. It leaves a prerendered /404 unset. The default Cloudflare worker calls app.render(), which renders the error page and strips the header. This project's cloudflare.config.ts entrypoint does not call app.render(). astro dev uses that entrypoint, so the header stays on the response.
---
export const prerender = true;
---
<html><body>PRERENDERED-404</body></html>src/pages/index.astro is a normal page. There is no SSR /404. Without a user-authored 404.astro, Astro injects an SSR 404 and pages() renders it. This project prerenders src/pages/404.astro so the header path is taken.
import cloudflare from '@astrojs/cloudflare';
import { defineConfig } from 'astro/config';
export default defineConfig({
adapter: cloudflare(),
output: 'server',
session: false,
});import { Hono } from 'hono';
import { pages } from 'astro/hono';
import { cf } from '@astrojs/cloudflare/hono';
const app = new Hono();
app.use(cf());
app.use(pages());
export default app;pnpm install, then pnpm dev. GET /missing is 404, the body is empty, and X-Astro-Error is true. The prerendered HTML is not served.
What's the expected result?
astro() after cf() loads the prerendered HTML through prerenderedErrorPageFetch and omits X-Astro-Error. A worker built from astro/hono has to render that page itself, because the worker never calls App.render.
Link to Minimal Reproducible Example
https://github.com/iseraph-dev/astro-hono-prerendered-404-header
Participation
- I am willing to submit a pull request for this issue.
Activity
astro-factory commentedon Oct 4, 2026
Triage complete
triage: fix pendingpackages/astro/test/units/fetch/index.test.tsastro/honopages()/middleware()plus a prerendered404.astro. Usingastro()instead avoids it.When no route matches and
/404is prerendered,FetchStateleavesrouteDataunset.handlePagesWithErrorFallback(packages/astro/src/core/pages/handler.ts) andhandleMiddlewareWithErrorFallback(packages/astro/src/core/middleware/astro-middleware.ts) then returnnew Response(null, { status: 404, headers: { 'X-Astro-Error': 'true' } })and expectBaseApp.render's post-check to render the error page. A Hono worker entry on Cloudflare is the top-level handler, soApp.rendernever runs, and the empty 404 and the internal header reach the client.astro()(handleRequest) handles the same case by rendering the error page inline, which is why it works.I found a potential fix for this issue. Both fallback handlers now call
renderErrorFromState(...)withstatus: 404, the same callhandleRequestmakes. That serves the prerendered 404 throughrenderOptions.prerenderedErrorPageFetch, whichcf()already sets. It needs human review.Try this fix
You can test this fix right now without waiting for a release:
# astro npm i https://pkg.pr.new/astro@b5c9e74If this fixes your issue, please leave a comment letting us know (for example
"confirmed, this fixes it"). A pull request will then be opened to get it merged.
Full Triage Report
Reproduction
Environment: monorepo
main(astro 7.3.5, @astrojs/cloudflare 14.3.3, hono 4.13.11, wrangler 4.136.2), Node.js,astro dev. The reporter used astro 7.4.0-beta.1 and @astrojs/cloudflare 15.0.0-beta.1.The project is the reporter's repo (https://github.com/iseraph-dev/astro-hono-prerendered-404-header), with one change:
cloudflare.config.ts(cf/config) is replaced by an equivalentwrangler.jsonc(main: ./src/worker.ts, ASSETS binding,run_worker_first: true), because the adapter onmainreads wrangler config.Steps:
astro.config.mjs:adapter: cloudflare(), output: 'server', session: falsesrc/pages/index.astrois a normal page.src/pages/404.astrohasexport const prerender = trueand rendersPRERENDERED-404.src/worker.ts: a Hono app withapp.use(cf()); app.use(pages());astro dev, thenGET /missingActual:
404, empty body,X-Astro-Error: true.Expected:
404serving the prerenderedPRERENDERED-404HTML, withoutX-Astro-Error.Control: with
cf()+astro(),/missingreturns 404 with the rendered HTML and no header./returns 200 in both setups.Diagnosis
packages/astro/src/core/pages/handler.ts(handlePagesWithErrorFallback) andpackages/astro/src/core/middleware/astro-middleware.ts(handleMiddlewareWithErrorFallback) return a 404 marked withX-Astro-Errorwhen!state.routeData.BaseApp.render(packages/astro/src/core/app/base.ts:~438). It deletes the header and callsrenderError.astro/hononever goes throughApp.render.cf()/finalizeonly copy cookies and CDN cache headers, so the marker reaches the client.cf()does setstate.renderOptions.prerenderedErrorPageFetch, so everything needed to render the prerendered page is already on the state.astro()→handleRequest(packages/astro/src/core/routing/handler.ts) handles!state.routeDatawithrenderErrorFromState(...).pages()/middleware()inpackages/astro/src/core/fetch/index.tssays "Unmatched routes render the 404 error page", which the code doesn't do here.Confidence: high.
Verification: bug (high confidence)
The marker was meant as a way to render
404.astro. The commit that added it (#17097) and the JSDoc both say the goal is to render the 404 page. Nothing in the code acknowledges hosts with noApp.renderpost-check, even though@astrojs/cloudflare/hono's own JSDoc showscf()+pages()as the intended usage. The existing tests only checked the marker against a stamped App.Fix
middleware()still skips user middleware andnextfor unmatched routes.ASTRO_ERROR_HEADERimport inastro-middleware.ts.packages/astro/test/units/fetch/index.test.tswere rewritten. They are nowmiddleware() › renders the prerendered 404 page without running middlewareandpages() › renders the prerendered 404 page for unmatched routes. Each stubsprerenderedErrorPageFetchand asserts a 404, noX-Astro-Error, and the prerendered body. Both fail without the fix and pass with it..changeset/chubby-forks-beg.md(astro: patch).Results:
/missingnow returns 404 with thePRERENDERED-404body and noX-Astro-Error.fetch,actions,app,errors,middleware,pages,routing): 933/933 pass.Tradeoffs:
cf()consume the header instead. That would fix only Cloudflare and leave every other host that composesastro/fetchwith the same problem.App.render+ customsrc/fetch.tspath, this 404 now goes through the environment'srenderErrorPagerather thanApp.renderError. That matches whatastro()already does there.redirectcase inhandlePagesstill returns a marker. It's a separate path and was left alone.This report was made by an LLM. The analysis may be wrong, and the potential fix might not work, but is intended as a starting point for exploring the issue.
iseraph-dev commentedon Oct 5, 2026
Replacing the empty
X-Astro-Errorresponse withrenderErrorFromStateis the right idea, andcf()should not be taught to consume that header.On this reproduction it is not enough.
astro devuses the development error strategy, which renders the 404 through server rendering and does not call the prerendered-page fetch thatcf()already installed. The prerendered404.astrois also absent from the worker page map.GET /missingwould still not return thePRERENDERED-404body. The factory tests pass because they run with the production error strategy.matthewp commentedon Oct 6, 2026
It's not the responsibility of either
cf()orpages()to render 404.astro. Probably a better solution here is to have a new fetch handler + hono middleware fornotFound()that does this. Then we wouldn't need to rely on theAppto do it for us. I would expectastro()to include this.Bot, can you triage again and explore this as a solution over what you provided?
astro-factory commentedon Oct 6, 2026
Triage complete
triage: fix pendingpackages/astro/test/units/fetch/index.test.ts,packages/astro/test/units/hono/index.test.tsastro/hono/astro/fetchthat has a prerendered404.astroand doesn't useastro().When no route matches and
404.astrois prerendered,FetchStateleavesrouteDataunset.pages()andmiddleware()(handlePagesWithErrorFallbackinpackages/astro/src/core/pages/handler.ts,handleMiddlewareWithErrorFallbackinpackages/astro/src/core/middleware/astro-middleware.ts) then return an empty 404 with the internalX-Astro-Errormarker. OnlyBaseApp.renderreads that marker (packages/astro/src/core/app/base.ts). When a Hono app is the worker entry itself,App.rendernever runs, so the empty 404 and the header reach the client.astro()doesn't have this problem becausehandleRequestrenders the 404 page itself.I found a potential fix for this issue. As @matthewp suggested, this adds a new
notFound()handler toastro/fetchand a matching Hono middleware toastro/hono. It renders the 404 page throughrenderErrorFromStatewhen nothing matched, andastro()now uses the same helper. I testedcf()+notFound()+pages()in the reproduction:astro dev:/missingreturns 404 with thePRERENDERED-404body and noX-Astro-Error. Dev renders the page from source.astro build+astro preview(workerd): the same request returns the static404.htmlthroughprerenderedErrorPageFetch.Users with a plain
cf()+pages()chain still have to addnotFound()to get this. The fix needs human review.Try this fix
You can test this fix right now without waiting for a release:
# astro npm i https://pkg.pr.new/astro@322d737If this fixes your issue, please leave a comment letting us know (for example
"confirmed, this fixes it"). A pull request will then be opened to get it merged.
Full Triage Report
Reproduction
Environment: monorepo
main(astro 7.3.6, @astrojs/cloudflare 14.3.4, hono 4.13.11), Node v22.23.2, pnpm 11.27.0. The reporter used astro 7.4.0-beta.1, @astrojs/cloudflare 15.0.0-beta.1 and hono 4.13.7.The project is the reporter's repo (https://github.com/iseraph-dev/astro-hono-prerendered-404-header) with one change.
cloudflare.config.ts(cf/config, which only exists in 15.0 beta) is replaced by an equivalentwrangler.jsonc:main: ./src/worker.ts, ASSETS binding,run_worker_first: true.Steps:
astro.config.mjs:adapter: cloudflare(), output: 'server', session: falsesrc/pages/index.astrois a normal page.src/pages/404.astrohasexport const prerender = trueand rendersPRERENDERED-404.src/worker.ts: a Hono app withapp.use(cf()); app.use(pages());astro dev, thenGET /missingActual:
404,content-length: 0,x-astro-error: true.Expected:
404serving the prerendered 404 HTML, withoutX-Astro-Error.Control: with
cf()+astro(),/missingreturns 404 with thePRERENDERED-404HTML and no header./returns 200 in both setups.Diagnosis (high confidence)
packages/astro/src/core/fetch/fetch-state.ts(~L1045–1055): when nothing matches,FetchStatefalls back only to an SSR custom 404. A prerendered 404 leavesrouteDataunset on purpose, so the static page can be served throughprerenderedErrorPageFetch.handlePagesWithErrorFallback(pages/handler.ts~L105–117) andhandleMiddlewareWithErrorFallback(middleware/astro-middleware.ts~L101–118) returnnew Response(null, { status: 404, headers: { 'X-Astro-Error': 'true' } }). They rely on "the app's post-check" one level up to render the page.BaseApp.render(app/base.ts~L438) is the only code that reads the header.App.renderis never called.cf()/finalizeonly apply cookies and CDN cache headers, so the marker leaks to the client.astro()→handleRequest(routing/handler.ts) handles!routeDatainline withrenderErrorFromState(..., status: 404).cf()already setsstate.renderOptions.prerenderedErrorPageFetch.pages()/middleware()incore/fetch/index.tssays unmatched routes "render the 404 error page". That is only true when the pipeline runs insideApp.render.On the follow-up comment saying dev wouldn't produce the body: in our control, dev with
astro()returnedPRERENDERED-404. The dev error strategy renders404.astrofrom source instead of fetching it.Verification: bug (high confidence)
The marker came in with #17636 (functional-core refactor). Its comments assume the composable handlers always run under
App.render. Using a Hono app directly as the worker entry is never mentioned, even though@astrojs/cloudflare/honodocuments exactly thecf()+pages()composition. The behavior also contradicts the public JSDoc, and leaking an internal header is clearly unintended. @matthewp confirmed it should be fixed with a newnotFound()handler.Fix
packages/astro/src/core/errors/not-found.ts, exportinghandleNotFound(state):undefinedwhen a route matched;renderErrorFromState(state, state.request, { ...state.renderOptions, status: 404, pathname: state.pathname }).core/fetch/index.ts: newnotFound(state)export. Thepages()/middleware()JSDoc is corrected to describe the marked 404 and point tonotFound().core/hono/index.ts: newnotFound()Hono middleware.core/routing/handler.ts:handleRequest(astro()) reuseshandleNotFound. Behavior is unchanged.pages()/middleware()still return the marked 404. That stays correct when the pipeline runs underApp.render(a userfetchFile).Usage:
Results:
astro dev:/missingreturns 404 withPRERENDERED-404and nox-astro-error./returns 200.astro build+astro preview(workerd):/missingreturns 404 with the static404.htmlbody and nox-astro-error.test/units/fetch/index.test.ts(notFound()):undefinedwhen a route matches;/404, it fetches/404.htmlviaprerenderedErrorPageFetchand returns a 404 with that body and noX-Astro-Error.test/units/hono/index.test.ts(notFound() Hono middleware):notFound()+pages()serves the prerendered 404;/still renders normally.Changeset:
.changeset/short-masks-shake.md,'astro': minor. This is a new public API; earlier additions likegetFetchState()were also minor. Minor bumps toastroneed maintainer approval.Tradeoffs:
cf()+pages()chain still gets the marked empty 404 untilnotFound()is added. A follow-up could stoppages()from emitting the marker, or add a dev warning.notFound()runs beforemiddleware(), so user middleware does not wrap unmatched requests. The error renderer still runs middleware for the 404 route itself, the same asastro().@astrojs/cloudflare/honocould addapp.use(notFound()). It was left unchanged to keep this change in one package.This report was made by an LLM. The analysis may be wrong, and the potential fix might not work, but is intended as a starting point for exploring the issue.
iseraph-dev commentedon Oct 6, 2026
Confirmed, this fixes the issue. Adding
notFound()beforepages()is enough. The Hono routing API reference needs to includenotFound.I tested on
@astrojs/[email protected], because@astrojs/[email protected]peersastro@^7.4.0-beta.0and the pkg.pr.new build is astro 7.3.6.btw @matthewp, With Hono should use the same handler order as
astro(). In the current examplei18n()comes afterpages(), andpages()has to be last. The docs also need a section on the order these handlers run.astro-factory commentedon Oct 6, 2026
The fix has been verified! A pull request has been created: #18265