Skip to content

fix(server): evaluate lazy route modules outside the request context - #2740

Merged
james-elicx merged 6 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-lazy-route-import-request-context
Jul 31, 2026
Merged

fix(server): evaluate lazy route modules outside the request context#2740
james-elicx merged 6 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-lazy-route-import-request-context

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

The decision boundary

A route module's top-level scope must not be able to observe a request. It is evaluated exactly once per isolate and its namespace is cached forever, so anything it captures from a request becomes shared state for every user that follows.

That contract held for free while route modules were eagerly imported at RSC-entry evaluation — there was no request in scope to capture. Making them lazy quietly removed it.

The bug

app-rsc-manifest.ts emits page/route-handler modules as () => import(...) thunks, and ensureAppRouteModulesLoaded resolves them from app-rsc-handler.ts:1167 — which is inside the runWithRequestContext(...) scope opened at app-rsc-handler.ts:1438.

A dynamic import() propagates AsyncLocalStorage into the imported module's top-level evaluation, not just into the calling frame:

await als.run({ secret: 'victim-cookie' }, () => import('./mod.mjs'));
// mod.mjs, at module scope:  als.getStore() -> { secret: 'victim-cookie' }

So for an app with module-scope request-API use:

// app/dashboard/page.tsx
const session = (await cookies()).get('session')?.value  // module scope
export default function Page() { return <Dash session={session} /> }

…the first request to reach /dashboard binds session to its own cookie. ensureAppRouteModulesLoaded then caches the namespace on route.page and sets __loaded = true, so every subsequent request in that isolate skips the import and reads the first visitor's session.

Before the lazy change this same code threw cookies() can only be called from a Server Component, Route Handler, or Server Action at startup — loud, and correct. Next.js behaves the same way: findPageComponents/loadComponents runs in base-server.ts before app-render.tsx enters the request store via workUnitAsyncStorage.run, so module scope never sees a request there either.

The fix

runOutsideRequestScopes composes AsyncLocalStorage.exit() over every ALS handed out by getOrCreateAls. exit() is one of the methods workerd implements (unlike enterWith()/disable()), and the registry's NoopAsyncLocalStorage already provides it for runtimes without node:async_hooks.

Exiting one store is not enough, and that mistake is not fail-safe. vinext's request stores nest at different depths: the Cloudflare entry (app-router-entry.ts:173) enters the standalone execution-context ALS outside the unified request context, and prerendering enters the work-unit store inside it. Worse, after() takes its getRequestExecutionContext() fallback precisely when the unified store is absent — so a unified-only exit would have enabled top-level after() to attach to the first request's waitUntil rather than closing it. Registry-wide exit is also future-proof by construction: new shims enrol themselves as they are created, with no enumeration to keep in sync.

Commit 1 — route modules. One guard at the single place every lazy thunk is invoked. pushFieldLoad and pushArrayLoads in app-route-module-loader.ts are the only call sites, so all nine ensureRouteLoaded callers (RSC handler, page dispatch, route-handler dispatch, server actions, intercept layouts) are covered by two lines:

runOutsideRequestScopes(loader).then((module) => { target[field] = module })

Commit 2 — global-not-found. resolveGlobalNotFoundModule in app-fallback-renderer.ts is a sibling call path with the identical mechanism: it imports the user's app/global-not-found.tsx on the first route-miss 404, from inside that request's context, and memoizes the promise for the worker's lifetime. Not introduced by the lazy-route change, but the same class of bug on user-authored code, so it is fixed here rather than left behind.

In both cases the guard wraps only the import — the .then that assigns the resolved module still runs in the request's context, so nothing downstream changes.

Scenario-level behavior

Route module top-level code Before lazy imports On main After
no request-API use (the norm) works works works (unchanged)
cookies() / headers() at module scope throws at startup 🔴 captures request #1, serves it to #2..N throws, as before
after() at module scope throws at startup binds to request #1's waitUntil throws, as before
getRequestExecutionContext() at module scope null caches request #1's worker context null, as before

