Astro Info
Astro v6.4.2
Vite v7.3.3
Node v24.16.0
System Linux (x64)
Package Manager npm
Output server
Adapter @astrojs/node (v10.1.2)
Integrations none
Also reproduced against astro@main. hono@4.12.23.
If this issue only occurs in one browser, which browser is a problem?
No response (server-side; reproduced with curl).
Describe the Bug
With experimental.advancedRouting enabled and a custom src/app.ts built from the
astro/hono handlers, any request to a path that matches no route throws
TypeError: Cannot read properties of undefined (reading 'route')
inside FetchState.getActionAPIContext(), instead of rendering src/pages/404.astro.
With the @astrojs/node standalone adapter the request is served as HTTP 500 Internal
Server Error.
The project has a valid src/pages/404.astro, but the internal 404 fallback in
FetchState.#resolveRouteData() never selects it, so routeData stays undefined and the
first handler that builds an API context crashes (pages() here; actions() throws at the
same spot, earlier in the documented chain).
The standard (non-astro/hono) SSR app renders the 404 page correctly for the same project
— only the experimental advancedRouting / FetchState path is affected (see the repro's
contrast check).
Observed server log (compiled chunk hash is build-specific):
TypeError: Cannot read properties of undefined (reading 'route')
at FetchState.getActionAPIContext (.../dist/server/chunks/server_<hash>.mjs)
at FetchState.getAPIContext (.../dist/server/chunks/server_<hash>.mjs)
at pages$1 (.../dist/server/chunks/server_<hash>.mjs)
at Hono.fetch (.../node_modules/hono/dist/hono-base.js)
at App.render (.../dist/server/chunks/server_<hash>.mjs)
Root cause
FetchState.#resolveRouteData() resolves the route eagerly in the constructor. For an
unmatched path pipeline.matchRoute(pathname) returns undefined, so it tries a 404 fallback
(packages/astro/src/core/fetch/fetch-state.ts, L822–826 on main):
// Fall back to a 404 route so middleware can still run.
if (!this.routeData) {
this.routeData = pipeline.manifestData.routes.find(
(route) => route.component === '404.astro' || route.component === DEFAULT_404_COMPONENT,
);
}
The comparison is by component name, but a built manifest stores the component as a
path ("component":"src/pages/404.astro"). Neither "404.astro" nor
"astro-default-404.astro" (DEFAULT_404_COMPONENT) equals "src/pages/404.astro", so
find() returns undefined and this.routeData stays undefined. This branch is effectively
dead for any real user 404.astro.
getActionAPIContext() then dereferences it (L955–956), behind a comment asserting the
very invariant the broken fallback violates:
routePattern: this.routeData!.route, // <-- TypeError: routeData is undefined
isPrerendered: this.routeData!.prerender,
// ...
// SAFETY: getActionAPIContext is only called after route resolution,
// so routeData is always set and the params getter always returns a value.
For contrast, the canonical matcher in core/routing/match.js resolves 404/500 by route
path, not component name. The helper isRoute404or500 is already imported into
fetch-state.ts (L36, used at L642) but unused in this fallback.
What's the expected result?
GET /does-not-exist should render src/pages/404.astro with HTTP 404, exactly as the
standard SSR app (without advancedRouting) already does. Verified in the reproduction: with
experimental.advancedRouting + src/app.ts removed, the same request returns 404 with
the custom 404 page; the server log is clean.
Link to Minimal Reproducible Example
https://github.com/iseraph-dev/astro-hono-404-repro
Participation
Suggested fix
In FetchState.#resolveRouteData(), select the fallback 404 route by route path using the
already-imported helper instead of the stale component-name comparison:
if (!this.routeData) {
- this.routeData = pipeline.manifestData.routes.find(
- (route) => route.component === '404.astro' || route.component === DEFAULT_404_COMPONENT,
- );
+ this.routeData = pipeline.manifestData.routes.find((route) => isRoute404or500(route));
}
(Reusing pipeline.matchRoute('/404') would also work — it already routes via isRoute404.)
With i18n there can be multiple 404 routes (/404, /pl/404, …); a follow-up could make the
fallback locale-aware, but matching by path already stops the crash and restores the
routeData-always-set invariant.
Optionally, as defense-in-depth, guard getActionAPIContext() against an undefined routeData
so a missing 404 route degrades gracefully instead of throwing.
Additional context — @astrojs/node amplification (separate concern)
In this minimal repro the @astrojs/node standalone adapter converts the unhandled rejection
into an HTTP 500, so the symptom here is "wrong status / wrong page" rather than a hang.
The impact is worse in a realistic pipeline, though. When a handler rejects and the app's Hono
onError re-throws — including the common pattern of re-throwing only for non-/api/* paths so
that page and API errors are handled differently — @astrojs/node standalone never writes a
response and the socket is left open with no reply. Repeated unmatched requests (a crawler, or a
footer link to a page that no longer exists) then accumulate hung connections and can eventually
take the Node process down: a single bad URL escalating into a process-wide hang / DoS.
The underlying cause is the same routeData === undefined described above; this section only
concerns how the failure surfaces. Two independent mitigations apply:
- Fixing the 404 fallback (above) removes the rejection entirely — the 404 page renders, so
there is nothing left to escalate. This is the primary fix.
- Independently, ensuring
@astrojs/node always turns an unhandled rejection into a 5xx
response — including when a user onError re-throws — would stop any future handler
rejection from escalating into an open, hanging connection.
Astro Info
If this issue only occurs in one browser, which browser is a problem?
No response (server-side; reproduced with
curl).Describe the Bug
With
experimental.advancedRoutingenabled and a customsrc/app.tsbuilt from theastro/honohandlers, any request to a path that matches no route throwsinside
FetchState.getActionAPIContext(), instead of renderingsrc/pages/404.astro.With the
@astrojs/nodestandalone adapter the request is served as HTTP 500 InternalServer Error.
The project has a valid
src/pages/404.astro, but the internal 404 fallback inFetchState.#resolveRouteData()never selects it, sorouteDatastaysundefinedand thefirst handler that builds an API context crashes (
pages()here;actions()throws at thesame spot, earlier in the documented chain).
The standard (non-
astro/hono) SSR app renders the 404 page correctly for the same project— only the experimental
advancedRouting/FetchStatepath is affected (see the repro'scontrast check).
Observed server log (compiled chunk hash is build-specific):
Root cause
FetchState.#resolveRouteData()resolves the route eagerly in the constructor. For anunmatched path
pipeline.matchRoute(pathname)returnsundefined, so it tries a 404 fallback(
packages/astro/src/core/fetch/fetch-state.ts, L822–826 onmain):The comparison is by component name, but a built manifest stores the component as a
path (
"component":"src/pages/404.astro"). Neither"404.astro"nor"astro-default-404.astro"(DEFAULT_404_COMPONENT) equals"src/pages/404.astro", sofind()returnsundefinedandthis.routeDatastaysundefined. This branch is effectivelydead for any real user
404.astro.getActionAPIContext()then dereferences it (L955–956), behind a comment asserting thevery invariant the broken fallback violates:
For contrast, the canonical matcher in
core/routing/match.jsresolves 404/500 by routepath, not component name. The helper
isRoute404or500is already imported intofetch-state.ts(L36, used at L642) but unused in this fallback.What's the expected result?
GET /does-not-existshould rendersrc/pages/404.astrowith HTTP 404, exactly as thestandard SSR app (without
advancedRouting) already does. Verified in the reproduction: withexperimental.advancedRouting+src/app.tsremoved, the same request returns 404 withthe custom 404 page; the server log is clean.
Link to Minimal Reproducible Example
https://github.com/iseraph-dev/astro-hono-404-repro
Participation
Suggested fix
In
FetchState.#resolveRouteData(), select the fallback 404 route by route path using thealready-imported helper instead of the stale component-name comparison:
if (!this.routeData) { - this.routeData = pipeline.manifestData.routes.find( - (route) => route.component === '404.astro' || route.component === DEFAULT_404_COMPONENT, - ); + this.routeData = pipeline.manifestData.routes.find((route) => isRoute404or500(route)); }(Reusing
pipeline.matchRoute('/404')would also work — it already routes viaisRoute404.)With i18n there can be multiple 404 routes (
/404,/pl/404, …); a follow-up could make thefallback locale-aware, but matching by path already stops the crash and restores the
routeData-always-set invariant.Optionally, as defense-in-depth, guard
getActionAPIContext()against an undefinedrouteDataso a missing 404 route degrades gracefully instead of throwing.
Additional context —
@astrojs/nodeamplification (separate concern)In this minimal repro the
@astrojs/nodestandalone adapter converts the unhandled rejectioninto an
HTTP 500, so the symptom here is "wrong status / wrong page" rather than a hang.The impact is worse in a realistic pipeline, though. When a handler rejects and the app's Hono
onErrorre-throws — including the common pattern of re-throwing only for non-/api/*paths sothat page and API errors are handled differently —
@astrojs/nodestandalone never writes aresponse and the socket is left open with no reply. Repeated unmatched requests (a crawler, or a
footer link to a page that no longer exists) then accumulate hung connections and can eventually
take the Node process down: a single bad URL escalating into a process-wide hang / DoS.
The underlying cause is the same
routeData === undefineddescribed above; this section onlyconcerns how the failure surfaces. Two independent mitigations apply:
there is nothing left to escalate. This is the primary fix.
@astrojs/nodealways turns an unhandled rejection into a5xxresponse — including when a user
onErrorre-throws — would stop any future handlerrejection from escalating into an open, hanging connection.