Skip to content

feat: AsyncLocalStorage for ExecutionContext (ctx) propagation#410

Merged
james-elicx merged 1 commit intomainfrom
feat/execution-context-als
Mar 10, 2026
Merged

feat: AsyncLocalStorage for ExecutionContext (ctx) propagation#410
james-elicx merged 1 commit intomainfrom
feat/execution-context-als

Conversation

@james-elicx
Copy link
Collaborator

Summary

Extracts ExecutionContext (ctx) propagation from #405 into a standalone feature. Adds an AsyncLocalStorage-based mechanism so Cloudflare Workers' ctx (used for waitUntil) is accessible anywhere in the call stack without manual threading.

What changed

  • shims/request-context.ts — New ALS module keyed on Symbol.for("vinext.requestContext.als"), following the same pattern as headers.ts, cache.ts, and navigation-state.ts. Exports runWithExecutionContext(ctx, fn) and getRequestExecutionContext().
  • server/app-router-entry.tsfetch(request, _env?, ctx?) now wraps the RSC handler in runWithExecutionContext(ctx, ...) when ctx is present.
  • entries/app-rsc-entry.ts — Generated RSC entry imports runWithExecutionContext and getRequestExecutionContext from the shim for downstream use.
  • deploy.ts — Generated Cloudflare worker entry updated to fetch(request, env, ctx) and forwards ctx to handler.fetch(request, env, ctx).
  • cloudflare/kv-cache-handler.ts — Uses getRequestExecutionContext() ?? this.ctx so the ALS-provided ctx takes priority over the constructor-injected one.

Design

getRequestExecutionContext() returns null when called outside a request scope (e.g. Node.js dev server), making it safe to call anywhere. The ALS scope is established by app-router-entry.ts before any RSC/SSR/routing logic runs, so it's available throughout the entire request lifecycle.

Tests

  • 8 new tests in tests/request-context.test.ts covering: null outside scope, correct value inside scope, async propagation, concurrent request isolation, nested scope override/restore, and waitUntil callable from inside scope.
  • Updated tests/deploy.test.ts assertions for the new worker entry signature.
  • Regenerated 6 snapshots in tests/__snapshots__/entry-templates.test.ts.snap.

Related

Extracted from #405 (ISR caching) — the ctx plumbing is a prerequisite for waitUntil-based background cache writes but is generally useful on its own.

@github-actions
Copy link

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

@james-elicx james-elicx force-pushed the feat/execution-context-als branch from 20b5bac to 8c88bdc Compare March 10, 2026 14:49
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 10, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@410

commit: 1bd622e

@james-elicx
Copy link
Collaborator Author

/bigbonk review

@james-elicx james-elicx marked this pull request as ready for review March 10, 2026 14:54
Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: AsyncLocalStorage for ExecutionContext (ctx) propagation

Overall this is a clean, well-structured PR that follows the established ALS patterns in the codebase. The implementation is correct for the App Router path and provides a solid foundation for waitUntil-based background work. A few observations:

Looks Good

  • ALS pattern is consistent — The Symbol.for("vinext.requestContext.als") + globalThis storage pattern matches headers.ts, cache.ts, and navigation-state.ts. The intentional omission of a fallback state (returning null instead) is the right design for ExecutionContext, since there's no meaningful default.

  • Correct scope placementapp-router-entry.ts wraps at the outermost level (before rscHandler(request) is called), so the ctx is available throughout the entire request lifecycle including RSC streaming. The ctx ? runWithExecutionContext(ctx, handleFn) : handleFn() ternary correctly handles the Node.js dev case where ctx is undefined.

  • KV cache handler fallbackgetRequestExecutionContext() ?? this.ctx in _deleteInBackground and _putInBackground is the right precedence: prefer ALS (always correct per-request), fall back to constructor-injected ctx.

  • Test coverage is thorough — The 8 tests cover the important cases: null outside scope, value inside scope, async propagation, concurrent request isolation, nested scope override/restore, and waitUntil callable. The concurrent request test with staggered timeouts is a good pattern.

Issue: Unused imports in generated RSC entry