Only apps in rows 2–3 change, and they change back to the pre-lazy behavior.

Validation

tests/app-route-module-loader.test.ts — new case driven through a real dynamic import() of tests/fixtures/module-scope-request-capture.ts, which reads cookies() at module scope exactly as a vulnerable app would. The test reads the live cookie inside the same runWithRequestContext scope first, so a pass proves isolation rather than a merely absent context:

expect(liveCookie).toBe("victim-secret")                    // context IS active at the call site
expect(page.moduleScopeCookieAccess).toBe("rejected-no-request-context")

Confirmed failing before the fix, passing after — pre-fix: expected 'read:victim-secret' to be 'rejected-no-request-context'.

tests/app-fallback-renderer.test.ts — the same shape for global-not-found, driven through renderer.renderNotFound(null, ...) (the route-miss path) inside a live request context. Also confirmed failing before, passing after, with the same pre-fix message.

tests/app-route-module-loader.test.ts — a second case for the nesting, built through the real runWithExecutionContextrunWithRequestContext → hydration chain: module scope must see getRequestExecutionContext() === null, after() must throw after() was called outside a request scope, and waitUntil must never be called. Confirmed failing against a unified-only exit.

tests/als-registry.test.ts (pre-existing) earned its keep here: an earlier draft imported work-unit-async-storage into the registry, and this test caught that it would crash client bundles where node:async_hooks resolves to a constructor-less stub. Hence the self-registration direction.

Also green: app-rsc-handler, app-page-route-wiring, app-page-dispatch, app-route-handler-dispatch, app-page-request, app-server-action-execution, app-rsc-route-matching (516 tests), plus the pre-commit full vp check and knip on both commits.

Review path

  1. packages/vinext/src/shims/internal/als-registry.ts — the runOutsideRequestScopes primitive and the registry that feeds it.
  2. packages/vinext/src/server/app-route-module-loader.ts — the two call sites it wraps; worth confirming these are still the only places a thunk is invoked.
  3. packages/vinext/src/server/app-fallback-renderer.ts — the sibling global-not-found path.
  4. tests/app-route-module-loader.test.ts + fixture, tests/app-fallback-renderer.test.ts — the regression tests.

Page and route-handler modules moved from eager RSC-entry evaluation to
lazy `() => import()` thunks resolved on the first request that matches the
route. A dynamic import propagates AsyncLocalStorage into the imported
module's top-level evaluation, and `ensureAppRouteModulesLoaded` is awaited
inside `runWithRequestContext`, so module-scope `headers()`/`cookies()`
bound to whichever request happened to reach the route first. ESM evaluates
a module once per isolate and the namespace is cached on the route with
`__loaded = true`, so that first visitor's session data was then reused for
every later request.

Invoke every lazy thunk through a new `runOutsideRequestContext`
(`AsyncLocalStorage.exit()`, which workerd implements) so module scope sees
no request. This restores the eager-evaluation contract the lazy loader
replaced and matches Next.js, which loads components in `base-server` before
`app-render` enters the request store.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Jul 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2740
npm i https://pkg.pr.new/create-vinext-app@2740
npm i https://pkg.pr.new/@vinext/types@2740
npm i https://pkg.pr.new/vinext@2740

commit: 5847f0f

`resolveGlobalNotFoundModule` imports the user's `app/global-not-found.tsx`
on the first route-miss 404 and caches the promise for the worker's
lifetime. That import runs inside the 404-triggering request's context, so
module-scope `headers()`/`cookies()` in global-not-found.tsx bound to that
first visitor and were then served to everyone after them — the same
mechanism as the lazy route-module thunks, on a sibling call path.

