Skip to content

experimental.advancedRouting (astro/hono): unmatched routes throw Cannot read properties of undefined (reading 'route') instead of rendering the 404 page #16907

@iseraph-dev

Description

@iseraph-dev

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

  • I am willing to submit a pull request for this issue.

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    - P3: minor bugAn edge case that only affects very specific usage (priority)pkg: astroRelated to the core `astro` package (scope)

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions