Skip to content

fix: keep auto-mode route handler request re-wrappable by fetch handler libraries (oRPC/Hono) on Workers#2558

Open
exKAZUu wants to merge 2 commits into
cloudflare:mainfrom
exKAZUu:fix/route-handler-request-rewrap
Open

fix: keep auto-mode route handler request re-wrappable by fetch handler libraries (oRPC/Hono) on Workers#2558
exKAZUu wants to merge 2 commits into
cloudflare:mainfrom
exKAZUu:fix/route-handler-request-rewrap

Conversation

@exKAZUu

@exKAZUu exKAZUu commented Jul 8, 2026

Copy link
Copy Markdown

Fixes #2557

Problem

App Router route handlers routinely hand the incoming request to fetch-based handler libraries — oRPC (RPCHandler / BodyLimitPlugin), Hono, etc. — which re-wrap it via new Request(request, init). On Cloudflare Workers this crashes with:

[vinext] Route handler error: TypeError: Invalid URL: [object Request]

and returns an empty 500. In a real app this silently broke every mutation (each routed through one oRPC /api/rpc route handler) after migrating to vinext.

Root cause

createTrackedAppRouteRequest() hands the handler a Proxy wrapping a NextRequest (to track dynamic access). A Proxy is not a genuine Request, so re-wrapping it fails:

  • workerd: new Request(proxy, init) can't read the native internal slots, so it coerces the proxy to a URL string. String(proxy) is "[object Request]" (via Symbol.toStringTag), so the re-wrapped request's url becomes "[object Request]" and the library's later new URL(request.url) throws TypeError: Invalid URL: [object Request].
  • Node/undici: the same call throws TypeError: Cannot read private member #state from an object whose class did not declare it.

proxy.url on its own is fine; the breakage only happens on re-wrap, which these libraries do routinely.

Fix

In the common "auto" (dynamic) mode, install dynamic-access tracking via own accessor properties on the real NextRequest instead of returning a Proxy. The handler now receives a genuine, re-wrappable Request. Native-slot reads during new Request(req, init) bypass JS getters, so tracking is unchanged.

A small reentrancy guard prevents getters that internally read another tracked property (e.g. the shim's ip/geo reading this.headers) from double-reporting — matching the previous behaviour where the Proxy read through the untracked target.

The value-substituting static-generation modes (force-static, error) intentionally return different values than the underlying request and are not meant to be re-wrapped, so they keep using the Proxy.

Tests

Added two regression tests to tests/app-route-handler-runtime.test.ts:

  • stays re-wrappable via new Request(request, init) for fetch handler libraries — mirrors oRPC's BodyLimitPlugin (new Request(request, { body, duplex })) and asserts url, method, headers and body survive. This fails before the change (undici throws Cannot read private member #state).
  • keeps a GET route handler request re-wrappable without a body.

All existing tests in the file continue to pass (dynamic tracking, ip/geo, nextUrl, body-reading, force-static and error modes).

Verification

  • pnpm exec vitest run --project unit tests/app-route-handler-runtime.test.ts → 15/15 pass
  • tests/app-route-handler-execution.test.ts + tests/app-route-handler-cache.test.ts → 15/15 pass
  • pnpm exec vp check on the changed files → no warnings, lint errors, or type errors
  • Reproduced the original failure and verified the fix on workerd (wrangler dev) and Node v26.

A changeset is included (vinext: patch).

Note: I ran the affected unit tests and static checks locally. I did not run the full pnpm test / pnpm test:e2e suites in this environment (unrelated files depend on generated build artifacts), so please let CI exercise them.

exKAZUu added 2 commits July 9, 2026 00:28
App Router route handlers routinely pass their request to fetch-based
handler libraries (oRPC, Hono, …) that re-wrap it via
`new Request(request, init)`. The tracked request was a Proxy, which is
not a real Request: on workerd `new Request(proxy)` cannot read the
native internal slots and coerces the proxy to the URL string
"[object Request]", so the library's `new URL(request.url)` throws
`TypeError: Invalid URL: [object Request]` (undici throws
"Cannot read private member #state"). This crashed every mutation
routed through such a handler in production.

In "auto" mode, track dynamic access with own accessor properties on the
real NextRequest instead of a Proxy, keeping the object a genuine,
re-wrappable Request. A reentrancy guard prevents getters that read
other tracked props internally (e.g. ip/geo reading headers) from
double-reporting. Value-substituting static modes keep using the Proxy.
…tionale

Per AGENTS.md, changesets are generated by CI from Conventional Commit
subjects; hand-authored `.changeset/*.md` files must not be committed.

Also document why force-static/error modes keep the Proxy: own accessors
only shadow JS getters while `new Request(req)` copies native slots, so
stubbing those modes would leak the real URL/headers/cookies/body on
re-wrap. Keeping the Proxy makes re-wrap throw instead of leak.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Route handler request (a Proxy) breaks fetch handler libraries that re-wrap it via new Request() on Workers

1 participant