app-rsc-entry.ts adds this import to the generated code:

import { runWithExecutionContext as _runWithExecutionContext, getRequestExecutionContext as _getRequestExecutionContext } from "...request-context.js";

But neither _runWithExecutionContext nor _getRequestExecutionContext is actually called anywhere in the generated RSC entry module. The RSC entry doesn't need them because the ALS scope is established by app-router-entry.ts before calling rscHandler(request), and getRequestExecutionContext() is called by kv-cache-handler.ts directly (which imports from the shim itself).

These imports are dead code in the generated entry. This won't cause runtime issues, but it adds unnecessary bytes to the RSC bundle and may confuse future readers into thinking the generated entry is responsible for ctx propagation.

Suggestion: Either remove these imports, or if they're intended as a forward-looking hook for #405 (ISR background cache writes in the RSC entry), add a comment explaining that.

Note: Pages Router worker entry doesn't use ALS-based ctx

The generated Pages Router worker entry in deploy.ts threads ctx explicitly through function arguments (runMiddleware(request, ctx), renderPage(request, resolvedUrl, null, ctx)) but does not wrap in runWithExecutionContext(). This means getRequestExecutionContext() returns null in Pages Router Workers.

This is fine today because:

  1. The KV cache handler falls back to this.ctx from the constructor
  2. The Pages Router production server path already threads ctx explicitly

But it's worth noting that if future code relies on getRequestExecutionContext() without the constructor fallback, it won't work in Pages Router. This could be addressed in a follow-up (not blocking for this PR).

Minor Nits

  1. ExecutionContext interface duplicationapp-router-entry.ts defines its own ExecutionContext interface (lines 19-22) despite the shim already exporting ExecutionContextLike. Consider importing and reusing ExecutionContextLike from the shim to reduce duplication. Though I understand the interface in app-router-entry.ts predates this PR and includes passThroughOnException() as required (vs optional in the shim), so this is a minor style point.

  2. passThroughOnException is optional in ExecutionContextLike — The Workers runtime always provides it, so marking it optional (passThroughOnException?()) is a deviation from the actual Cloudflare Workers type. This is fine for the minimal structural interface approach, but worth noting that callers who need passThroughOnException would need a type guard.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 10, 2026

Review posted on PR #410. Here's a summary of my findings:

Overall: Clean, well-structured PR that correctly follows the established ALS patterns in the codebase. The implementation is sound for the App Router path.

Key findings:

  1. Unused imports in generated RSC entry_runWithExecutionContext and _getRequestExecutionContext are imported in the generated RSC entry code (app-rsc-entry.ts) but never called. These are dead code since the ALS scope is established by app-router-entry.ts before calling the RSC handler. They should either be removed or annotated as forward-looking for feat: ISR caching for App Router (production-only, stale-while-revalidate) #405.

  2. Pages Router gap (non-blocking) — The Pages Router worker entry threads ctx explicitly via function arguments but doesn't use runWithExecutionContext(), so getRequestExecutionContext() returns null there. This is fine today since the KV cache handler has the constructor fallback, but worth noting for future code.

  3. Minor interface duplicationapp-router-entry.ts defines its own ExecutionContext interface rather than reusing ExecutionContextLike from the shim.

github run

@james-elicx james-elicx force-pushed the feat/execution-context-als branch from 8c88bdc to ded215f Compare March 10, 2026 15:07
Adds request-context.ts shim with runWithExecutionContext() and
getRequestExecutionContext() so Cloudflare Workers ctx is accessible
anywhere in the call stack without manual threading.

- New shims/request-context.ts with ALS keyed on Symbol.for('vinext.requestContext.als')
- app-router-entry.ts wraps each request in runWithExecutionContext(ctx, ...)
- app-rsc-entry.ts generated code imports the helpers for downstream use
- deploy.ts generated worker entry forwards ctx in fetch(request, env, ctx)
- kv-cache-handler.ts uses getRequestExecutionContext() ?? this.ctx
- 8 new tests in tests/request-context.test.ts
@james-elicx james-elicx force-pushed the feat/execution-context-als branch from ded215f to 7a3d9a3 Compare March 10, 2026 15:21
@james-elicx
Copy link
Collaborator Author

