mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
499ee6a979
* refactor: remove wrangler from prerender, use startProdServer HTTP for all builds All builds — plain Node and Cloudflare Workers — now prerender via HTTP against a locally-spawned startProdServer instance. Removes findWranglerConfig, loadWrangler, WranglerWorker, isWorkersBuild branching, and all related fields from PrerenderAppOptions. Fixes generateStaticParamsMap not being accessible from CF bundles by falling through to the RSC handler for prerender endpoints. * . * fix: redirect:manual for prerender fetch, remove wrangler peer dep - Add redirect:manual to renderPage fetch in prerenderPages so that getStaticProps redirect responses are captured instead of followed. Fixes the 'escapes meta refresh URL to prevent HTML injection' test. - Remove wrangler from devDependencies and peerDependencies now that all prerender logic goes through the HTTP prod server path. * refactor: extract readPrerenderSecret to shared utility, fix server-manifest secret rotation - Move readPrerenderSecret to src/build/server-manifest.ts so both build-time code (prerender.ts) and runtime code (prod-server.ts) can import it without creating a circular dependency - Fix vinext:server-manifest plugin calling randomBytes() independently in each Vite environment (rsc + ssr), causing the second env to overwrite the first with a different secret; generate the secret once at plugin creation time via an IIFE closure - Replace the two duplicate manifest-read blocks in prod-server.ts with calls to the shared readPrerenderSecret helper * fix: warn on non-OK response from pages-static-paths prerender endpoint A 403 (stale secret) or 500 from the endpoint was silently treated the same as 'no paths', causing dynamic routes to be skipped without any indication of why. Now logs a warning with the HTTP status code so a stale-secret scenario is visible in the build output. * fix: guard static-params fetch against non-OK responses in prerenderApp A 403 (stale secret) or 500 from /__vinext/prerender/static-params was passed directly to JSON.parse, which throws on a non-JSON body like 'Forbidden'. The rejected Promise was then cached in staticParamsCache, poisoning all subsequent generateStaticParams lookups for that route. - Check res.ok before JSON.parse; return null and warn on non-OK status - Evict the cache entry on rejection so a transient error does not permanently block the route
181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
import { build, type ViteDevServer } from "vite";
|
|
import path from "node:path";
|
|
import http from "node:http";
|
|
import vinext from "../packages/vinext/src/index.js";
|
|
import { createIsolatedFixture, PAGES_FIXTURE_DIR, startFixtureServer } from "./helpers.js";
|
|
|
|
const CONCURRENCY = 15;
|
|
|
|
async function fetchConcurrentPages(
|
|
baseUrl: string,
|
|
pagePath: string,
|
|
count: number,
|
|
): Promise<string[]> {
|
|
const requests = Array.from({ length: count }, (_, i) =>
|
|
fetch(`${baseUrl}${pagePath}?id=${i}`).then((r) => r.text()),
|
|
);
|
|
|
|
return Promise.all(requests);
|
|
}
|
|
|
|
function extractTestId(html: string, testId: string): string | null {
|
|
const pattern = new RegExp(`data-testid="${testId}"[^>]*>([^<]*)<`);
|
|
const match = html.match(pattern);
|
|
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function extractTitle(html: string): string | null {
|
|
const match = html.match(/<title[^>]*>([^<]*)<\/title>/);
|
|
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function extractMetaContent(html: string, name: string): string | null {
|
|
const pattern = new RegExp(`<meta(?=[^>]*name="${name}")[^>]+content="([^"]*)"`, "i");
|
|
const match = html.match(pattern);
|
|
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
describe("Pages Router dev concurrency isolation", () => {
|
|
let server: ViteDevServer;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
({ server, baseUrl } = await startFixtureServer(PAGES_FIXTURE_DIR));
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
await server?.close();
|
|
});
|
|
|
|
it("<Head> elements do not leak between concurrent requests", async () => {
|
|
const htmlResults = await fetchConcurrentPages(baseUrl, "/concurrent-head", CONCURRENCY);
|
|
|
|
for (let i = 0; i < CONCURRENCY; i++) {
|
|
const title = extractTitle(htmlResults[i]);
|
|
const metaReqId = extractMetaContent(htmlResults[i], "req-id");
|
|
const bodyReqId = extractTestId(htmlResults[i], "req-id");
|
|
|
|
expect(bodyReqId, `response ${i} should contain its own req-id`).toBe(String(i));
|
|
expect(title, `response ${i} should have its own <title>`).toBe(`req-${bodyReqId}`);
|
|
expect(metaReqId, `response ${i} meta req-id should match body`).toBe(bodyReqId);
|
|
}
|
|
});
|
|
|
|
it("router SSR context does not leak between concurrent requests", async () => {
|
|
const htmlResults = await fetchConcurrentPages(baseUrl, "/concurrent-router", CONCURRENCY);
|
|
|
|
for (let i = 0; i < CONCURRENCY; i++) {
|
|
const ssrPathname = extractTestId(htmlResults[i], "ssr-pathname");
|
|
const routerPathname = extractTestId(htmlResults[i], "router-pathname");
|
|
|
|
expect(ssrPathname, `response ${i} ssr-pathname`).toBe("/concurrent-router");
|
|
expect(routerPathname, `response ${i} router-pathname`).toBe("/concurrent-router");
|
|
|
|
const nextData = htmlResults[i].match(/__NEXT_DATA__\s*=\s*(\{[^<]+\})/);
|
|
if (!nextData) throw new Error(`no __NEXT_DATA__ found in response ${i}`);
|
|
const data = JSON.parse(nextData[1]);
|
|
expect(data.props.pageProps.ssrQuery.id, `response ${i} ssrQuery.id`).toBe(String(i));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Pages Router prod concurrency isolation", () => {
|
|
let prodServer: http.Server | undefined;
|
|
let prodPort: number;
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await createIsolatedFixture(
|
|
PAGES_FIXTURE_DIR,
|
|
"vinext-pages-concurrency-",
|
|
(src) => !src.includes("node_modules") && !src.includes("alias-test"),
|
|
);
|
|
|
|
const outDir = path.join(tmpDir, "dist");
|
|
|
|
await build({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins: [vinext()],
|
|
logLevel: "silent",
|
|
build: {
|
|
outDir: path.join(outDir, "server"),
|
|
ssr: "virtual:vinext-server-entry",
|
|
rollupOptions: { output: { entryFileNames: "entry.js" } },
|
|
},
|
|
});
|
|
|
|
await build({
|
|
root: tmpDir,
|
|
configFile: false,
|
|
plugins: [vinext()],
|
|
logLevel: "silent",
|
|
build: {
|
|
outDir: path.join(outDir, "client"),
|
|
manifest: true,
|
|
ssrManifest: true,
|
|
rollupOptions: { input: "virtual:vinext-client-entry" },
|
|
},
|
|
});
|
|
|
|
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
|
|
({ server: prodServer } = await startProdServer({
|
|
port: 0,
|
|
host: "127.0.0.1",
|
|
outDir,
|
|
}));
|
|
|
|
const addr = prodServer!.address();
|
|
if (!addr || typeof addr === "string") {
|
|
throw new Error("Failed to start production server");
|
|
}
|
|
prodPort = addr.port;
|
|
}, 60000);
|
|
|
|
afterAll(async () => {
|
|
if (prodServer) {
|
|
await new Promise<void>((resolve) => prodServer!.close(() => resolve()));
|
|
}
|
|
if (tmpDir) {
|
|
const fsp = await import("node:fs/promises");
|
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// prod does not inject <Head> children into the html <head> section, so we
|
|
// can only verify getServerSideProps data isolation here (not title/meta).
|
|
it("getServerSideProps data does not leak between concurrent requests", async () => {
|
|
const base = `http://127.0.0.1:${prodPort}`;
|
|
const htmlResults = await fetchConcurrentPages(base, "/concurrent-head", CONCURRENCY);
|
|
|
|
for (let i = 0; i < CONCURRENCY; i++) {
|
|
const bodyReqId = extractTestId(htmlResults[i], "req-id");
|
|
expect(bodyReqId, `response ${i} should contain its own req-id`).toBe(String(i));
|
|
|
|
const nextData = htmlResults[i].match(/__NEXT_DATA__\s*=\s*(\{[^<]+\})/);
|
|
if (!nextData) throw new Error(`no __NEXT_DATA__ found in response ${i}`);
|
|
const data = JSON.parse(nextData[1]);
|
|
expect(data.props.pageProps.reqId, `response ${i} pageProps.reqId`).toBe(String(i));
|
|
}
|
|
});
|
|
|
|
it("SSR props do not leak between concurrent requests", async () => {
|
|
const base = `http://127.0.0.1:${prodPort}`;
|
|
const htmlResults = await fetchConcurrentPages(base, "/concurrent-router", CONCURRENCY);
|
|
|
|
for (let i = 0; i < CONCURRENCY; i++) {
|
|
const ssrPathname = extractTestId(htmlResults[i], "ssr-pathname");
|
|
expect(ssrPathname, `response ${i} ssr-pathname`).toBe("/concurrent-router");
|
|
|
|
const nextData = htmlResults[i].match(/__NEXT_DATA__\s*=\s*(\{[^<]+\})/);
|
|
if (!nextData) throw new Error(`no __NEXT_DATA__ found in response ${i}`);
|
|
const data = JSON.parse(nextData[1]);
|
|
expect(data.props.pageProps.ssrQuery.id, `response ${i} ssrQuery.id`).toBe(String(i));
|
|
}
|
|
});
|
|
});
|