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/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..1dd12dd12 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( + nextConfig.deploymentId ?? "", + ); // 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..2e8510a61 100644 --- a/packages/vinext/src/server/app-router-entry.ts +++ b/packages/vinext/src/server/app-router-entry.ts @@ -35,61 +35,70 @@ export default { env?: WorkerAssetEnv, ctx?: ExecutionContextLike, ): Promise { - const url = new URL(request.url); + return 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..0bbbd337e 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,21 @@ type RscModule = { decodeReply: (body: string | FormData, options?: unknown) => Promise; }; -function getUseCacheBuildId(): string | undefined { +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. 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_DEPLOYMENT_ID || process.env.NEXT_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. return process.env.__VINEXT_BUILD_ID; } catch (error) { if (error instanceof ReferenceError) return undefined; @@ -102,8 +112,12 @@ 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 { + return 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 +346,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 +373,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/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: "/", 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(); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index f62388022..4527c033e 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -2491,6 +2491,105 @@ 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 } = + 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; + const previousNextDeploymentId = process.env.NEXT_DEPLOYMENT_ID; + try { + process.env.__VINEXT_BUILD_ID = "stable-build"; + delete process.env.NEXT_DEPLOYMENT_ID; + + let callCount = 0; + const cached = registerCachedFunction(async () => { + callCount++; + return { count: callCount }; + }, "test:deployment-id"); + + process.env.__VINEXT_DEPLOYMENT_ID = "deployment-one"; + expect(await cached()).toEqual({ count: 1 }); + expect(await cached()).toEqual({ count: 1 }); + + 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 { + 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; + } + if (previousNextDeploymentId === undefined) { + delete process.env.NEXT_DEPLOYMENT_ID; + } else { + process.env.NEXT_DEPLOYMENT_ID = previousNextDeploymentId; + } + } + }); + + 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"); + 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:next-deployment-id"); + + process.env.NEXT_DEPLOYMENT_ID = "env-deployment-one"; + expect(await cached()).toEqual({ count: 1 }); + expect(await cached()).toEqual({ count: 1 }); + + 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) { + 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; + } + if (previousNextDeploymentId === undefined) { + delete process.env.NEXT_DEPLOYMENT_ID; + } else { + process.env.NEXT_DEPLOYMENT_ID = previousNextDeploymentId; + } + } + }); + it("registerCachedFunction respects cacheLife inside cached function", async () => { const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js");