From 0f59a59f5f08f0b1b4ba3128aca50e724b8f72ef Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 13:30:12 +0200 Subject: [PATCH 1/2] fix(platform): read the hosted request context through a client-safe seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config loader's hosted-identity assertions called getCurrentRequestContext() synchronously, keeping the AsyncLocalStorage module — and its module-scope node:async_hooks import — reachable from the browser entrypoint as the last baselined client-bundle leak. Introduce platform/request-context-access.ts, a holder with no async_hooks anywhere: the server request-context module registers its real accessor when it loads, and the loader reads through currentRequestContext(). The only context writer (multi-project-adapter's asyncLocalStorage.run) imports the server module, so a populated context always implies the accessor is registered; in the browser nothing registers and the holder returns null, which is that environment's correct answer. The client-bundle baseline is now empty: the boundary gate goes from ratchet to hard invariant. --- scripts/lint/client-bundle-baseline.json | 4 +-- src/config/loader.ts | 8 ++--- .../adapters/fs/veryfront/request-context.ts | 6 ++++ src/platform/request-context-access.ts | 35 +++++++++++++++++++ 4 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 src/platform/request-context-access.ts diff --git a/scripts/lint/client-bundle-baseline.json b/scripts/lint/client-bundle-baseline.json index d7c9f2d674..6f62923081 100644 --- a/scripts/lint/client-bundle-baseline.json +++ b/scripts/lint/client-bundle-baseline.json @@ -1,8 +1,6 @@ { "note": "Known server modules reachable from a browser entrypoint (#3670). Burn down, never grow. Regenerate with: deno run --allow-read --allow-write scripts/lint/audit-client-bundle.ts --update", "entrypoints": { - "src/index.client.ts": [ - "src/platform/adapters/fs/veryfront/request-context.ts" - ] + "src/index.client.ts": [] } } diff --git a/src/config/loader.ts b/src/config/loader.ts index 9b36ed7845..6d921fde27 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -29,7 +29,7 @@ import { getHostEnv } from "#veryfront/platform/compat/process/env.ts"; import { LRUCache } from "#veryfront/utils/lru-wrapper.ts"; import { registerLRUCache } from "#veryfront/cache/registry.ts"; import { VERYFRONT_CONFIG_FILES } from "./config-files.ts"; -import { getCurrentRequestContext } from "#veryfront/platform/adapters/fs/veryfront/request-context.ts"; +import { currentRequestContext } from "#veryfront/platform/request-context-access.ts"; import type { ModuleLexer } from "#veryfront/extensions/bundler/module-lexer.ts"; import { tryResolve as tryResolveContract } from "#veryfront/extensions/contracts.ts"; import { importFirstPartyExtensionModule } from "#veryfront/extensions/first-party-import.ts"; @@ -1935,7 +1935,7 @@ interface InternalGetConfigOptions extends GetConfigOptions { } function getVirtualConfigSourceContext(): VirtualConfigSourceContext | undefined { - const source = getCurrentRequestContext(); + const source = currentRequestContext(); if (!source) return undefined; return { @@ -2026,7 +2026,7 @@ function assertMatchingVirtualConfigSource( function assertMatchingHostedProjectIdentity( cacheKey: string, - actual: ReturnType, + actual: ReturnType, ): void { if (!actual) { throw CACHE_INVARIANT_VIOLATION.create({ @@ -2144,7 +2144,7 @@ function getConfigInternal( assertMatchingVirtualConfigSource(options.sourceContext, ambientSourceContext); } if (hostedMultiProjectFilesystem) { - assertMatchingHostedProjectIdentity(options!.cacheKey!, getCurrentRequestContext()); + assertMatchingHostedProjectIdentity(options!.cacheKey!, currentRequestContext()); } const sourceContext = hasQualifiedCacheIdentity ? options.sourceContext ?? ambientSourceContext diff --git a/src/platform/adapters/fs/veryfront/request-context.ts b/src/platform/adapters/fs/veryfront/request-context.ts index e1c58faaa1..686ce70c17 100644 --- a/src/platform/adapters/fs/veryfront/request-context.ts +++ b/src/platform/adapters/fs/veryfront/request-context.ts @@ -1,5 +1,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; +import { registerRequestContextAccessor } from "#veryfront/platform/request-context-access.ts"; + export interface RequestContext { projectSlug: string; projectId?: string; @@ -25,6 +27,10 @@ export function getCurrentRequestContext(): RequestContext | null { return asyncLocalStorage.getStore() ?? null; } +// Shared client/server code reads the context through the client-safe holder; +// loading this module is what makes the real accessor available there. +registerRequestContextAccessor(getCurrentRequestContext); + /** * Wraps a callback to preserve the current AsyncLocalStorage context. * diff --git a/src/platform/request-context-access.ts b/src/platform/request-context-access.ts new file mode 100644 index 0000000000..7698c716be --- /dev/null +++ b/src/platform/request-context-access.ts @@ -0,0 +1,35 @@ +/** + * Client-safe access to the hosted request context. + * + * The real context lives in an AsyncLocalStorage inside + * `adapters/fs/veryfront/request-context.ts`, whose module-scope + * `node:async_hooks` import must stay out of browser bundles. Shared + * client/server code (the config loader's hosted-identity assertions) reads + * the context through this holder instead: the server module registers its + * accessor when it loads, and in the browser nothing ever registers, so + * `currentRequestContext()` returns null — the correct answer there, since a + * hosted request context only exists while the server VFS adapter runs a + * request. + * + * Registration cannot be observed "too early": the only writer of the + * context (`multi-project-adapter.ts`'s `asyncLocalStorage.run`) imports the + * server module, so any populated context implies the accessor is in place. + */ + +import type { RequestContext } from "./adapters/fs/veryfront/request-context.ts"; + +export type { RequestContext }; + +let accessor: (() => RequestContext | null) | undefined; + +/** Called by the server request-context module when it loads. */ +export function registerRequestContextAccessor( + fn: () => RequestContext | null, +): void { + accessor = fn; +} + +/** The current hosted request context, or null outside a server request. */ +export function currentRequestContext(): RequestContext | null { + return accessor?.() ?? null; +} From c235f6809dff27d14077050df618c5dc961f48f7 Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Sun, 16 Aug 2026 13:40:06 +0200 Subject: [PATCH 2/2] fix(platform): use the internal alias for the holder's type import Review follow-up: cross-boundary imports go through #veryfront/*, even type-only ones. --- src/platform/request-context-access.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/request-context-access.ts b/src/platform/request-context-access.ts index 7698c716be..727ead4ece 100644 --- a/src/platform/request-context-access.ts +++ b/src/platform/request-context-access.ts @@ -16,7 +16,7 @@ * server module, so any populated context implies the accessor is in place. */ -import type { RequestContext } from "./adapters/fs/veryfront/request-context.ts"; +import type { RequestContext } from "#veryfront/platform/adapters/fs/veryfront/request-context.ts"; export type { RequestContext };