Skip to content
28 changes: 28 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>;
/** Identifier for deployment-aware cache keys and version skew protection. */
deploymentId?: string;
/** Any other options */
[key: string]: unknown;
};
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Calling out: when a user sets deploymentId: "" in next.config.js, this silently swallows NEXT_DEPLOYMENT_ID from the environment. The test covers this behavior ("treats an empty next.config.js deploymentId as unset"), so it's intentional — but it functions as a way to opt out of the env var, not just "leave unset." Worth a brief inline comment for the next person who reads this, e.g.:

Suggested change
if (deploymentId === undefined || deploymentId === "") return undefined;
// Setting deploymentId to "" in next.config.js explicitly opts out of
// any deployment ID (including NEXT_DEPLOYMENT_ID from the environment).
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
Expand All @@ -501,6 +525,7 @@ export async function resolveNextConfig(
): Promise<ResolvedNextConfig> {
if (!config) {
const buildId = await resolveBuildId(undefined);
const deploymentId = resolveDeploymentId(undefined);
const resolved: ResolvedNextConfig = {
env: {},
basePath: "",
Expand All @@ -526,6 +551,7 @@ export async function resolveNextConfig(
enablePrerenderSourceMaps: true,
hashSalt: process.env.NEXT_HASH_SALT ?? "",
buildId,
deploymentId,
};
detectNextIntlConfig(root, resolved);
return resolved;
Expand Down Expand Up @@ -670,6 +696,7 @@ export async function resolveNextConfig(
const buildId = await resolveBuildId(
config.generateBuildId as (() => string | null | Promise<string | null>) | undefined,
);
const deploymentId = resolveDeploymentId(config.deploymentId);

// Resolve cacheHandler path — handle file:// URLs from import.meta.resolve()
const cacheHandler: string | undefined =
Expand Down Expand Up @@ -706,6 +733,7 @@ export async function resolveNextConfig(
enablePrerenderSourceMaps: config.enablePrerenderSourceMaps ?? true,
hashSalt,
buildId,
deploymentId,
};

// Auto-detect next-intl (lowest priority — explicit aliases from
Expand Down
6 changes: 6 additions & 0 deletions packages/vinext/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
5 changes: 5 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When deploymentId is undefined, this serializes to "". Downstream in getUseCacheKeySeed(), "" is falsy so it correctly falls through to getUseCacheBuildIdDefine(). This is the right approach — it avoids needing Vite to define-replace with undefined (which wouldn't inline cleanly). Just wanted to confirm: have you verified this works when __VINEXT_DEPLOYMENT_ID is never defined at all (non-Vite test runner, no define transform)? In that case process.env.__VINEXT_DEPLOYMENT_ID would be literally undefined, which also falls through correctly. Looks good.

);

// Build the shim alias map. Exact `.js` variants are included for the
// public Next entrypoints that are file-backed in `next/package.json`.
Expand Down
107 changes: 58 additions & 49 deletions packages/vinext/src/server/app-router-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,61 +35,70 @@ export default {
env?: WorkerAssetEnv,
ctx?: ExecutionContextLike,
): Promise<Response> {
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<Response> {
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice cleanup: extracting env.ASSETS into assetFetcher avoids the non-null assertion that was previously needed inside the closure (env.ASSETS!). This is a strict improvement over the original.

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 });
}
32 changes: 23 additions & 9 deletions packages/vinext/src/shims/cache-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -90,20 +90,34 @@ type RscModule = {
decodeReply: (body: string | FormData, options?: unknown) => Promise<unknown[]>;
};

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;
throw error;
}
}

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: The key prefix is still build: even when the seed is a deployment ID rather than a build ID. This doesn't cause correctness issues (it's just a string prefix for namespacing), but it could be confusing when debugging cache keys. Consider whether a more generic prefix like scope: would be clearer, or add a comment explaining the naming is historical.

Suggested change
const scopedId = keySeed ? `build:${encodeURIComponent(keySeed)}:${id}` : id;
const scopedId = keySeed ? `build:${encodeURIComponent(keySeed)}:${id}` : id; // "build:" prefix is historical — seed may be deployment ID or build ID

return argsKey === undefined ? `use-cache:${scopedId}` : `use-cache:${scopedId}:${argsKey}`;
}

Expand Down Expand Up @@ -332,11 +346,11 @@ export function registerCachedFunction<T extends (...args: any[]) => Promise<any
// Per-request ("use cache: private") caching still works in dev since
// it's scoped to a single request and doesn't persist across HMR.
const isDev = typeof process !== "undefined" && process.env.NODE_ENV === "development";
const buildId = getUseCacheBuildId();

// oxlint-disable-next-line @typescript-eslint/no-explicit-any
const cachedFn = async (...args: any[]): Promise<any> => {
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
Expand All @@ -359,10 +373,10 @@ export function registerCachedFunction<T extends (...args: any[]) => Promise<any
const encoded = await rsc.encodeReply(processedArgs, {
temporaryReferences: tempRefs,
});
cacheKey = buildUseCacheKey(id, buildId, await replyToCacheKey(encoded));
cacheKey = buildUseCacheKey(id, keySeed, await replyToCacheKey(encoded));
} else {
const argsKey = args.length > 0 ? stableStringify(args) : undefined;
cacheKey = buildUseCacheKey(id, buildId, argsKey);
cacheKey = buildUseCacheKey(id, keySeed, argsKey);
}
} catch {
// Non-serializable arguments — run without caching
Expand Down
1 change: 1 addition & 0 deletions tests/app-elements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ describe("AppElementsWire", () => {
if (!isAppElementsRecord(payload)) return;

expect(AppElementsWire.readMetadata(payload)).toEqual({
artifactCompatibility: createArtifactCompatibilityEnvelope(),
interceptionContext: null,
layoutFlags: { [AppElementsWire.encodeLayoutId("/")]: "s" },
rootLayoutTreePath: "/",
Expand Down
57 changes: 57 additions & 0 deletions tests/next-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ describe("detectNextIntlConfig", () => {
enablePrerenderSourceMaps: true,
expireTime: 31_536_000,
buildId: "test-build-id",
deploymentId: undefined,
...overrides,
};
}
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading