From 20cb09d52613de506b37cbb6da03d6da1892c2b7 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 6 May 2026 14:41:22 +1000 Subject: [PATCH 1/7] fix(cache): scope use cache keys by deployment id Shared "use cache" entries only used the vinext build ID as their cache-key seed. That diverges from the upstream Next.js change for cacheHandlers and can let a Cloudflare Worker deployment reuse entries from another deployment when the deployment identity changes independently of the build fallback. The cache runtime now resolves a deployment-aware key seed at execution time, with request-scoped Worker version metadata or NEXT_DEPLOYMENT_ID taking precedence over the build ID. Generated Wrangler config includes the CF_VERSION_METADATA binding, and the App Router Worker entry threads it into the runtime without process-global request races. Tests cover deployment ID precedence, concurrent deployment scope isolation, build ID fallback, and generated Worker config wiring. --- packages/vinext/src/deploy.ts | 9 ++ packages/vinext/src/global.d.ts | 6 + packages/vinext/src/index.ts | 5 + .../vinext/src/server/app-router-entry.ts | 118 ++++++++++-------- packages/vinext/src/shims/cache-runtime.ts | 62 +++++++-- tests/deploy.test.ts | 11 ++ tests/shims.test.ts | 84 +++++++++++++ 7 files changed, 237 insertions(+), 58 deletions(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index a31133458..1f91fea13 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -412,6 +412,11 @@ export function generateWranglerConfig(info: ProjectInfo): string { images: { binding: "IMAGES", }, + // Exposes the Cloudflare Worker version ID at runtime. vinext uses it as + // the deployment seed for shared "use cache" entries when available. + version_metadata: { + binding: "CF_VERSION_METADATA", + }, }; if (info.hasISR) { @@ -457,6 +462,10 @@ import handler from "vinext/server/app-router-entry"; ${isrImports} interface Env { ASSETS: Fetcher;${isrEnvField} + NEXT_DEPLOYMENT_ID?: string; + CF_VERSION_METADATA?: { + id?: string; + }; IMAGES: { input(stream: ReadableStream): { transform(options: Record): { diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index 3c206c885..cdbcf15cf 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -327,6 +327,12 @@ declare global { */ __VINEXT_BUILD_ID?: string; + /** + * Deployment ID string injected via Vite `define` when + * `NEXT_DEPLOYMENT_ID` is present at build time. + */ + __VINEXT_DEPLOYMENT_ID?: string; + /** * JSON-encoded array of `RemotePattern` objects from * `next.config.js` → `images.remotePatterns`. diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index e53115fc9..574b78e2d 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -902,6 +902,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Also used to namespace ISR cache keys so old cached entries from a // previous deploy are never served by the new one. defines["process.env.__VINEXT_BUILD_ID"] = JSON.stringify(nextConfig.buildId); + // Deployment ID — mirrors Next.js' NEXT_DEPLOYMENT_ID seed for shared + // "use cache" entries, falling back to build ID when absent. + defines["process.env.__VINEXT_DEPLOYMENT_ID"] = JSON.stringify( + process.env.NEXT_DEPLOYMENT_ID ?? "", + ); // Build the shim alias map. Exact `.js` variants are included for the // public Next entrypoints that are file-backed in `next/package.json`. diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index 3fe49046c..28e83df32 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -14,6 +14,7 @@ // @ts-expect-error — virtual module resolved by vinext import rscHandler from "virtual:vinext-rsc-entry"; +import { runWithUseCacheDeploymentId } from "vinext/shims/cache-runtime"; import { runWithExecutionContext, type ExecutionContextLike } from "vinext/shims/request-context"; import { resolveStaticAssetSignal } from "./worker-utils.js"; import { @@ -27,69 +28,88 @@ type WorkerAssetEnv = { ASSETS?: { fetch(request: Request): Promise | Response; }; + NEXT_DEPLOYMENT_ID?: string; + CF_VERSION_METADATA?: { + id?: string; + }; }; +function deploymentIdFromEnv(env: WorkerAssetEnv | undefined): string | undefined { + return env?.NEXT_DEPLOYMENT_ID || env?.CF_VERSION_METADATA?.id || undefined; +} + export default { async fetch( request: Request, env?: WorkerAssetEnv, ctx?: ExecutionContextLike, ): Promise { - const url = new URL(request.url); + return runWithUseCacheDeploymentId(deploymentIdFromEnv(env), () => + handleRequest(request, env, ctx), + ); + }, +}; - // Block protocol-relative URL open redirects (//evil.com/, /\evil.com/, - // /%5Cevil.com/, /%2F/evil.com/). Check BEFORE decode so both literal and - // percent-encoded variants are caught — encoded forms survive segment-wise - // decoding and would otherwise reach trailing-slash redirect emitters. - if (isOpenRedirectShaped(url.pathname)) { - return notFoundResponse(); - } +async function handleRequest( + request: Request, + env: WorkerAssetEnv | undefined, + ctx: ExecutionContextLike | undefined, +): Promise { + const url = new URL(request.url); - // Validate that percent-encoding is well-formed. The RSC handler performs - // the actual decode + normalize; we only check here to return a clean 400 - // instead of letting a malformed sequence crash downstream. - try { - decodeURIComponent(url.pathname); - } catch { - // Malformed percent-encoding (e.g. /%E0%A4%A) — return 400 instead of throwing. - return badRequestResponse(); - } + // Block protocol-relative URL open redirects (//evil.com/, /\evil.com/, + // /%5Cevil.com/, /%2F/evil.com/). Check BEFORE decode so both literal and + // percent-encoded variants are caught — encoded forms survive segment-wise + // decoding and would otherwise reach trailing-slash redirect emitters. + if (isOpenRedirectShaped(url.pathname)) { + return notFoundResponse(); + } - // Strip internal headers from inbound requests before any handler or - // middleware sees them. Must happen before the RSC handler runs. - // Builds a new Headers — Request.headers is immutable in Workers. - { - const filteredHeaders = filterInternalHeaders(request.headers); - request = cloneRequestWithHeaders(request, filteredHeaders); - } + // Validate that percent-encoding is well-formed. The RSC handler performs + // the actual decode + normalize; we only check here to return a clean 400 + // instead of letting a malformed sequence crash downstream. + try { + decodeURIComponent(url.pathname); + } catch { + // Malformed percent-encoding (e.g. /%E0%A4%A) — return 400 instead of throwing. + return badRequestResponse(); + } - // Do NOT decode/normalize the pathname here. The RSC handler - // (virtual:vinext-rsc-entry) is the single point of decoding — it calls - // decodeURIComponent + normalizePath on the incoming URL. Decoding here - // AND in the handler would double-decode, causing inconsistent path - // matching between middleware and routing. + // Strip internal headers from inbound requests before any handler or + // middleware sees them. Must happen before the RSC handler runs. + // Builds a new Headers — Request.headers is immutable in Workers. + { + const filteredHeaders = filterInternalHeaders(request.headers); + request = cloneRequestWithHeaders(request, filteredHeaders); + } - // Delegate to RSC handler (which decodes + normalizes the pathname itself), - // wrapping in the ExecutionContext ALS scope so downstream code can reach - // ctx.waitUntil() without having ctx threaded through every call site. - const handleFn = () => rscHandler(request, ctx); - const result = await (ctx ? runWithExecutionContext(ctx, handleFn) : handleFn()); + // Do NOT decode/normalize the pathname here. The RSC handler + // (virtual:vinext-rsc-entry) is the single point of decoding — it calls + // decodeURIComponent + normalizePath on the incoming URL. Decoding here + // AND in the handler would double-decode, causing inconsistent path + // matching between middleware and routing. - if (result instanceof Response) { - if (env?.ASSETS) { - const assetResponse = await resolveStaticAssetSignal(result, { - fetchAsset: (path) => - Promise.resolve(env.ASSETS!.fetch(new Request(new URL(path, request.url)))), - }); - if (assetResponse) return assetResponse; - } - return result; - } + // Delegate to RSC handler (which decodes + normalizes the pathname itself), + // wrapping in the ExecutionContext ALS scope so downstream code can reach + // ctx.waitUntil() without having ctx threaded through every call site. + const handleFn = () => rscHandler(request, ctx); + const result = await (ctx ? runWithExecutionContext(ctx, handleFn) : handleFn()); - if (result === null || result === undefined) { - return notFoundResponse(); + if (result instanceof Response) { + if (env?.ASSETS) { + const assetFetcher = env.ASSETS; + const assetResponse = await resolveStaticAssetSignal(result, { + fetchAsset: (path) => + Promise.resolve(assetFetcher.fetch(new Request(new URL(path, request.url)))), + }); + if (assetResponse) return assetResponse; } + return result; + } - return new Response(String(result), { status: 200 }); - }, -}; + if (result === null || result === undefined) { + return notFoundResponse(); + } + + return new Response(String(result), { status: 200 }); +} diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index e722c5b76..ce447753b 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -6,7 +6,7 @@ * Vite plugin to wrap them with `registerCachedFunction()`. * * The runtime: - * 1. Generates a cache key from build ID + function identity + serialized arguments + * 1. Generates a cache key from deployment/build ID + function identity + serialized arguments * 2. Checks the CacheHandler for a cached value * 3. On HIT: returns the cached value (deserialized via RSC stream) * 4. On MISS: creates an AsyncLocalStorage context for cacheLife/cacheTag, @@ -90,11 +90,43 @@ type RscModule = { decodeReply: (body: string | FormData, options?: unknown) => Promise; }; -function getUseCacheBuildId(): string | undefined { +const _DEPLOYMENT_ID_KEY = Symbol.for("vinext.cacheRuntime.deploymentId"); +const _deploymentIdStorage = getOrCreateAls( + "vinext.cacheRuntime.deploymentIdAls", +); + +/** + * Set the runtime deployment ID used to seed shared "use cache" keys. + * + * Cloudflare exposes version metadata through the Worker `env` object, so this + * cannot be represented by a Vite compile-time define alone. + */ +export function setUseCacheDeploymentId(deploymentId: string | undefined): void { + Reflect.set(globalThis, _DEPLOYMENT_ID_KEY, deploymentId || undefined); +} + +export function runWithUseCacheDeploymentId( + deploymentId: string | undefined, + fn: () => T | Promise, +): T | Promise { + return _deploymentIdStorage.run(deploymentId || undefined, fn); +} + +function getUseCacheDeploymentIdDefine(): string | undefined { + try { + // Keep this direct reference so Vite's define transform can inline it for + // Worker bundles where the process global might not exist at runtime. + return process.env.__VINEXT_DEPLOYMENT_ID; + } catch (error) { + if (error instanceof ReferenceError) return undefined; + throw error; + } +} + +function getUseCacheBuildIdDefine(): string | undefined { try { // Keep this direct reference so Vite's define transform can inline it for - // Worker bundles where the process global might not exist at runtime. A - // typeof process guard would return before the inlined build ID is reached. + // Worker bundles where the process global might not exist at runtime. return process.env.__VINEXT_BUILD_ID; } catch (error) { if (error instanceof ReferenceError) return undefined; @@ -102,8 +134,20 @@ function getUseCacheBuildId(): string | undefined { } } -function buildUseCacheKey(id: string, buildId: string | undefined, argsKey?: string): string { - const scopedId = buildId ? `build:${encodeURIComponent(buildId)}:${id}` : id; +function getUseCacheKeySeed(): string | undefined { + const runtimeDeploymentId = Reflect.get(globalThis, _DEPLOYMENT_ID_KEY); + return ( + _deploymentIdStorage.getStore() || + (typeof runtimeDeploymentId === "string" && runtimeDeploymentId !== "" + ? runtimeDeploymentId + : undefined) || + getUseCacheDeploymentIdDefine() || + getUseCacheBuildIdDefine() + ); +} + +function buildUseCacheKey(id: string, keySeed: string | undefined, argsKey?: string): string { + const scopedId = keySeed ? `build:${encodeURIComponent(keySeed)}:${id}` : id; return argsKey === undefined ? `use-cache:${scopedId}` : `use-cache:${scopedId}:${argsKey}`; } @@ -332,11 +376,11 @@ export function registerCachedFunction Promise => { const rsc = await getRscModule(); + const keySeed = getUseCacheKeySeed(); // Build the cache key. Use encodeReply (RSC protocol) when available — // it correctly handles React elements as temporary references (excluded @@ -359,10 +403,10 @@ export function registerCachedFunction Promise 0 ? stableStringify(args) : undefined; - cacheKey = buildUseCacheKey(id, buildId, argsKey); + cacheKey = buildUseCacheKey(id, keySeed, argsKey); } } catch { // Non-serializable arguments — run without caching diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index f89ce05ca..b79725764 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -369,6 +369,15 @@ describe("generateWranglerConfig", () => { expect(parsed.images).toBeDefined(); expect(parsed.images.binding).toBe("IMAGES"); }); + + it("includes Cloudflare version metadata binding for deployment-scoped cache keys", () => { + mkdir(tmpDir, "app"); + const info = detectProject(tmpDir); + const config = generateWranglerConfig(info); + const parsed = JSON.parse(config); + + expect(parsed.version_metadata).toEqual({ binding: "CF_VERSION_METADATA" }); + }); }); // ─── Worker Entry Generation ───────────────────────────────────────────────── @@ -392,6 +401,8 @@ describe("generateAppRouterWorkerEntry", () => { expect(content).toContain("interface Env"); expect(content).toContain("IMAGES"); expect(content).toContain("ASSETS"); + expect(content).toContain("NEXT_DEPLOYMENT_ID?: string"); + expect(content).toContain("CF_VERSION_METADATA?:"); }); it("declares ExecutionContext interface", () => { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index f62388022..0f0b9ad10 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2491,6 +2491,90 @@ describe('"use cache" runtime', () => { } }); + it("scopes shared cache entries by deployment ID when available", async () => { + // Ported from Next.js: test/production/app-dir/use-cache-cross-deployment/use-cache-cross-deployment.test.ts + // https://github.com/vercel/next.js/blob/07f76411b07de9417d4a6b816f3137cafe1045fc/test/production/app-dir/use-cache-cross-deployment/use-cache-cross-deployment.test.ts + const { registerCachedFunction, setUseCacheDeploymentId } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + setCacheHandler(new MemoryCacheHandler()); + + const previousBuildId = process.env.__VINEXT_BUILD_ID; + const previousDeploymentId = process.env.__VINEXT_DEPLOYMENT_ID; + try { + process.env.__VINEXT_BUILD_ID = "stable-build"; + delete process.env.__VINEXT_DEPLOYMENT_ID; + + let callCount = 0; + const cached = registerCachedFunction(async () => { + callCount++; + return { count: callCount }; + }, "test:deployment-id"); + + setUseCacheDeploymentId("deployment-one"); + expect(await cached()).toEqual({ count: 1 }); + expect(await cached()).toEqual({ count: 1 }); + + setUseCacheDeploymentId("deployment-two"); + expect(await cached()).toEqual({ count: 2 }); + expect(await cached()).toEqual({ count: 2 }); + + setUseCacheDeploymentId(undefined); + expect(await cached()).toEqual({ count: 3 }); + expect(await cached()).toEqual({ count: 3 }); + } finally { + setUseCacheDeploymentId(undefined); + if (previousBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = previousBuildId; + } + if (previousDeploymentId === undefined) { + delete process.env.__VINEXT_DEPLOYMENT_ID; + } else { + process.env.__VINEXT_DEPLOYMENT_ID = previousDeploymentId; + } + } + }); + + it("keeps concurrent request deployment IDs isolated", async () => { + const { registerCachedFunction, runWithUseCacheDeploymentId } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + setCacheHandler(new MemoryCacheHandler()); + + const previousBuildId = process.env.__VINEXT_BUILD_ID; + try { + process.env.__VINEXT_BUILD_ID = "stable-build"; + + let callCount = 0; + const cached = registerCachedFunction(async () => { + callCount++; + return { count: callCount }; + }, "test:concurrent-deployment-id"); + + const [first, second] = await Promise.all([ + runWithUseCacheDeploymentId("deployment-one", () => cached()), + runWithUseCacheDeploymentId("deployment-two", () => cached()), + ]); + + expect(new Set([first.count, second.count])).toEqual(new Set([1, 2])); + expect(callCount).toBe(2); + + expect(await runWithUseCacheDeploymentId("deployment-one", () => cached())).toEqual(first); + expect(await runWithUseCacheDeploymentId("deployment-two", () => cached())).toEqual(second); + expect(callCount).toBe(2); + } finally { + if (previousBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = previousBuildId; + } + } + }); + it("registerCachedFunction respects cacheLife inside cached function", async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); From 60c8ebfe349bc1821065d5c770476f477c84931e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 6 May 2026 14:51:02 +1000 Subject: [PATCH 2/7] fix(config): resolve deployment id for cache seeds Deployment-aware "use cache" keys had runtime Worker and NEXT_DEPLOYMENT_ID support, but vinext did not resolve the Next.js deploymentId config field. That left config-defined deployment identity out of the build-time cache seed. Resolve deploymentId from next.config.js before NEXT_DEPLOYMENT_ID, validate the same string shape Next.js documents, and feed the resolved value into the __VINEXT_DEPLOYMENT_ID define. The process-wide cache-runtime setter is also documented as a fallback rather than the per-request API. Tests cover config precedence, env fallback, empty config values, and invalid deploymentId shapes. --- packages/vinext/src/config/next-config.ts | 28 +++++++++++ packages/vinext/src/index.ts | 2 +- packages/vinext/src/shims/cache-runtime.ts | 6 +-- tests/next-config.test.ts | 57 ++++++++++++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 17664478d..2b8f50b06 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -221,6 +221,8 @@ export type NextConfig = { * Must return a non-empty string, or null to use the default random ID. */ generateBuildId?: () => string | null | Promise; + /** Identifier for deployment-aware cache keys and version skew protection. */ + deploymentId?: string; /** Any other options */ [key: string]: unknown; }; @@ -275,6 +277,8 @@ export type ResolvedNextConfig = { enablePrerenderSourceMaps: boolean; /** Resolved build ID (from generateBuildId, or a random UUID if not provided). */ buildId: string; + /** Resolved deployment ID from next.config.js or NEXT_DEPLOYMENT_ID. */ + deploymentId: string | undefined; /** * Path to a custom cache handler module. file:// URLs are resolved to * filesystem paths via fileURLToPath() during config resolution. @@ -477,6 +481,26 @@ async function resolveBuildId( return trimmed; } +function resolveDeploymentId(configDeploymentId: unknown): string | undefined { + const deploymentId = + configDeploymentId !== undefined ? configDeploymentId : process.env.NEXT_DEPLOYMENT_ID; + if (deploymentId === undefined || deploymentId === "") return undefined; + + if (typeof deploymentId !== "string") { + throw new Error( + "Invalid `deploymentId` configuration: must be a string. https://nextjs.org/docs/messages/deploymentid-not-a-string", + ); + } + + if (!/^[a-zA-Z0-9_-]+$/.test(deploymentId)) { + throw new Error( + "Invalid `deploymentId` configuration: contains invalid characters. Only alphanumeric characters, hyphens, and underscores are allowed. https://nextjs.org/docs/messages/deploymentid-invalid-characters", + ); + } + + return deploymentId; +} + /** * Converts a cache handler path to a filesystem path. * ESM's import.meta.resolve() returns file:// URLs which break when concatenated @@ -501,6 +525,7 @@ export async function resolveNextConfig( ): Promise { if (!config) { const buildId = await resolveBuildId(undefined); + const deploymentId = resolveDeploymentId(undefined); const resolved: ResolvedNextConfig = { env: {}, basePath: "", @@ -526,6 +551,7 @@ export async function resolveNextConfig( enablePrerenderSourceMaps: true, hashSalt: process.env.NEXT_HASH_SALT ?? "", buildId, + deploymentId, }; detectNextIntlConfig(root, resolved); return resolved; @@ -670,6 +696,7 @@ export async function resolveNextConfig( const buildId = await resolveBuildId( config.generateBuildId as (() => string | null | Promise) | undefined, ); + const deploymentId = resolveDeploymentId(config.deploymentId); // Resolve cacheHandler path — handle file:// URLs from import.meta.resolve() const cacheHandler: string | undefined = @@ -706,6 +733,7 @@ export async function resolveNextConfig( enablePrerenderSourceMaps: config.enablePrerenderSourceMaps ?? true, hashSalt, buildId, + deploymentId, }; // Auto-detect next-intl (lowest priority — explicit aliases from diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 574b78e2d..1dd12dd12 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -905,7 +905,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // Deployment ID — mirrors Next.js' NEXT_DEPLOYMENT_ID seed for shared // "use cache" entries, falling back to build ID when absent. defines["process.env.__VINEXT_DEPLOYMENT_ID"] = JSON.stringify( - process.env.NEXT_DEPLOYMENT_ID ?? "", + nextConfig.deploymentId ?? "", ); // Build the shim alias map. Exact `.js` variants are included for the diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index ce447753b..560bf4ac4 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -96,10 +96,10 @@ const _deploymentIdStorage = getOrCreateAls( ); /** - * Set the runtime deployment ID used to seed shared "use cache" keys. + * Set the process-wide fallback deployment ID used to seed shared "use cache" keys. * - * Cloudflare exposes version metadata through the Worker `env` object, so this - * cannot be represented by a Vite compile-time define alone. + * This is for tests and startup-time configuration. Request handlers should use + * runWithUseCacheDeploymentId() so concurrent requests cannot overwrite each other. */ export function setUseCacheDeploymentId(deploymentId: string | undefined): void { Reflect.set(globalThis, _DEPLOYMENT_ID_KEY, deploymentId || undefined); diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index 4c0bbed2f..d22978c3e 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -494,6 +494,7 @@ describe("detectNextIntlConfig", () => { enablePrerenderSourceMaps: true, expireTime: 31_536_000, buildId: "test-build-id", + deploymentId: undefined, ...overrides, }; } @@ -735,6 +736,62 @@ describe("generateBuildId", () => { }); }); +describe("deploymentId", () => { + const OLD_ENV = process.env.NEXT_DEPLOYMENT_ID; + + afterEach(() => { + if (OLD_ENV === undefined) { + delete process.env.NEXT_DEPLOYMENT_ID; + } else { + process.env.NEXT_DEPLOYMENT_ID = OLD_ENV; + } + }); + + it("defaults to undefined when no deployment ID is configured", async () => { + delete process.env.NEXT_DEPLOYMENT_ID; + + const config = await resolveNextConfig(null); + + expect(config.deploymentId).toBeUndefined(); + }); + + it("uses NEXT_DEPLOYMENT_ID when next.config.js does not set deploymentId", async () => { + process.env.NEXT_DEPLOYMENT_ID = "env-deployment"; + + const config = await resolveNextConfig({}); + + expect(config.deploymentId).toBe("env-deployment"); + }); + + it("lets next.config.js deploymentId take precedence over NEXT_DEPLOYMENT_ID", async () => { + process.env.NEXT_DEPLOYMENT_ID = "env-deployment"; + + const config = await resolveNextConfig({ deploymentId: "config-deployment" }); + + expect(config.deploymentId).toBe("config-deployment"); + }); + + it("treats an empty next.config.js deploymentId as unset even when NEXT_DEPLOYMENT_ID is set", async () => { + process.env.NEXT_DEPLOYMENT_ID = "env-deployment"; + + const config = await resolveNextConfig({ deploymentId: "" }); + + expect(config.deploymentId).toBeUndefined(); + }); + + it("throws when deploymentId contains invalid characters", async () => { + await expect(resolveNextConfig({ deploymentId: "bad value" })).rejects.toThrow( + "Invalid `deploymentId` configuration: contains invalid characters", + ); + }); + + it("throws when deploymentId is not a string", async () => { + await expect(resolveNextConfig({ deploymentId: 42 as unknown as string })).rejects.toThrow( + "Invalid `deploymentId` configuration: must be a string", + ); + }); +}); + describe("resolveNextConfig external rewrite warning", () => { afterEach(() => { vi.restoreAllMocks(); From 518b83e06da683dee04990a6c93b24bff2b9c594 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 6 May 2026 18:04:54 +1000 Subject: [PATCH 3/7] refactor(cache): carry deployment id through request context The Worker version metadata path still needs request-scoped state, but cache-runtime should not own a cache-specific deployment ALS or public-looking setter. Move runtime deployment ID storage into the unified request-context layer. The Worker boundary now writes deploymentId into request runtime context, cache-runtime reads that generic request metadata before falling back to inlined deployment/build IDs, and the tests cover direct runtime scopes plus inheritance into unified request contexts. --- .../vinext/src/server/app-router-entry.ts | 4 +- packages/vinext/src/shims/cache-runtime.ts | 33 +------- .../src/shims/unified-request-context.ts | 50 +++++++++++ tests/shims.test.ts | 83 +++++++++++++++---- 4 files changed, 123 insertions(+), 47 deletions(-) diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index 28e83df32..4faaaeaeb 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -14,8 +14,8 @@ // @ts-expect-error — virtual module resolved by vinext import rscHandler from "virtual:vinext-rsc-entry"; -import { runWithUseCacheDeploymentId } from "vinext/shims/cache-runtime"; import { runWithExecutionContext, type ExecutionContextLike } from "vinext/shims/request-context"; +import { runWithRequestRuntimeContext } from "vinext/shims/unified-request-context"; import { resolveStaticAssetSignal } from "./worker-utils.js"; import { cloneRequestWithHeaders, @@ -44,7 +44,7 @@ export default { env?: WorkerAssetEnv, ctx?: ExecutionContextLike, ): Promise { - return runWithUseCacheDeploymentId(deploymentIdFromEnv(env), () => + return runWithRequestRuntimeContext({ deploymentId: deploymentIdFromEnv(env) }, () => handleRequest(request, env, ctx), ); }, diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 560bf4ac4..137f6b534 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -40,6 +40,7 @@ import { getOrCreateAls } from "./internal/als-registry.js"; import { isInsideUnifiedScope, getRequestContext, + getRequestDeploymentId, runWithUnifiedStateMutation, } from "./unified-request-context.js"; @@ -90,28 +91,6 @@ type RscModule = { decodeReply: (body: string | FormData, options?: unknown) => Promise; }; -const _DEPLOYMENT_ID_KEY = Symbol.for("vinext.cacheRuntime.deploymentId"); -const _deploymentIdStorage = getOrCreateAls( - "vinext.cacheRuntime.deploymentIdAls", -); - -/** - * Set the process-wide fallback deployment ID used to seed shared "use cache" keys. - * - * This is for tests and startup-time configuration. Request handlers should use - * runWithUseCacheDeploymentId() so concurrent requests cannot overwrite each other. - */ -export function setUseCacheDeploymentId(deploymentId: string | undefined): void { - Reflect.set(globalThis, _DEPLOYMENT_ID_KEY, deploymentId || undefined); -} - -export function runWithUseCacheDeploymentId( - deploymentId: string | undefined, - fn: () => T | Promise, -): T | Promise { - return _deploymentIdStorage.run(deploymentId || undefined, fn); -} - function getUseCacheDeploymentIdDefine(): string | undefined { try { // Keep this direct reference so Vite's define transform can inline it for @@ -135,15 +114,7 @@ function getUseCacheBuildIdDefine(): string | undefined { } function getUseCacheKeySeed(): string | undefined { - const runtimeDeploymentId = Reflect.get(globalThis, _DEPLOYMENT_ID_KEY); - return ( - _deploymentIdStorage.getStore() || - (typeof runtimeDeploymentId === "string" && runtimeDeploymentId !== "" - ? runtimeDeploymentId - : undefined) || - getUseCacheDeploymentIdDefine() || - getUseCacheBuildIdDefine() - ); + return getRequestDeploymentId() || getUseCacheDeploymentIdDefine() || getUseCacheBuildIdDefine(); } function buildUseCacheKey(id: string, keySeed: string | undefined, argsKey?: string): string { diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 1bb4a929d..0265872a0 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -40,6 +40,8 @@ export type UnifiedRequestContext = { // ── request-context.ts ───────────────────────────────────────────── /** Cloudflare Workers ExecutionContext, or null on Node.js dev. */ executionContext: ExecutionContextLike | null; + /** Deployment identifier for request-scoped cache key seeding. */ + deploymentId: string | undefined; // ── cache-for-request.ts ────────────────────────────────────────── /** Per-request cache for cacheForRequest(). Keyed by factory function reference. */ @@ -61,6 +63,7 @@ export type UnifiedRequestContext = { // --------------------------------------------------------------------------- const _REQUEST_CONTEXT_ALS_KEY = Symbol.for("vinext.requestContext.als"); +const _REQUEST_RUNTIME_CONTEXT_ALS_KEY = Symbol.for("vinext.requestRuntimeContext.als"); const _g = globalThis as unknown as Record; const _als = getOrCreateAls("vinext.unifiedRequestContext.als"); @@ -74,6 +77,24 @@ function _getInheritedExecutionContext(): ExecutionContextLike | null { return executionContextAls?.getStore() ?? null; } +function _getInheritedDeploymentId(): string | undefined { + const unifiedStore = _als.getStore(); + if (unifiedStore) return unifiedStore.deploymentId; + + const requestRuntimeAls = _g[_REQUEST_RUNTIME_CONTEXT_ALS_KEY] as + | AsyncLocalStorage + | undefined; + return requestRuntimeAls?.getStore()?.deploymentId || undefined; +} + +export type RequestRuntimeContext = { + deploymentId?: string | undefined; +}; + +const _requestRuntimeAls = getOrCreateAls( + "vinext.requestRuntimeContext.als", +); + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -104,6 +125,7 @@ export function createRequestContext(opts?: Partial): Uni ssrContext: null, ssrHeadChildren: [], rootParams: null, + deploymentId: _getInheritedDeploymentId(), ...opts, }; } @@ -128,6 +150,34 @@ export function runWithRequestContext( return _als.run(ctx, fn); } +export function runWithRequestRuntimeContext( + ctx: RequestRuntimeContext, + fn: () => Promise, +): Promise; +export function runWithRequestRuntimeContext( + ctx: RequestRuntimeContext, + fn: () => T | Promise, +): T | Promise; +export function runWithRequestRuntimeContext( + ctx: RequestRuntimeContext, + fn: () => T | Promise, +): T | Promise { + const deploymentId = ctx.deploymentId || undefined; + if (isInsideUnifiedScope()) { + return runWithUnifiedStateMutation((uCtx) => { + uCtx.deploymentId = deploymentId; + }, fn); + } + return _requestRuntimeAls.run({ deploymentId }, fn); +} + +export function getRequestDeploymentId(): string | undefined { + if (isInsideUnifiedScope()) { + return getRequestContext().deploymentId; + } + return _requestRuntimeAls.getStore()?.deploymentId || undefined; +} + /** * Run `fn` in a nested unified scope derived from the current request context. * Used by legacy runWith* wrappers to reset or override one sub-state while diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 0f0b9ad10..8cbc51a5d 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2494,8 +2494,10 @@ describe('"use cache" runtime', () => { it("scopes shared cache entries by deployment ID when available", async () => { // Ported from Next.js: test/production/app-dir/use-cache-cross-deployment/use-cache-cross-deployment.test.ts // https://github.com/vercel/next.js/blob/07f76411b07de9417d4a6b816f3137cafe1045fc/test/production/app-dir/use-cache-cross-deployment/use-cache-cross-deployment.test.ts - const { registerCachedFunction, setUseCacheDeploymentId } = + const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); + const { runWithRequestRuntimeContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); const { setCacheHandler, MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); setCacheHandler(new MemoryCacheHandler()); @@ -2512,19 +2514,23 @@ describe('"use cache" runtime', () => { return { count: callCount }; }, "test:deployment-id"); - setUseCacheDeploymentId("deployment-one"); - expect(await cached()).toEqual({ count: 1 }); - expect(await cached()).toEqual({ count: 1 }); + expect( + await runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => cached()), + ).toEqual({ count: 1 }); + expect( + await runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => cached()), + ).toEqual({ count: 1 }); - setUseCacheDeploymentId("deployment-two"); - expect(await cached()).toEqual({ count: 2 }); - expect(await cached()).toEqual({ count: 2 }); + expect( + await runWithRequestRuntimeContext({ deploymentId: "deployment-two" }, () => cached()), + ).toEqual({ count: 2 }); + expect( + await runWithRequestRuntimeContext({ deploymentId: "deployment-two" }, () => cached()), + ).toEqual({ count: 2 }); - setUseCacheDeploymentId(undefined); expect(await cached()).toEqual({ count: 3 }); expect(await cached()).toEqual({ count: 3 }); } finally { - setUseCacheDeploymentId(undefined); if (previousBuildId === undefined) { delete process.env.__VINEXT_BUILD_ID; } else { @@ -2539,8 +2545,10 @@ describe('"use cache" runtime', () => { }); it("keeps concurrent request deployment IDs isolated", async () => { - const { registerCachedFunction, runWithUseCacheDeploymentId } = + const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); + const { runWithRequestRuntimeContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); const { setCacheHandler, MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); setCacheHandler(new MemoryCacheHandler()); @@ -2556,15 +2564,62 @@ describe('"use cache" runtime', () => { }, "test:concurrent-deployment-id"); const [first, second] = await Promise.all([ - runWithUseCacheDeploymentId("deployment-one", () => cached()), - runWithUseCacheDeploymentId("deployment-two", () => cached()), + runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => cached()), + runWithRequestRuntimeContext({ deploymentId: "deployment-two" }, () => cached()), ]); expect(new Set([first.count, second.count])).toEqual(new Set([1, 2])); expect(callCount).toBe(2); - expect(await runWithUseCacheDeploymentId("deployment-one", () => cached())).toEqual(first); - expect(await runWithUseCacheDeploymentId("deployment-two", () => cached())).toEqual(second); + expect( + await runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => cached()), + ).toEqual(first); + expect( + await runWithRequestRuntimeContext({ deploymentId: "deployment-two" }, () => cached()), + ).toEqual(second); + expect(callCount).toBe(2); + } finally { + if (previousBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = previousBuildId; + } + } + }); + + it("inherits request runtime deployment ID when a unified request context is created", async () => { + const { registerCachedFunction } = + await import("../packages/vinext/src/shims/cache-runtime.js"); + const { setCacheHandler, MemoryCacheHandler } = + await import("../packages/vinext/src/shims/cache.js"); + const { createRequestContext, runWithRequestContext, runWithRequestRuntimeContext } = + await import("../packages/vinext/src/shims/unified-request-context.js"); + setCacheHandler(new MemoryCacheHandler()); + + const previousBuildId = process.env.__VINEXT_BUILD_ID; + try { + process.env.__VINEXT_BUILD_ID = "stable-build"; + + let callCount = 0; + const cached = registerCachedFunction(async () => { + callCount++; + return { count: callCount }; + }, "test:inherited-runtime-deployment-id"); + + const first = await runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => + runWithRequestContext(createRequestContext(), () => cached()), + ); + const second = await runWithRequestRuntimeContext({ deploymentId: "deployment-two" }, () => + runWithRequestContext(createRequestContext(), () => cached()), + ); + + expect(first).toEqual({ count: 1 }); + expect(second).toEqual({ count: 2 }); + expect( + await runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => + runWithRequestContext(createRequestContext(), () => cached()), + ), + ).toEqual(first); expect(callCount).toBe(2); } finally { if (previousBuildId === undefined) { From c3cf95c5b3e04eb129a4772a1fbb103aff08d340 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 6 May 2026 18:11:37 +1000 Subject: [PATCH 4/7] refactor(cache): reuse request context for deployment id --- .../vinext/src/server/app-router-entry.ts | 37 ++++++++++--- packages/vinext/src/server/app-rsc-handler.ts | 10 ++++ .../src/shims/unified-request-context.ts | 39 +------------- tests/shims.test.ts | 52 +++++++++++++------ 4 files changed, 78 insertions(+), 60 deletions(-) diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index 4faaaeaeb..21b4cbc09 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -15,7 +15,6 @@ // @ts-expect-error — virtual module resolved by vinext import rscHandler from "virtual:vinext-rsc-entry"; import { runWithExecutionContext, type ExecutionContextLike } from "vinext/shims/request-context"; -import { runWithRequestRuntimeContext } from "vinext/shims/unified-request-context"; import { resolveStaticAssetSignal } from "./worker-utils.js"; import { cloneRequestWithHeaders, @@ -34,26 +33,50 @@ type WorkerAssetEnv = { }; }; +type AppRouterRuntimeContext = { + deploymentId?: string; + passThroughOnException?(): void; + waitUntil?(promise: Promise): void; +}; + function deploymentIdFromEnv(env: WorkerAssetEnv | undefined): string | undefined { return env?.NEXT_DEPLOYMENT_ID || env?.CF_VERSION_METADATA?.id || undefined; } +function runtimeContextForRequest( + ctx: ExecutionContextLike | undefined, + deploymentId: string | undefined, +): AppRouterRuntimeContext | undefined { + if (!deploymentId) return ctx; + return ctx + ? { + deploymentId, + passThroughOnException: ctx.passThroughOnException?.bind(ctx), + waitUntil: (promise) => ctx.waitUntil(promise), + } + : { deploymentId }; +} + +function isExecutionContextLike( + ctx: AppRouterRuntimeContext | undefined, +): ctx is ExecutionContextLike { + return typeof ctx?.waitUntil === "function"; +} + export default { async fetch( request: Request, env?: WorkerAssetEnv, ctx?: ExecutionContextLike, ): Promise { - return runWithRequestRuntimeContext({ deploymentId: deploymentIdFromEnv(env) }, () => - handleRequest(request, env, ctx), - ); + return handleRequest(request, env, runtimeContextForRequest(ctx, deploymentIdFromEnv(env))); }, }; async function handleRequest( request: Request, env: WorkerAssetEnv | undefined, - ctx: ExecutionContextLike | undefined, + ctx: AppRouterRuntimeContext | undefined, ): Promise { const url = new URL(request.url); @@ -93,7 +116,9 @@ async function handleRequest( // wrapping in the ExecutionContext ALS scope so downstream code can reach // ctx.waitUntil() without having ctx threaded through every call site. const handleFn = () => rscHandler(request, ctx); - const result = await (ctx ? runWithExecutionContext(ctx, handleFn) : handleFn()); + const result = await (isExecutionContextLike(ctx) + ? runWithExecutionContext(ctx, handleFn) + : handleFn()); if (result instanceof Response) { if (env?.ASSETS) { diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index cfee87e49..397e11811 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -187,6 +187,15 @@ function isExecutionContextLike(value: unknown): value is ExecutionContextLike { return hasProperty(value, "waitUntil") && typeof value.waitUntil === "function"; } +function deploymentIdFromRuntimeContext(value: unknown): string | undefined { + if (!value || typeof value !== "object" || !hasProperty(value, "deploymentId")) { + return undefined; + } + return typeof value.deploymentId === "string" && value.deploymentId !== "" + ? value.deploymentId + : undefined; +} + function redirectDestinationWithBasePath(destination: string, basePath: string): string { if (!basePath || isExternalUrl(destination) || hasBasePath(destination, basePath)) { return destination; @@ -504,6 +513,7 @@ export function createAppRscHandler( const requestContext = createRequestContext({ headersContext, executionContext, + deploymentId: deploymentIdFromRuntimeContext(ctx), unstableCacheRevalidation: "background", }); diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 0265872a0..acfab2dff 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -63,7 +63,6 @@ export type UnifiedRequestContext = { // --------------------------------------------------------------------------- const _REQUEST_CONTEXT_ALS_KEY = Symbol.for("vinext.requestContext.als"); -const _REQUEST_RUNTIME_CONTEXT_ALS_KEY = Symbol.for("vinext.requestRuntimeContext.als"); const _g = globalThis as unknown as Record; const _als = getOrCreateAls("vinext.unifiedRequestContext.als"); @@ -79,22 +78,9 @@ function _getInheritedExecutionContext(): ExecutionContextLike | null { function _getInheritedDeploymentId(): string | undefined { const unifiedStore = _als.getStore(); - if (unifiedStore) return unifiedStore.deploymentId; - - const requestRuntimeAls = _g[_REQUEST_RUNTIME_CONTEXT_ALS_KEY] as - | AsyncLocalStorage - | undefined; - return requestRuntimeAls?.getStore()?.deploymentId || undefined; + return unifiedStore?.deploymentId; } -export type RequestRuntimeContext = { - deploymentId?: string | undefined; -}; - -const _requestRuntimeAls = getOrCreateAls( - "vinext.requestRuntimeContext.als", -); - // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -150,32 +136,11 @@ export function runWithRequestContext( return _als.run(ctx, fn); } -export function runWithRequestRuntimeContext( - ctx: RequestRuntimeContext, - fn: () => Promise, -): Promise; -export function runWithRequestRuntimeContext( - ctx: RequestRuntimeContext, - fn: () => T | Promise, -): T | Promise; -export function runWithRequestRuntimeContext( - ctx: RequestRuntimeContext, - fn: () => T | Promise, -): T | Promise { - const deploymentId = ctx.deploymentId || undefined; - if (isInsideUnifiedScope()) { - return runWithUnifiedStateMutation((uCtx) => { - uCtx.deploymentId = deploymentId; - }, fn); - } - return _requestRuntimeAls.run({ deploymentId }, fn); -} - export function getRequestDeploymentId(): string | undefined { if (isInsideUnifiedScope()) { return getRequestContext().deploymentId; } - return _requestRuntimeAls.getStore()?.deploymentId || undefined; + return undefined; } /** diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 8cbc51a5d..bb0456839 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2496,7 +2496,7 @@ describe('"use cache" runtime', () => { // https://github.com/vercel/next.js/blob/07f76411b07de9417d4a6b816f3137cafe1045fc/test/production/app-dir/use-cache-cross-deployment/use-cache-cross-deployment.test.ts const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); - const { runWithRequestRuntimeContext } = + const { createRequestContext, runWithRequestContext } = await import("../packages/vinext/src/shims/unified-request-context.js"); const { setCacheHandler, MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); @@ -2515,17 +2515,25 @@ describe('"use cache" runtime', () => { }, "test:deployment-id"); expect( - await runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => cached()), + await runWithRequestContext(createRequestContext({ deploymentId: "deployment-one" }), () => + cached(), + ), ).toEqual({ count: 1 }); expect( - await runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => cached()), + await runWithRequestContext(createRequestContext({ deploymentId: "deployment-one" }), () => + cached(), + ), ).toEqual({ count: 1 }); expect( - await runWithRequestRuntimeContext({ deploymentId: "deployment-two" }, () => cached()), + await runWithRequestContext(createRequestContext({ deploymentId: "deployment-two" }), () => + cached(), + ), ).toEqual({ count: 2 }); expect( - await runWithRequestRuntimeContext({ deploymentId: "deployment-two" }, () => cached()), + await runWithRequestContext(createRequestContext({ deploymentId: "deployment-two" }), () => + cached(), + ), ).toEqual({ count: 2 }); expect(await cached()).toEqual({ count: 3 }); @@ -2547,7 +2555,7 @@ describe('"use cache" runtime', () => { it("keeps concurrent request deployment IDs isolated", async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); - const { runWithRequestRuntimeContext } = + const { createRequestContext, runWithRequestContext } = await import("../packages/vinext/src/shims/unified-request-context.js"); const { setCacheHandler, MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); @@ -2564,18 +2572,26 @@ describe('"use cache" runtime', () => { }, "test:concurrent-deployment-id"); const [first, second] = await Promise.all([ - runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => cached()), - runWithRequestRuntimeContext({ deploymentId: "deployment-two" }, () => cached()), + runWithRequestContext(createRequestContext({ deploymentId: "deployment-one" }), () => + cached(), + ), + runWithRequestContext(createRequestContext({ deploymentId: "deployment-two" }), () => + cached(), + ), ]); expect(new Set([first.count, second.count])).toEqual(new Set([1, 2])); expect(callCount).toBe(2); expect( - await runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => cached()), + await runWithRequestContext(createRequestContext({ deploymentId: "deployment-one" }), () => + cached(), + ), ).toEqual(first); expect( - await runWithRequestRuntimeContext({ deploymentId: "deployment-two" }, () => cached()), + await runWithRequestContext(createRequestContext({ deploymentId: "deployment-two" }), () => + cached(), + ), ).toEqual(second); expect(callCount).toBe(2); } finally { @@ -2587,12 +2603,12 @@ describe('"use cache" runtime', () => { } }); - it("inherits request runtime deployment ID when a unified request context is created", async () => { + it("inherits deployment ID when a nested unified request context is created", async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); const { setCacheHandler, MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); - const { createRequestContext, runWithRequestContext, runWithRequestRuntimeContext } = + const { createRequestContext, runWithRequestContext } = await import("../packages/vinext/src/shims/unified-request-context.js"); setCacheHandler(new MemoryCacheHandler()); @@ -2606,17 +2622,19 @@ describe('"use cache" runtime', () => { return { count: callCount }; }, "test:inherited-runtime-deployment-id"); - const first = await runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => - runWithRequestContext(createRequestContext(), () => cached()), + const first = await runWithRequestContext( + createRequestContext({ deploymentId: "deployment-one" }), + () => runWithRequestContext(createRequestContext(), () => cached()), ); - const second = await runWithRequestRuntimeContext({ deploymentId: "deployment-two" }, () => - runWithRequestContext(createRequestContext(), () => cached()), + const second = await runWithRequestContext( + createRequestContext({ deploymentId: "deployment-two" }), + () => runWithRequestContext(createRequestContext(), () => cached()), ); expect(first).toEqual({ count: 1 }); expect(second).toEqual({ count: 2 }); expect( - await runWithRequestRuntimeContext({ deploymentId: "deployment-one" }, () => + await runWithRequestContext(createRequestContext({ deploymentId: "deployment-one" }), () => runWithRequestContext(createRequestContext(), () => cached()), ), ).toEqual(first); From 6c3bd8c4a6185741c4cb2c21bdc9842b6581b050 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 6 May 2026 18:15:32 +1000 Subject: [PATCH 5/7] test(app-elements): expect artifact compatibility metadata --- tests/app-elements.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index f9f003000..565c68d38 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -158,6 +158,7 @@ describe("AppElementsWire", () => { if (!isAppElementsRecord(payload)) return; expect(AppElementsWire.readMetadata(payload)).toEqual({ + artifactCompatibility: createArtifactCompatibilityEnvelope(), interceptionContext: null, layoutFlags: { [AppElementsWire.encodeLayoutId("/")]: "s" }, rootLayoutTreePath: "/", From 192ada21268e33553dbfe8f50801ce19e4169c35 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 7 May 2026 01:12:46 +1000 Subject: [PATCH 6/7] refactor(cache): inline deployment id seed --- packages/vinext/src/deploy.ts | 9 -- .../vinext/src/server/app-router-entry.ts | 42 +----- packages/vinext/src/server/app-rsc-handler.ts | 10 -- packages/vinext/src/shims/cache-runtime.ts | 5 +- .../src/shims/unified-request-context.ts | 15 --- tests/deploy.test.ts | 11 -- tests/shims.test.ts | 124 +++++------------- 7 files changed, 38 insertions(+), 178 deletions(-) diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 1f91fea13..a31133458 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -412,11 +412,6 @@ export function generateWranglerConfig(info: ProjectInfo): string { images: { binding: "IMAGES", }, - // Exposes the Cloudflare Worker version ID at runtime. vinext uses it as - // the deployment seed for shared "use cache" entries when available. - version_metadata: { - binding: "CF_VERSION_METADATA", - }, }; if (info.hasISR) { @@ -462,10 +457,6 @@ import handler from "vinext/server/app-router-entry"; ${isrImports} interface Env { ASSETS: Fetcher;${isrEnvField} - NEXT_DEPLOYMENT_ID?: string; - CF_VERSION_METADATA?: { - id?: string; - }; IMAGES: { input(stream: ReadableStream): { transform(options: Record): { diff --git a/packages/vinext/src/server/app-router-entry.ts b/packages/vinext/src/server/app-router-entry.ts index 21b4cbc09..2e8510a61 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -27,56 +27,22 @@ type WorkerAssetEnv = { ASSETS?: { fetch(request: Request): Promise | Response; }; - NEXT_DEPLOYMENT_ID?: string; - CF_VERSION_METADATA?: { - id?: string; - }; -}; - -type AppRouterRuntimeContext = { - deploymentId?: string; - passThroughOnException?(): void; - waitUntil?(promise: Promise): void; }; -function deploymentIdFromEnv(env: WorkerAssetEnv | undefined): string | undefined { - return env?.NEXT_DEPLOYMENT_ID || env?.CF_VERSION_METADATA?.id || undefined; -} - -function runtimeContextForRequest( - ctx: ExecutionContextLike | undefined, - deploymentId: string | undefined, -): AppRouterRuntimeContext | undefined { - if (!deploymentId) return ctx; - return ctx - ? { - deploymentId, - passThroughOnException: ctx.passThroughOnException?.bind(ctx), - waitUntil: (promise) => ctx.waitUntil(promise), - } - : { deploymentId }; -} - -function isExecutionContextLike( - ctx: AppRouterRuntimeContext | undefined, -): ctx is ExecutionContextLike { - return typeof ctx?.waitUntil === "function"; -} - export default { async fetch( request: Request, env?: WorkerAssetEnv, ctx?: ExecutionContextLike, ): Promise { - return handleRequest(request, env, runtimeContextForRequest(ctx, deploymentIdFromEnv(env))); + return handleRequest(request, env, ctx); }, }; async function handleRequest( request: Request, env: WorkerAssetEnv | undefined, - ctx: AppRouterRuntimeContext | undefined, + ctx: ExecutionContextLike | undefined, ): Promise { const url = new URL(request.url); @@ -116,9 +82,7 @@ async function handleRequest( // wrapping in the ExecutionContext ALS scope so downstream code can reach // ctx.waitUntil() without having ctx threaded through every call site. const handleFn = () => rscHandler(request, ctx); - const result = await (isExecutionContextLike(ctx) - ? runWithExecutionContext(ctx, handleFn) - : handleFn()); + const result = await (ctx ? runWithExecutionContext(ctx, handleFn) : handleFn()); if (result instanceof Response) { if (env?.ASSETS) { diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 397e11811..cfee87e49 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -187,15 +187,6 @@ function isExecutionContextLike(value: unknown): value is ExecutionContextLike { return hasProperty(value, "waitUntil") && typeof value.waitUntil === "function"; } -function deploymentIdFromRuntimeContext(value: unknown): string | undefined { - if (!value || typeof value !== "object" || !hasProperty(value, "deploymentId")) { - return undefined; - } - return typeof value.deploymentId === "string" && value.deploymentId !== "" - ? value.deploymentId - : undefined; -} - function redirectDestinationWithBasePath(destination: string, basePath: string): string { if (!basePath || isExternalUrl(destination) || hasBasePath(destination, basePath)) { return destination; @@ -513,7 +504,6 @@ export function createAppRscHandler( const requestContext = createRequestContext({ headersContext, executionContext, - deploymentId: deploymentIdFromRuntimeContext(ctx), unstableCacheRevalidation: "background", }); diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 137f6b534..0bbbd337e 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -40,7 +40,6 @@ import { getOrCreateAls } from "./internal/als-registry.js"; import { isInsideUnifiedScope, getRequestContext, - getRequestDeploymentId, runWithUnifiedStateMutation, } from "./unified-request-context.js"; @@ -95,7 +94,7 @@ function getUseCacheDeploymentIdDefine(): string | undefined { try { // Keep this direct reference so Vite's define transform can inline it for // Worker bundles where the process global might not exist at runtime. - return process.env.__VINEXT_DEPLOYMENT_ID; + return process.env.__VINEXT_DEPLOYMENT_ID || process.env.NEXT_DEPLOYMENT_ID; } catch (error) { if (error instanceof ReferenceError) return undefined; throw error; @@ -114,7 +113,7 @@ function getUseCacheBuildIdDefine(): string | undefined { } function getUseCacheKeySeed(): string | undefined { - return getRequestDeploymentId() || getUseCacheDeploymentIdDefine() || getUseCacheBuildIdDefine(); + return getUseCacheDeploymentIdDefine() || getUseCacheBuildIdDefine(); } function buildUseCacheKey(id: string, keySeed: string | undefined, argsKey?: string): string { diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index acfab2dff..1bb4a929d 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -40,8 +40,6 @@ export type UnifiedRequestContext = { // ── request-context.ts ───────────────────────────────────────────── /** Cloudflare Workers ExecutionContext, or null on Node.js dev. */ executionContext: ExecutionContextLike | null; - /** Deployment identifier for request-scoped cache key seeding. */ - deploymentId: string | undefined; // ── cache-for-request.ts ────────────────────────────────────────── /** Per-request cache for cacheForRequest(). Keyed by factory function reference. */ @@ -76,11 +74,6 @@ function _getInheritedExecutionContext(): ExecutionContextLike | null { return executionContextAls?.getStore() ?? null; } -function _getInheritedDeploymentId(): string | undefined { - const unifiedStore = _als.getStore(); - return unifiedStore?.deploymentId; -} - // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -111,7 +104,6 @@ export function createRequestContext(opts?: Partial): Uni ssrContext: null, ssrHeadChildren: [], rootParams: null, - deploymentId: _getInheritedDeploymentId(), ...opts, }; } @@ -136,13 +128,6 @@ export function runWithRequestContext( return _als.run(ctx, fn); } -export function getRequestDeploymentId(): string | undefined { - if (isInsideUnifiedScope()) { - return getRequestContext().deploymentId; - } - return undefined; -} - /** * Run `fn` in a nested unified scope derived from the current request context. * Used by legacy runWith* wrappers to reset or override one sub-state while diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index b79725764..f89ce05ca 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -369,15 +369,6 @@ describe("generateWranglerConfig", () => { expect(parsed.images).toBeDefined(); expect(parsed.images.binding).toBe("IMAGES"); }); - - it("includes Cloudflare version metadata binding for deployment-scoped cache keys", () => { - mkdir(tmpDir, "app"); - const info = detectProject(tmpDir); - const config = generateWranglerConfig(info); - const parsed = JSON.parse(config); - - expect(parsed.version_metadata).toEqual({ binding: "CF_VERSION_METADATA" }); - }); }); // ─── Worker Entry Generation ───────────────────────────────────────────────── @@ -401,8 +392,6 @@ describe("generateAppRouterWorkerEntry", () => { expect(content).toContain("interface Env"); expect(content).toContain("IMAGES"); expect(content).toContain("ASSETS"); - expect(content).toContain("NEXT_DEPLOYMENT_ID?: string"); - expect(content).toContain("CF_VERSION_METADATA?:"); }); it("declares ExecutionContext interface", () => { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index bb0456839..4527c033e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2496,17 +2496,16 @@ describe('"use cache" runtime', () => { // https://github.com/vercel/next.js/blob/07f76411b07de9417d4a6b816f3137cafe1045fc/test/production/app-dir/use-cache-cross-deployment/use-cache-cross-deployment.test.ts const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); - const { createRequestContext, runWithRequestContext } = - await import("../packages/vinext/src/shims/unified-request-context.js"); const { setCacheHandler, MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); setCacheHandler(new MemoryCacheHandler()); const previousBuildId = process.env.__VINEXT_BUILD_ID; const previousDeploymentId = process.env.__VINEXT_DEPLOYMENT_ID; + const previousNextDeploymentId = process.env.NEXT_DEPLOYMENT_ID; try { process.env.__VINEXT_BUILD_ID = "stable-build"; - delete process.env.__VINEXT_DEPLOYMENT_ID; + delete process.env.NEXT_DEPLOYMENT_ID; let callCount = 0; const cached = registerCachedFunction(async () => { @@ -2514,28 +2513,15 @@ describe('"use cache" runtime', () => { return { count: callCount }; }, "test:deployment-id"); - expect( - await runWithRequestContext(createRequestContext({ deploymentId: "deployment-one" }), () => - cached(), - ), - ).toEqual({ count: 1 }); - expect( - await runWithRequestContext(createRequestContext({ deploymentId: "deployment-one" }), () => - cached(), - ), - ).toEqual({ count: 1 }); + process.env.__VINEXT_DEPLOYMENT_ID = "deployment-one"; + expect(await cached()).toEqual({ count: 1 }); + expect(await cached()).toEqual({ count: 1 }); - expect( - await runWithRequestContext(createRequestContext({ deploymentId: "deployment-two" }), () => - cached(), - ), - ).toEqual({ count: 2 }); - expect( - await runWithRequestContext(createRequestContext({ deploymentId: "deployment-two" }), () => - cached(), - ), - ).toEqual({ count: 2 }); + process.env.__VINEXT_DEPLOYMENT_ID = "deployment-two"; + expect(await cached()).toEqual({ count: 2 }); + expect(await cached()).toEqual({ count: 2 }); + delete process.env.__VINEXT_DEPLOYMENT_ID; expect(await cached()).toEqual({ count: 3 }); expect(await cached()).toEqual({ count: 3 }); } finally { @@ -2549,95 +2535,41 @@ describe('"use cache" runtime', () => { } else { process.env.__VINEXT_DEPLOYMENT_ID = previousDeploymentId; } - } - }); - - it("keeps concurrent request deployment IDs isolated", async () => { - const { registerCachedFunction } = - await import("../packages/vinext/src/shims/cache-runtime.js"); - const { createRequestContext, runWithRequestContext } = - await import("../packages/vinext/src/shims/unified-request-context.js"); - const { setCacheHandler, MemoryCacheHandler } = - await import("../packages/vinext/src/shims/cache.js"); - setCacheHandler(new MemoryCacheHandler()); - - const previousBuildId = process.env.__VINEXT_BUILD_ID; - try { - process.env.__VINEXT_BUILD_ID = "stable-build"; - - let callCount = 0; - const cached = registerCachedFunction(async () => { - callCount++; - return { count: callCount }; - }, "test:concurrent-deployment-id"); - - const [first, second] = await Promise.all([ - runWithRequestContext(createRequestContext({ deploymentId: "deployment-one" }), () => - cached(), - ), - runWithRequestContext(createRequestContext({ deploymentId: "deployment-two" }), () => - cached(), - ), - ]); - - expect(new Set([first.count, second.count])).toEqual(new Set([1, 2])); - expect(callCount).toBe(2); - - expect( - await runWithRequestContext(createRequestContext({ deploymentId: "deployment-one" }), () => - cached(), - ), - ).toEqual(first); - expect( - await runWithRequestContext(createRequestContext({ deploymentId: "deployment-two" }), () => - cached(), - ), - ).toEqual(second); - expect(callCount).toBe(2); - } finally { - if (previousBuildId === undefined) { - delete process.env.__VINEXT_BUILD_ID; + if (previousNextDeploymentId === undefined) { + delete process.env.NEXT_DEPLOYMENT_ID; } else { - process.env.__VINEXT_BUILD_ID = previousBuildId; + process.env.NEXT_DEPLOYMENT_ID = previousNextDeploymentId; } } }); - it("inherits deployment ID when a nested unified request context is created", async () => { + it("uses NEXT_DEPLOYMENT_ID when the internal define is empty", async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js"); const { setCacheHandler, MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); - const { createRequestContext, runWithRequestContext } = - await import("../packages/vinext/src/shims/unified-request-context.js"); setCacheHandler(new MemoryCacheHandler()); const previousBuildId = process.env.__VINEXT_BUILD_ID; + const previousDeploymentId = process.env.__VINEXT_DEPLOYMENT_ID; + const previousNextDeploymentId = process.env.NEXT_DEPLOYMENT_ID; try { process.env.__VINEXT_BUILD_ID = "stable-build"; + process.env.__VINEXT_DEPLOYMENT_ID = ""; let callCount = 0; const cached = registerCachedFunction(async () => { callCount++; return { count: callCount }; - }, "test:inherited-runtime-deployment-id"); + }, "test:next-deployment-id"); - const first = await runWithRequestContext( - createRequestContext({ deploymentId: "deployment-one" }), - () => runWithRequestContext(createRequestContext(), () => cached()), - ); - const second = await runWithRequestContext( - createRequestContext({ deploymentId: "deployment-two" }), - () => runWithRequestContext(createRequestContext(), () => cached()), - ); + process.env.NEXT_DEPLOYMENT_ID = "env-deployment-one"; + expect(await cached()).toEqual({ count: 1 }); + expect(await cached()).toEqual({ count: 1 }); - expect(first).toEqual({ count: 1 }); - expect(second).toEqual({ count: 2 }); - expect( - await runWithRequestContext(createRequestContext({ deploymentId: "deployment-one" }), () => - runWithRequestContext(createRequestContext(), () => cached()), - ), - ).toEqual(first); + process.env.NEXT_DEPLOYMENT_ID = "env-deployment-two"; + expect(await cached()).toEqual({ count: 2 }); + expect(await cached()).toEqual({ count: 2 }); expect(callCount).toBe(2); } finally { if (previousBuildId === undefined) { @@ -2645,6 +2577,16 @@ describe('"use cache" runtime', () => { } else { process.env.__VINEXT_BUILD_ID = previousBuildId; } + if (previousDeploymentId === undefined) { + delete process.env.__VINEXT_DEPLOYMENT_ID; + } else { + process.env.__VINEXT_DEPLOYMENT_ID = previousDeploymentId; + } + if (previousNextDeploymentId === undefined) { + delete process.env.NEXT_DEPLOYMENT_ID; + } else { + process.env.NEXT_DEPLOYMENT_ID = previousNextDeploymentId; + } } }); From 6b80257317bb55a01b4d6bdc01e8779f9a5cc9bc Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 7 May 2026 01:14:54 +1000 Subject: [PATCH 7/7] chore: rerun ci