Route it through `runOutsideRequestContext` too.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e2f32ff402

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/unified-request-context.ts Outdated
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 5847f0f against base 25dc2f3 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 134.3 KB 134.3 KB ⚫ +0.0%
Client entry size (gzip) vinext 121.9 KB 122.0 KB ⚫ +0.0%
Dev server cold start vinext 2.87 s 2.86 s ⚫ -0.2%
Production build time vinext 3.11 s 3.14 s ⚫ +1.0%
RSC entry closure size (gzip) vinext 111.2 KB 111.3 KB ⚫ +0.1%
Server bundle size (gzip) vinext 188.9 KB 189.1 KB ⚫ +0.1%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

Exiting only the unified request context was not enough. The Cloudflare
entry (`app-router-entry.ts:173`) enters the standalone execution-context
ALS *outside* it, and prerendering enters the work-unit store *inside* it,
so both stayed visible to module scope. Worse, `after()` takes its
`getRequestExecutionContext()` fallback precisely when the unified store is
absent — so the partial exit enabled that path instead of closing it,
letting top-level `after()` attach to the first request's `waitUntil`.

`runOutsideRequestScopes` now exits every ALS handed out by
`getOrCreateAls`, which is future-proof by construction: new shims are
enrolled as they are created, with no enumeration to keep in sync.
`workUnitAsyncStorage` registers itself via `registerAlsForScopeExit`
because it must stay module-local (Sentry resolves it by specifier), and
registering from that side keeps `als-registry` importing nothing but
`node:async_hooks` — it stays evaluable in client bundles where that
resolves to a constructor-less stub.

Reported by Codex on cloudflare#2740.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d5335ccc6c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/server/app-route-module-loader.ts
The intercepting-route page and not-found modules were imported inline at
their call sites — `resolveAppPageInterceptState` in app-page-request.ts and
the generated `probePage` path — so they missed the isolation every other
route module gets. They are user modules cached on the intercept for the
isolate's lifetime, so module-scope `cookies()`/`headers()` could still
capture the first visitor to an intercepted route.

Move both onto the shared loader as `loadAppInterceptPage` /
`loadAppInterceptNotFound`, beside the `loadAppInterceptLayouts` that already
lives there. Both call sites now route through one guarded implementation
instead of two hand-rolled copies of the same dedup block.

Reported by Codex on cloudflare#2740.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 7db2e7bb9e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: lazy route modules outside request context

Solid, well-reasoned fix. The core insight is correct and important: a dynamic import() propagates AsyncLocalStorage into the imported module's top-level evaluation, so hydrating lazy route modules inside runWithRequestContext(...) lets module-scope cookies()/headers()/after() capture the first request and serve it to every subsequent request in the isolate. I verified this against the described mechanism and the code.

What I checked and confirmed

  • Registry-wide exit is the right call. runOutsideRequestScopes composing als.exit() over every ALS from getOrCreateAls (plus the self-registered workUnitAsyncStorage) is correct. Exiting only the unified store would leave the standalone execution-context ALS (entered outside the unified scope in the Cloudflare entry) and the work-unit store (entered inside it during prerender) visible. The observation that after()'s getRequestExecutionContext() fallback fires precisely when the unified store is absent — so a partial exit would enable rather than close that path — is subtle and correct. Good catch, and the nesting-depth test locks it in.
  • Self-registration direction is correct. work-unit-async-storage.ts importing registerAlsForScopeExit (rather than the registry importing it) keeps als-registry.ts importing nothing but node:async_hooks, so it stays client-bundle-safe where that resolves to a constructor-less stub. tests/als-registry.test.ts guards this.
  • Only-call-site claim holds. pushFieldLoad/pushArrayLoads are the wrapped seams, and consolidating the intercept page/not-found loads into loadAppInterceptPage/loadAppInterceptNotFound closes the three previously-inline invocations (app-page-request.ts, generated probePage). I swept the remaining () => import() call sites in the generated entry (ssrLoader, route-handler/action/metadata dispatch) — all resolve vinext's own server modules, not user code. app-rsc-route-matching.ts:317 reads a cached __loadState.page (no loader invocation), and app-rsc-handler.ts:931 is a truthiness gate, not an invocation. No missed leak paths found.
  • .then still runs in request context. Only the loader thunk is wrapped; the assignment continuation runs in the original scope, preserving downstream behavior. Confirmed by the liveCookie assertions in the tests.
  • Concurrent dedup improved. hydrateInterceptModule now assigns intercept[field] = await loading for the shared-clone case, fixing a latent gap where a concurrent caller observing the in-flight promise wouldn't get the namespace published onto its own request-local clone. Tested.

