diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index 554a4dd83..61e0b3728 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -173,6 +173,10 @@ const CONFIG_SUPPORT: Record = { detail: "config recognized; vinext uses unified RSC navigation payloads so per-segment prefetch inlining is a no-op", }, + "experimental.outputHashSalt": { + status: "supported", + detail: "salt mixed into output content hashes for cache-busting", + }, "experimental.swcEnvOptions": { status: "unsupported", detail: diff --git a/packages/vinext/src/config/next-config.ts b/packages/vinext/src/config/next-config.ts index 7bb5eb2c1..d5f48ce45 100644 --- a/packages/vinext/src/config/next-config.ts +++ b/packages/vinext/src/config/next-config.ts @@ -273,6 +273,13 @@ export type ResolvedNextConfig = { * Set to 0 to disable in-memory caching entirely. */ cacheMaxMemorySize: number | undefined; + /** + * Concatenated hash salt from `experimental.outputHashSalt` config option + * and `NEXT_HASH_SALT` environment variable. Empty string when neither is set. + * When non-empty, mix into content-addressed output filenames so hash values + * change without modifying source — useful for cache-busting after CDN poisoning. + */ + hashSalt: string; }; const CONFIG_FILES = ["next.config.ts", "next.config.mjs", "next.config.js", "next.config.cjs"]; @@ -502,6 +509,7 @@ export async function resolveNextConfig( serverExternalPackages: [], cacheHandler: undefined, cacheMaxMemorySize: undefined, + hashSalt: process.env.NEXT_HASH_SALT ?? "", buildId, }; detectNextIntlConfig(root, resolved); @@ -581,6 +589,11 @@ export async function resolveNextConfig( serverActionsConfig?.bodySizeLimit as string | number | undefined, ); + // Resolve hashSalt from experimental.outputHashSalt config + NEXT_HASH_SALT env var. + // Next.js concatenates them: config value first, then env var. + const configOutputHashSalt = experimental?.outputHashSalt as string | undefined; + const hashSalt = (configOutputHashSalt ?? "") + (process.env.NEXT_HASH_SALT ?? ""); + // Resolve optimizePackageImports from experimental config const rawOptimize = experimental?.optimizePackageImports; const optimizePackageImports = Array.isArray(rawOptimize) @@ -674,6 +687,7 @@ export async function resolveNextConfig( serverExternalPackages, cacheHandler, cacheMaxMemorySize, + hashSalt, buildId, }; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 260641312..ea9d979ba 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3207,6 +3207,23 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }; })(), + // Mix experimental.outputHashSalt / NEXT_HASH_SALT into chunk content hashes. + // This changes output filenames (e.g., index-[hash].js) without modifying source. + // Uses augmentChunkHash (supported by Rolldown) instead of the unsupported output.hashSalt. + { + name: "vinext:hash-salt", + apply: "build", + augmentChunkHash() { + // Only apply to client environment; SSR/RSC don't use content hashing + if (this.environment?.name !== "client") return; + const salt = nextConfig?.hashSalt; + if (salt) { + return salt; + } + }, + }, + // Note: augmentChunkHash only affects JS chunk hashes. CSS and static asset + // hashes are not salted, which is a known gap vs Next.js behavior. // Write vinext-server.json to dist/server/ with a per-build prerender secret. // The prerender secret is used by prod-server.ts to authenticate requests to // the internal /__vinext/prerender/* endpoints, which are only reachable during diff --git a/tests/next-config.test.ts b/tests/next-config.test.ts index 2186d0bd2..969115ac4 100644 --- a/tests/next-config.test.ts +++ b/tests/next-config.test.ts @@ -398,6 +398,55 @@ describe("resolveNextConfig serverActionsBodySizeLimit", () => { }); }); +describe("resolveNextConfig hashSalt", () => { + const OLD_ENV = process.env.NEXT_HASH_SALT; + + afterEach(() => { + if (OLD_ENV !== undefined) { + process.env.NEXT_HASH_SALT = OLD_ENV; + } else { + delete process.env.NEXT_HASH_SALT; + } + }); + + it("defaults to empty string when no config or env is set", async () => { + const resolved = await resolveNextConfig(null); + expect(resolved.hashSalt).toBe(""); + }); + + it("defaults to empty string when config has no experimental", async () => { + const resolved = await resolveNextConfig({ env: {} }); + expect(resolved.hashSalt).toBe(""); + }); + + it("reads outputHashSalt from experimental config", async () => { + const resolved = await resolveNextConfig({ + experimental: { outputHashSalt: "v1" }, + }); + expect(resolved.hashSalt).toBe("v1"); + }); + + it("reads NEXT_HASH_SALT from env var", async () => { + process.env.NEXT_HASH_SALT = "envsalt"; + const resolved = await resolveNextConfig(null); + expect(resolved.hashSalt).toBe("envsalt"); + }); + + it("concatenates config salt and env salt (config first)", async () => { + process.env.NEXT_HASH_SALT = "envsalt"; + const resolved = await resolveNextConfig({ + experimental: { outputHashSalt: "configsalt" }, + }); + expect(resolved.hashSalt).toBe("configsaltenvsalt"); + }); + + it("handles only env var without config salt", async () => { + process.env.NEXT_HASH_SALT = "onlyenv"; + const resolved = await resolveNextConfig({ env: {} }); + expect(resolved.hashSalt).toBe("onlyenv"); + }); +}); + describe("detectNextIntlConfig", () => { let tmpDir: string; @@ -429,6 +478,7 @@ describe("detectNextIntlConfig", () => { serverExternalPackages: [], cacheHandler: undefined, cacheMaxMemorySize: undefined, + hashSalt: "", buildId: "test-build-id", ...overrides, };