/bigbonk re-review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

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

Review: AsyncLocalStorage for ExecutionContext propagation

Clean, well-structured PR. The implementation is correct and consistent with the existing ALS patterns in the codebase. Approving with a couple of observations.

What looks good

Consistent ALS pattern. request-context.ts follows the exact same Symbol.for() on globalThis pattern as headers.ts, cache.ts, and navigation-state.ts. The intentional choice to return null (no fallback state) is correct — there's no meaningful default for an ExecutionContext.

Correct scope placement. In app-router-entry.ts, the ALS scope wraps at the outermost level before rscHandler(request), so ctx is available throughout the entire request lifecycle including RSC streaming. The ctx ? runWithExecutionContext(ctx, handleFn) : handleFn() ternary correctly handles Node.js dev where ctx is undefined.

KV cache handler precedence. getRequestExecutionContext() ?? this.ctx is the right order — prefer the per-request ALS context (always correct), fall back to the constructor-injected ctx. This eliminates the need to thread ctx through every function call while maintaining backward compatibility.

Pages Router integration. The runMiddleware and renderPage wrapper functions in pages-server-entry.ts are well-structured — they establish the ALS scope before delegating to the actual implementation (_runMiddleware, _renderPage). This means triggerBackgroundRegeneration and middleware's fetchEvent.drainWaitUntil() can both call _getRequestExecutionContext() directly.

triggerBackgroundRegeneration simplification. Removing the explicit ctx parameter in favor of _getRequestExecutionContext() is a nice cleanup — callers no longer need to thread ctx through to the ISR layer.

Test coverage. The 8 tests cover the essential cases: null outside scope, value inside scope, async propagation, concurrent isolation, nested override/restore, and waitUntil callable. The concurrent test with staggered timeouts is a good pattern for verifying ALS isolation.

Observations (non-blocking)

  1. Pages Router worker entry doesn't use runWithExecutionContext at the top level. The generated Pages Router worker entry in deploy.ts passes ctx explicitly to runMiddleware(request, ctx) and renderPage(request, resolvedUrl, null, ctx), which then internally wrap with runWithExecutionContext. This works, but it means code that runs between those calls (e.g., the redirect/rewrite/header matching logic at lines 600-715 of deploy.ts) does not have access to the execution context via ALS. This is fine today since none of that code needs waitUntil, but differs from the App Router path where the entire request is wrapped. Worth noting for when future code might want to call getRequestExecutionContext() from a shared utility that runs in both router contexts.

  2. passThroughOnException optionality. ExecutionContextLike marks passThroughOnException as optional, while the Workers runtime always provides it. This is fine for the minimal structural interface, but callers wanting passThroughOnException would need a type guard. Not an issue for the current usage (only waitUntil is called).

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 10, 2026

Approved PR #410. Here's a summary of my review:

Verdict: Approve

The implementation is clean and correct. It follows the established ALS pattern (Symbol.for() on globalThis) used by headers.ts, cache.ts, and navigation-state.ts, making it consistent and predictable.

Key strengths:

  • Correct scope placement — ALS wraps at the outermost level in app-router-entry.ts
  • KV cache handler correctly prefers ALS context over constructor-injected ctx
  • Pages Router integration properly wraps both runMiddleware and renderPage
  • Nice simplification of triggerBackgroundRegeneration (removes explicit ctx threading)
  • Thorough test coverage including concurrent isolation

Two non-blocking observations noted:

  1. The Pages Router worker entry wraps individual calls (runMiddleware, renderPage) rather than the entire request, so code between those calls doesn't have ALS access — fine today but worth noting for future work.
  2. passThroughOnException is optional in ExecutionContextLike — not an issue since only waitUntil is used.

github run

@james-elicx james-elicx merged commit 36f1775 into main Mar 10, 2026
26 checks passed
@james-elicx james-elicx deleted the feat/execution-context-als branch March 10, 2026 15:28
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.

1 participant