Files
cloudflare__vinext/tests/compiler-define.test.ts
T
James Anderson 9596b72495 fix(pages-router): pass revalidateReason "on-demand" to gsp/gssp (#1462) (#1856)
* fix(pages-router): pass revalidateReason "on-demand" to getStaticProps (#1462)

Implements res.revalidate() for Pages Router API routes and wires the
on-demand revalidation trigger through to getStaticProps/getServerSideProps
context as revalidateReason: "on-demand".

res.revalidate(urlPath) issues an internal HEAD request to the target path
carrying the x-prerender-revalidate header (mirroring Next.js's api-resolver).
The Pages render path (dev-server and prod pages-page-data) detects that
header, bypasses the fresh/stale cache-hit short-circuits, and regenerates
the entry synchronously with reason "on-demand".

The build and stale reasons already worked; this finishes the on-demand case.

Closes #1462

* fix(pages-router): authenticate on-demand revalidation with a secret (#1462)

The on-demand `revalidateReason` detection treated `x-prerender-revalidate`
as an unauthenticated presence check and sent the literal value "1". Any
external client could send `x-prerender-revalidate: 1` to force synchronous
regeneration of any ISR page, bypassing the fresh/stale cache short-circuits
— a cache-stampede/DoS vector.

Mirror Next.js's `checkIsOnDemandRevalidate`: a request is only treated as
on-demand revalidation when the `x-prerender-revalidate` header value EQUALS a
secret (the vinext analog of `previewModeId`). Introduce a per-process
revalidate secret (`getRevalidateSecret`, 256-bit random hex via Web Crypto)
that `res.revalidate()` attaches to its internal loopback request, and a
constant-time `isOnDemandRevalidateRequest` equality check on the receiving
dev-server and Pages page handler. Header presence alone is never honored.

Also fix the test's HTML-reason extraction to avoid CodeQL's
incomplete-sanitization / bad-HTML-regex findings, and add a negative test
asserting forged `x-prerender-revalidate` values ("1", "", a guess) are
rejected while the real secret is accepted.

* refactor(pages-router): share res.revalidate() impl; support unstable_onlyGenerated (#1462)

Address non-blocking bonk review findings:
- Extract the two near-identical `res.revalidate()` implementations
  (`api-handler.ts`, `pages-node-compat.ts`) into a shared
  `performOnDemandRevalidate` helper so the secret wiring and success detection
  cannot drift between dev and prod.
- Align success detection with Next.js's api-resolver: accept
  `x-nextjs-cache: REVALIDATED`, status 200, or a 404 when the caller passed
  `unstable_onlyGenerated`. Add `unstable_onlyGenerated` support
  (`x-prerender-revalidate-if-generated` header).

* docs(pages-router): note x-nextjs-cache REVALIDATED branch is parity-only (#1462)

* fix(pages-router): make on-demand revalidate secret build-time (shared across isolates)

The on-demand ISR revalidation secret was a per-process random value. On
Cloudflare Workers, res.revalidate()'s loopback fetch() can land on a different
isolate than the sender, so a per-process secret mismatches across isolates and
false-rejects legitimate revalidations.

Mirror Next.js's previewModeId: generate one 256-bit secret at build time (in
the vinext build CLI, once per build, shared across plugin instances like
__VINEXT_SHARED_BUILD_ID) and bake it SERVER-ONLY into every server bundle via a
Vite define (__VINEXT_REVALIDATE_SECRET). It is byte-for-byte identical in every
isolate and never reaches the client bundle. getRevalidateSecret() reads the
baked constant at runtime and falls back to a process-shared random secret in
dev (single-process, no regression). Also persist revalidateSecret in
vinext-server.json for diagnostics/tooling parity with prerenderSecret.

Constant-time safeEqual validation and reject-arrays/empty/absent behavior are
unchanged.

Refs #1462

* test(pages-router): cover server-only revalidate secret; drop dead manifest reader

Address ask-bonk review of the build-time revalidate secret:

- Add tests pinning the security-critical invariant that the
  __VINEXT_REVALIDATE_SECRET define is baked into server environments (rsc/ssr)
  but NEVER into the client bundle (the client env returns null outright). A
  leak would ship the secret to every browser and re-open the cache-stampede
  vector the equality check prevents. Also harden the existing "no-ops when
  defineServer unset" test to explicitly clear the env var instead of relying on
  it happening to be unset.
- Drop the unused readRevalidateSecret export and the orphaned revalidateSecret
  manifest field — the runtime authenticates against the baked define (both
  Workers isolates and the Node prod-server run the bundle), so the on-disk copy
  had no consumer (knip-flagged dead code).
- Hoist the client early-return in vinext:compiler-define-server so the
  server-only guarantee is structural (removes the redundant name check), and
  reword the dev-fallback comments ("absent unless set during vinext build").

Refs #1462
2026-06-09 10:07:50 +01:00

315 lines
12 KiB
TypeScript

/**
* compiler.define / compiler.defineServer tests.
*
* Verifies that vinext forwards `next.config.compiler.define` to Vite's
* top-level `define` (applies to client + server) and forwards
* `compiler.defineServer` only to non-client Vite environments via the
* `configEnvironment` hook.
*
* Ported from Next.js: test/e2e/define/define.test.ts
* https://github.com/vercel/next.js/blob/canary/test/e2e/define/define.test.ts
*/
import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test";
import os from "node:os";
import fsp from "node:fs/promises";
import path from "node:path";
// Standard `@types/...` for these Node built-ins live in the workspace, so
// the imports above are fully typed without explicit casts.
type VinextPlugin = {
name: string;
config?: (config: unknown, env: { command: string }) => unknown;
configEnvironment?: (
name: string,
config: unknown,
env: { command: string },
) => { define?: Record<string, string> } | null | void;
};
async function setupTmpProject(nextConfigBody: string): Promise<string> {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-compiler-define-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
await fsp.writeFile(path.join(tmpDir, "next.config.mjs"), nextConfigBody);
return tmpDir;
}
describe("compiler.define forwarding to Vite", () => {
it("merges `compiler.define` entries into the top-level Vite `define`", async () => {
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins = vinext() as VinextPlugin[];
const mainPlugin = plugins.find(
(p) => p.name === "vinext:config" && typeof p.config === "function",
);
expect(mainPlugin).toBeDefined();
const tmpDir = await setupTmpProject(
`export default {
compiler: {
define: {
MY_MAGIC_VARIABLE: "foobar",
"process.env.MY_MAGIC_EXPR": "barbaz",
MY_NUMBER_VARIABLE: 42,
MY_BOOLEAN_VARIABLE: true,
},
},
};`,
);
try {
const result = (await mainPlugin!.config!(
{ root: tmpDir, build: {}, plugins: [], optimizeDeps: {} },
{ command: "build" },
)) as { define?: Record<string, string> };
expect(result.define).toBeDefined();
expect(result.define!.MY_MAGIC_VARIABLE).toBe('"foobar"');
expect(result.define!["process.env.MY_MAGIC_EXPR"]).toBe('"barbaz"');
expect(result.define!.MY_NUMBER_VARIABLE).toBe("42");
expect(result.define!.MY_BOOLEAN_VARIABLE).toBe("true");
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 15000);
it("does NOT merge `compiler.defineServer` into the top-level Vite `define`", async () => {
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins = vinext() as VinextPlugin[];
const mainPlugin = plugins.find(
(p) => p.name === "vinext:config" && typeof p.config === "function",
);
expect(mainPlugin).toBeDefined();
const tmpDir = await setupTmpProject(
`export default {
compiler: {
defineServer: { MY_SERVER_VARIABLE: "server" },
},
};`,
);
try {
const result = (await mainPlugin!.config!(
{ root: tmpDir, build: {}, plugins: [], optimizeDeps: {} },
{ command: "build" },
)) as { define?: Record<string, string> };
// Server-only defines must not leak into the global Vite define;
// they're layered in per-environment instead.
expect(result.define?.MY_SERVER_VARIABLE).toBeUndefined();
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 15000);
it("applies `compiler.defineServer` to non-client environments via configEnvironment", async () => {
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins = vinext() as VinextPlugin[];
const mainPlugin = plugins.find(
(p) => p.name === "vinext:config" && typeof p.config === "function",
);
const serverDefinePlugin = plugins.find((p) => p.name === "vinext:compiler-define-server");
expect(mainPlugin).toBeDefined();
expect(serverDefinePlugin).toBeDefined();
const tmpDir = await setupTmpProject(
`export default {
compiler: {
define: { CLIENT_SAFE: "shared" },
defineServer: {
MY_SERVER_VARIABLE: "server",
"process.env.MY_MAGIC_SERVER_EXPR": "serverbarbaz",
},
},
};`,
);
try {
// `config` must run first so the plugin reads nextConfig.
await mainPlugin!.config!(
{ root: tmpDir, build: {}, plugins: [], optimizeDeps: {} },
{ command: "build" },
);
const rscResult = serverDefinePlugin!.configEnvironment!("rsc", {}, { command: "build" });
const ssrResult = serverDefinePlugin!.configEnvironment!("ssr", {}, { command: "build" });
const clientResult = serverDefinePlugin!.configEnvironment!(
"client",
{},
{ command: "build" },
);
expect(rscResult?.define).toEqual({
MY_SERVER_VARIABLE: '"server"',
"process.env.MY_MAGIC_SERVER_EXPR": '"serverbarbaz"',
});
expect(ssrResult?.define).toEqual({
MY_SERVER_VARIABLE: '"server"',
"process.env.MY_MAGIC_SERVER_EXPR": '"serverbarbaz"',
});
// Client environment must never receive server-only defines.
expect(clientResult).toBeNull();
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 15000);
// Mirrors Next.js: packages/next/src/build/define-env.ts (collision check)
it("throws when `compiler.define` collides with a vinext built-in", async () => {
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins = vinext() as VinextPlugin[];
const mainPlugin = plugins.find(
(p) => p.name === "vinext:config" && typeof p.config === "function",
);
expect(mainPlugin).toBeDefined();
const tmpDir = await setupTmpProject(
`export default {
compiler: {
define: { "process.env.NODE_ENV": "evil" },
},
};`,
);
try {
await expect(
mainPlugin!.config!(
{ root: tmpDir, build: {}, plugins: [], optimizeDeps: {} },
{ command: "build" },
),
).rejects.toThrow(/compiler\.define.*process\.env\.NODE_ENV/);
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 15000);
it("throws when `compiler.defineServer` collides with `compiler.define` or a built-in", async () => {
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins = vinext() as VinextPlugin[];
const mainPlugin = plugins.find(
(p) => p.name === "vinext:config" && typeof p.config === "function",
);
expect(mainPlugin).toBeDefined();
const tmpDir = await setupTmpProject(
`export default {
compiler: {
define: { SHARED: "client" },
defineServer: { SHARED: "server" },
},
};`,
);
try {
await expect(
mainPlugin!.config!(
{ root: tmpDir, build: {}, plugins: [], optimizeDeps: {} },
{ command: "build" },
),
).rejects.toThrow(/compiler\.defineServer.*SHARED/);
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 15000);
it("no-ops the configEnvironment hook when `defineServer` is not configured", async () => {
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins = vinext() as VinextPlugin[];
const mainPlugin = plugins.find(
(p) => p.name === "vinext:config" && typeof p.config === "function",
);
const serverDefinePlugin = plugins.find((p) => p.name === "vinext:compiler-define-server");
expect(mainPlugin).toBeDefined();
expect(serverDefinePlugin).toBeDefined();
// Explicitly clear the build-time revalidate secret env var so the hook has
// no `defineServer` entries AND no baked revalidate-secret define — only
// then is the truly-empty no-op path exercised. (Without this the test would
// silently depend on the env var happening to be unset in the test process.)
const prev = process.env.__VINEXT_SHARED_REVALIDATE_SECRET;
delete process.env.__VINEXT_SHARED_REVALIDATE_SECRET;
const tmpDir = await setupTmpProject(`export default {};`);
try {
await mainPlugin!.config!(
{ root: tmpDir, build: {}, plugins: [], optimizeDeps: {} },
{ command: "build" },
);
const rscResult = serverDefinePlugin!.configEnvironment!("rsc", {}, { command: "build" });
expect(rscResult).toBeNull();
} finally {
if (prev === undefined) delete process.env.__VINEXT_SHARED_REVALIDATE_SECRET;
else process.env.__VINEXT_SHARED_REVALIDATE_SECRET = prev;
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 15000);
});
describe("build-time revalidate secret define (security: server-only)", () => {
// The on-demand ISR revalidation secret is baked into server bundles via a
// SERVER-ONLY define so all Workers isolates share it. The whole security
// model depends on it NEVER reaching the client bundle — a leak would ship the
// secret to every browser and re-open the cache-stampede/DoS vector that the
// equality check exists to prevent. These tests pin that invariant.
const TEST_SECRET = "a".repeat(64);
let prevSecret: string | undefined;
beforeEach(() => {
prevSecret = process.env.__VINEXT_SHARED_REVALIDATE_SECRET;
process.env.__VINEXT_SHARED_REVALIDATE_SECRET = TEST_SECRET;
});
afterEach(() => {
if (prevSecret === undefined) delete process.env.__VINEXT_SHARED_REVALIDATE_SECRET;
else process.env.__VINEXT_SHARED_REVALIDATE_SECRET = prevSecret;
});
async function getServerDefinePlugin(): Promise<VinextPlugin> {
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins = vinext() as VinextPlugin[];
const mainPlugin = plugins.find(
(p) => p.name === "vinext:config" && typeof p.config === "function",
);
const serverDefinePlugin = plugins.find((p) => p.name === "vinext:compiler-define-server");
expect(mainPlugin).toBeDefined();
expect(serverDefinePlugin).toBeDefined();
const tmpDir = await setupTmpProject(`export default {};`);
try {
// `config` must run first so the plugin reads nextConfig.
await mainPlugin!.config!(
{ root: tmpDir, build: {}, plugins: [], optimizeDeps: {} },
{ command: "build" },
);
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
return serverDefinePlugin!;
}
it("bakes the secret into server environments (rsc, ssr)", async () => {
const serverDefinePlugin = await getServerDefinePlugin();
for (const env of ["rsc", "ssr"]) {
const result = serverDefinePlugin.configEnvironment!(env, {}, { command: "build" });
expect(result?.define?.["process.env.__VINEXT_REVALIDATE_SECRET"]).toBe(
JSON.stringify(TEST_SECRET),
);
}
}, 15000);
it("NEVER bakes the secret into the client environment", async () => {
const serverDefinePlugin = await getServerDefinePlugin();
const clientResult = serverDefinePlugin.configEnvironment!("client", {}, { command: "build" });
// The client env returns null outright — no define object at all — so the
// secret cannot reach the browser bundle. Assert both the null return and
// (defensively) the absence of the key in any returned define.
expect(clientResult).toBeNull();
expect(clientResult?.define?.["process.env.__VINEXT_REVALIDATE_SECRET"]).toBeUndefined();
}, 15000);
});