Verification

Ran the targeted suites locally — all green:

tests/app-route-module-loader.test.ts (18)
tests/app-page-request.test.ts (38)
tests/als-registry.test.ts (2)
tests/app-fallback-renderer.test.ts (20)

vp check clean on all changed source files. The regression tests are well-designed: reading the live cookie inside the same runWithRequestContext scope before asserting isolation proves the context is genuinely active at the call site (not merely absent), which is the right way to test this.

No blocking issues. One minor note inline.

* the unified store is absent, so a partial exit would enable that path rather
* than close it.
*/
export function runOutsideRequestScopes<T>(fn: () => T): T {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: runOutsideRequestScopes iterates _registry synchronously to build the closure chain, so concurrent getOrCreateAls calls can't mutate the Set mid-iteration (single-threaded, no await between). That's safe today. Worth a one-line comment noting the invariant, since a future refactor that made this async or interleaved an await inside the loop would silently reintroduce a mutation-during-iteration hazard.

Also worth confirming (I believe it holds): any request-scoped ALS must already be registered by the time the loader runs, because entering the request scope necessarily imported the module that called getOrCreateAls. An ALS created for the first time after a request scope is open — if that ever happens — would not be exited. Not a concern for the current stores, but a fragile implicit ordering dependency worth a note near the registry comment.

@ask-bonk

ask-bonk Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my findings.

Review summary for PR #2740

Verdict: Approve — no blocking issues. This is a well-reasoned, thorough security fix.

The bug (confirmed correct)

Making route modules lazy quietly broke an important invariant: a route module's top-level scope must never observe a request. A dynamic import() propagates AsyncLocalStorage into the imported module's top-level evaluation, so hydrating lazy thunks inside runWithRequestContext(...) let module-scope cookies()/headers()/after() capture request #1 and — because the namespace is cached per-isolate — serve it to every subsequent request.

What I verified

  • Registry-wide exit is necessary, not a single store. The stores nest at different depths (execution-context ALS outside the unified scope; work-unit store inside it during prerender). The subtle after() fallback observation — that it takes the getRequestExecutionContext() path precisely when the unified store is absent, so a partial exit would enable the leak — is correct.
  • Self-registration direction keeps als-registry.ts importing only node:async_hooks, preserving client-bundle safety (guarded by als-registry.test.ts).
  • "Only call sites" claim holds. I swept all remaining () => import() invocations — the rest resolve vinext's own server modules, not user code. app-rsc-route-matching.ts:317 reads a cached value; app-rsc-handler.ts:931 is a truthiness gate.
  • .then assignments still run in request context (only the loader is wrapped).
  • Concurrent dedup improvement in hydrateInterceptModule (publishing the resolved namespace onto every request-local clone) is a genuine latent-gap fix.
  • All 78 targeted tests pass locally and vp check is clean on changed files.

One minor inline note (non-blocking)

Flagged the implicit invariants in runOutsideRequestScopes: (1) synchronous Set iteration is safe today but fragile if a future refactor adds an await in the loop, and (2) an ALS created for the first time after a request scope opens wouldn't be exited — worth a comment documenting the ordering dependency.

github run

@james-elicx
james-elicx merged commit a9fe796 into cloudflare:main Jul 31, 2026
56 checks passed
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.

2 participants