Skip to content

Prerendered 404 leaks X-Astro-Error from a custom worker #18232

Open

Description

@iseraph-dev

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

  1. astro-factory commented on Oct 4, 2026

    @astro-factory
    Contributor

    Triage complete

    • Prepare the workspace: ready
    • Install dependencies: complete
    • Build the project: complete
    • Reproduce the issue: confirmed
    • Diagnose the cause: high confidence
    • Verify the diagnosis: bug
    • Attempt a fix: verified
    • Publish the result: set triage: fix pending

    • Reproduced: Yes
    • Exploration: Yes — View branch
    • Unit Test: Yes — packages/astro/test/units/fetch/index.test.ts
    • Priority: P3: minor bug. This only happens with a custom Cloudflare worker entry built from astro/hono pages()/middleware() plus a prerendered 404.astro. Using astro() instead avoids it.

    When no route matches and /404 is prerendered, FetchState leaves routeData unset. handlePagesWithErrorFallback (packages/astro/src/core/pages/handler.ts) and handleMiddlewareWithErrorFallback (packages/astro/src/core/middleware/astro-middleware.ts) then return new Response(null, { status: 404, headers: { 'X-Astro-Error': 'true' } }) and expect BaseApp.render's post-check to render the error page. A Hono worker entry on Cloudflare is the top-level handler, so App.render never 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(...) with status: 404, the same call handleRequest makes. That serves the prerendered 404 through renderOptions.prerenderedErrorPageFetch, which cf() 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@b5c9e74

    If 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 equivalent wrangler.jsonc (main: ./src/worker.ts, ASSETS binding, run_worker_first: true), because the adapter on main reads wrangler config.

    Steps:

    1. astro.config.mjs: adapter: cloudflare(), output: 'server', session: false
    2. src/pages/index.astro is a normal page. src/pages/404.astro has export const prerender = true and renders PRERENDERED-404.
    3. src/worker.ts: a Hono app with app.use(cf()); app.use(pages());
    4. astro dev, then GET /missing

    Actual: 404, empty body, X-Astro-Error: true.
    Expected: 404 serving the prerendered PRERENDERED-404 HTML, without X-Astro-Error.
    Control: with cf() + astro(), /missing returns 404 with the rendered HTML and no header. / returns 200 in both setups.

    Diagnosis

    • packages/astro/src/core/pages/handler.ts (handlePagesWithErrorFallback) and packages/astro/src/core/middleware/astro-middleware.ts (handleMiddlewareWithErrorFallback) return a 404 marked with X-Astro-Error when !state.routeData.
    • The only code that reads the header is BaseApp.render (packages/astro/src/core/app/base.ts:~438). It deletes the header and calls renderError.
    • A Cloudflare worker entry composed from astro/hono never goes through App.render. cf()/finalize only copy cookies and CDN cache headers, so the marker reaches the client. cf() does set state.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.routeData with renderErrorFromState(...).
    • The JSDoc on pages()/middleware() in packages/astro/src/core/fetch/index.ts says "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 no App.render post-check, even though @astrojs/cloudflare/hono's own JSDoc shows cf() + pages() as the intended usage. The existing tests only checked the marker against a stamped App.

    Fix

    • In both fallback handlers, the marker response is replaced with:
      return renderErrorFromState(state, state.request, {
          ...state.renderOptions,
          status: 404,
          pathname: state.pathname,
      });
      middleware() still skips user middleware and next for unmatched routes.
    • Updated the JSDoc and inline comments in both handlers, and removed the now-unused ASTRO_ERROR_HEADER import in astro-middleware.ts.
    • Tests: two existing tests in packages/astro/test/units/fetch/index.test.ts were rewritten. They are now middleware() › renders the prerendered 404 page without running middleware and pages() › renders the prerendered 404 page for unmatched routes. Each stubs prerenderedErrorPageFetch and asserts a 404, no X-Astro-Error, and the prerendered body. Both fail without the fix and pass with it.
    • Changeset: .changeset/chubby-forks-beg.md (astro: patch).

    Results:

    • Repro: /missing now returns 404 with the PRERENDERED-404 body and no X-Astro-Error.
    • astro unit tests (fetch, actions, app, errors, middleware, pages, routing): 933/933 pass.
    • Related Cloudflare tests: 110/110 pass.
    • Biome, prettier, and the build's typecheck are clean.

    Tradeoffs:

    • I didn't make cf() consume the header instead. That would fix only Cloudflare and leave every other host that composes astro/fetch with the same problem.
    • On the App.render + custom src/fetch.ts path, this 404 now goes through the environment's renderErrorPage rather than App.renderError. That matches what astro() already does there.
    • The redirect case in handlePages still 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.

  2. added
    - P3: minor bugAn edge case that only affects very specific usage (priority)
    pkg: astroRelated to the core `astro` package (scope)
    pkg: cloudflareRelated to the Cloudflare adapter
    triage: fix pendingReporter needs to verify the triage bot fix works
    and removed on Oct 4, 2026
  3. iseraph-dev commented on Oct 5, 2026

    @iseraph-dev
    ContributorAuthor

    Replacing the empty X-Astro-Error response with renderErrorFromState is the right idea, and cf() should not be taught to consume that header.

    On this reproduction it is not enough. astro dev uses the development error strategy, which renders the 404 through server rendering and does not call the prerendered-page fetch that cf() already installed. The prerendered 404.astro is also absent from the worker page map. GET /missing would still not return the PRERENDERED-404 body. The factory tests pass because they run with the production error strategy.

  4. added
    triage: fix rejectedReporter says the proposed fix does not work
    and removed
    triage: fix pendingReporter needs to verify the triage bot fix works
    on Oct 5, 2026
  5. self-assigned this
    on Oct 5, 2026
  6. added and removed
    triage: fix rejectedReporter says the proposed fix does not work
    on Oct 6, 2026
  7. matthewp commented on Oct 6, 2026

    @matthewp
    Contributor

    It's not the responsibility of either cf() or pages() to render 404.astro. Probably a better solution here is to have a new fetch handler + hono middleware for notFound() that does this. Then we wouldn't need to rely on the App to do it for us. I would expect astro() to include this.

    Bot, can you triage again and explore this as a solution over what you provided?

  8. astro-factory commented on Oct 6, 2026

    @astro-factory
    Contributor

    Triage complete

    • Prepare the workspace: ready
    • Install dependencies: complete
    • Build the project: complete
    • Reproduce the issue: confirmed
    • Diagnose the cause: high confidence
    • Verify the diagnosis: bug
    • Attempt a fix: verified
    • Publish the result: set triage: fix pending

    • Reproduced: Yes
    • Exploration: Yes — View branch
    • Unit Test: Yes — packages/astro/test/units/fetch/index.test.ts, packages/astro/test/units/hono/index.test.ts
    • Priority: P3: minor bug. This only affects a custom worker entry built from astro/hono/astro/fetch that has a prerendered 404.astro and doesn't use astro().

    When no route matches and 404.astro is prerendered, FetchState leaves routeData unset. pages() and middleware() (handlePagesWithErrorFallback in packages/astro/src/core/pages/handler.ts, handleMiddlewareWithErrorFallback in packages/astro/src/core/middleware/astro-middleware.ts) then return an empty 404 with the internal X-Astro-Error marker. Only BaseApp.render reads that marker (packages/astro/src/core/app/base.ts). When a Hono app is the worker entry itself, App.render never runs, so the empty 404 and the header reach the client. astro() doesn't have this problem because handleRequest renders the 404 page itself.

    I found a potential fix for this issue. As @matthewp suggested, this adds a new notFound() handler to astro/fetch and a matching Hono middleware to astro/hono. It renders the 404 page through renderErrorFromState when nothing matched, and astro() now uses the same helper. I tested cf() + notFound() + pages() in the reproduction:

    • astro dev: /missing returns 404 with the PRERENDERED-404 body and no X-Astro-Error. Dev renders the page from source.
    • astro build + astro preview (workerd): the same request returns the static 404.html through prerenderedErrorPageFetch.

    Users with a plain cf() + pages() chain still have to add notFound() 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@322d737

    If 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 equivalent wrangler.jsonc: main: ./src/worker.ts, ASSETS binding, run_worker_first: true.

    Steps:

    1. astro.config.mjs: adapter: cloudflare(), output: 'server', session: false
    2. src/pages/index.astro is a normal page. src/pages/404.astro has export const prerender = true and renders PRERENDERED-404.
    3. src/worker.ts: a Hono app with app.use(cf()); app.use(pages());
    4. Run astro dev, then GET /missing

    Actual: 404, content-length: 0, x-astro-error: true.
    Expected: 404 serving the prerendered 404 HTML, without X-Astro-Error.
    Control: with cf() + astro(), /missing returns 404 with the PRERENDERED-404 HTML and no header. / returns 200 in both setups.

    Diagnosis (high confidence)

    • packages/astro/src/core/fetch/fetch-state.ts (~L1045–1055): when nothing matches, FetchState falls back only to an SSR custom 404. A prerendered 404 leaves routeData unset on purpose, so the static page can be served through prerenderedErrorPageFetch.
    • handlePagesWithErrorFallback (pages/handler.ts ~L105–117) and handleMiddlewareWithErrorFallback (middleware/astro-middleware.ts ~L101–118) return new 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.
    • When the Hono app is the worker entry, App.render is never called. cf()/finalize only apply cookies and CDN cache headers, so the marker leaks to the client.
    • astro() → handleRequest (routing/handler.ts) handles !routeData inline with renderErrorFromState(..., status: 404). cf() already sets state.renderOptions.prerenderedErrorPageFetch.
    • The public JSDoc for pages()/middleware() in core/fetch/index.ts says unmatched routes "render the 404 error page". That is only true when the pipeline runs inside App.render.

    On the follow-up comment saying dev wouldn't produce the body: in our control, dev with astro() returned PRERENDERED-404. The dev error strategy renders 404.astro from 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/hono documents exactly the cf() + 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 new notFound() handler.

    Fix

    • New packages/astro/src/core/errors/not-found.ts, exporting handleNotFound(state):
      • returns 400 for over-encoded paths, like the other composable handlers;
      • returns undefined when a route matched;
      • otherwise returns renderErrorFromState(state, state.request, { ...state.renderOptions, status: 404, pathname: state.pathname }).
    • core/fetch/index.ts: new notFound(state) export. The pages()/middleware() JSDoc is corrected to describe the marked 404 and point to notFound().
    • core/hono/index.ts: new notFound() Hono middleware.
    • core/routing/handler.ts: handleRequest (astro()) reuses handleNotFound. Behavior is unchanged.
    • pages()/middleware() still return the marked 404. That stays correct when the pipeline runs under App.render (a user fetchFile).

    Usage:

    app.use(cf());
    app.use(notFound());
    app.use(pages());

    Results:

    • Repro, astro dev: /missing returns 404 with PRERENDERED-404 and no x-astro-error. / returns 200.
    • Repro, astro build + astro preview (workerd): /missing returns 404 with the static 404.html body and no x-astro-error.
    • New tests in test/units/fetch/index.test.ts (notFound()):
      • returns undefined when a route matches;
      • with a prerendered /404, it fetches /404.html via prerenderedErrorPageFetch and returns a 404 with that body and no X-Astro-Error.
    • New test in test/units/hono/index.test.ts (notFound() Hono middleware): notFound() + pages() serves the prerendered 404; / still renders normally.
    • Existing suites, all passing: units/fetch (97), hono (10), app (264), routing (303), middleware (75). Biome, prettier and the astro build are clean.

    Changeset: .changeset/short-masks-shake.md, 'astro': minor. This is a new public API; earlier additions like getFetchState() were also minor. Minor bumps to astro need maintainer approval.

    Tradeoffs:

    • A bare cf() + pages() chain still gets the marked empty 404 until notFound() is added. A follow-up could stop pages() from emitting the marker, or add a dev warning.
    • notFound() runs before middleware(), so user middleware does not wrap unmatched requests. The error renderer still runs middleware for the 404 route itself, the same as astro().
    • The doc example in @astrojs/cloudflare/hono could add app.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.

  9. added
    triage: fix pendingReporter needs to verify the triage bot fix works
    and removed on Oct 6, 2026
  10. iseraph-dev commented on Oct 6, 2026

    @iseraph-dev
    ContributorAuthor

    Confirmed, this fixes the issue. Adding notFound() before pages() is enough. The Hono routing API reference needs to include notFound.

    I tested on @astrojs/[email protected], because @astrojs/[email protected] peers astro@^7.4.0-beta.0 and 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 example i18n() comes after pages(), and pages() has to be last. The docs also need a section on the order these handlers run.

  11. added
    triage: fix verifiedReporter confirmed the triage bot fix works
    and removed
    triage: fix pendingReporter needs to verify the triage bot fix works
    on Oct 6, 2026
  12. astro-factory commented on Oct 6, 2026

    @astro-factory
    Contributor

    The fix has been verified! A pull request has been created: #18265

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

- P3: minor bugAn edge case that only affects very specific usage (priority)pkg: astroRelated to the core `astro` package (scope)pkg: cloudflareRelated to the Cloudflare adaptertriage: fix verifiedReporter confirmed the triage bot fix works

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions