Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/vinext/src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ const CONFIG_SUPPORT: Record<string, { status: Status; detail?: string }> = {
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",
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: this says "output content hashes" which implies all output files. Since the implementation only salts JS chunk hashes (via augmentChunkHash), this detail could be more precise to avoid misleading vinext check output.

Suggested change
detail: "salt mixed into output content hashes for cache-busting",
detail: "salt mixed into JS chunk content hashes for cache-busting (CSS/asset hashes not yet salted)",

},
"experimental.swcEnvOptions": {
status: "unsupported",
detail:
Expand Down
14 changes: 14 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -502,6 +509,7 @@ export async function resolveNextConfig(
serverExternalPackages: [],
cacheHandler: undefined,
cacheMaxMemorySize: undefined,
hashSalt: process.env.NEXT_HASH_SALT ?? "",
buildId,
};
detectNextIntlConfig(root, resolved);
Expand Down Expand Up @@ -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 ?? "");
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.

The concatenation logic matches Next.js exactly ((result.experimental.outputHashSalt ?? '') + process.env.NEXT_HASH_SALT in packages/next/src/server/config.ts). This part is correct.

Note: Next.js does the concatenation inside config.ts and then stores the result back into experimental.outputHashSalt. Here, vinext stores it as a separate hashSalt field on ResolvedNextConfig, which is a cleaner approach.


// Resolve optimizePackageImports from experimental config
const rawOptimize = experimental?.optimizePackageImports;
const optimizePackageImports = Array.isArray(rawOptimize)
Expand Down Expand Up @@ -674,6 +687,7 @@ export async function resolveNextConfig(
serverExternalPackages,
cacheHandler,
cacheMaxMemorySize,
hashSalt,
buildId,
};

Expand Down
17 changes: 17 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
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 this.environment?.name pattern is consistent with other hooks in this file, but for augmentChunkHash specifically, the hook only runs during build (enforced by apply: "build"), and in a multi-environment build, each environment's plugins run independently. So the guard is correct — just confirming this is doing the right thing.

One thing to double-check: does the RSC plugin's multi-environment build pipeline call augmentChunkHash once per environment, with this.environment correctly set? If for some reason this.environment is undefined during the build (e.g., fallback environment), the hook would silently return undefined for all environments including client. Given that other hooks in this file use the same ?.name pattern, this is likely fine, but worth a sanity check if you can test with an actual build.

const salt = nextConfig?.hashSalt;
if (salt) {
return salt;
}
},
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.

Scope gap: augmentChunkHash only affects JS chunks, not CSS or static assets.

In Next.js, outputHashSalt affects three things:

  1. JS chunk hashes — via webpack output.hashSalt
  2. CSS chunk hashes — via webpack output.hashSalt (same mechanism)
  3. Image hashes — via a separate manual salt prepend in next-image-loader

The Next.js test suite (test/production/app-dir/hash-salt/hash-salt.test.ts) explicitly verifies that changing the salt changes filenames for JS chunks, images, and CSS.

augmentChunkHash in Rolldown is documented as "called for each Rolldown output chunk" and is the right hook for JS chunks. Whether it also affects CSS chunks depends on whether Rolldown/Vite emits CSS as chunks (which Vite 8 with cssCodeSplit may do) or as assets (where it would not apply).

This isn't a blocker — JS chunk salting is the primary use case and this PR delivers that correctly. But it would be good to:

  1. Add a brief comment noting the CSS/asset gap for future work
  2. File a follow-up issue if full parity is desired

Also, the comment on line 3212 says "supported by both Rollup (via crypto hash.update()) and Rolldown (via xxhash_base64_url)" — I'd soften this since the Rollup compatibility claim is hard to verify from the current deps (this project runs on Vite 8 / Rolldown, not Rollup). Simpler to just say "supported by Rolldown".

},
// 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
Expand Down
50 changes: 50 additions & 0 deletions tests/next-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -429,6 +478,7 @@ describe("detectNextIntlConfig", () => {
serverExternalPackages: [],
cacheHandler: undefined,
cacheMaxMemorySize: undefined,
hashSalt: "",
buildId: "test-build-id",
...overrides,
};
Expand Down
Loading