mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
0c1193fd76
Pages Router ISR cache entries were keyed only by pathname. With i18n domain routing, the same pathname can render different content depending on the matched domain and default locale. A cache entry created for one domain could be reused for another domain. Add an i18n cache variant to the Pages ISR key when i18n is configured: - domain:<domain> for matched domain-locales - locale:<locale> for locale-prefix/default-locale routing Adds a Pages i18n production fixture page with getStaticProps + revalidate and verifies example.fr and example.com maintain separate ISR entries for the same pathname.
241 lines
7.9 KiB
TypeScript
241 lines
7.9 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
|
|
import { build } from "vite-plus";
|
|
import path from "node:path";
|
|
import fsp from "node:fs/promises";
|
|
import http from "node:http";
|
|
import vinext from "../packages/vinext/src/index.js";
|
|
import {
|
|
PAGES_I18N_DOMAINS_BASEPATH_FIXTURE_DIR,
|
|
PAGES_I18N_DOMAINS_FIXTURE_DIR,
|
|
createIsolatedFixture,
|
|
requestNodeServerWithHost,
|
|
} from "./helpers.js";
|
|
|
|
async function startProdFixture(
|
|
fixtureDir: string,
|
|
prefix: string,
|
|
): Promise<{
|
|
port: number;
|
|
server: http.Server;
|
|
tmpDir: string;
|
|
}> {
|
|
const tmpDir = await createIsolatedFixture(fixtureDir, prefix);
|
|
const outDir = path.join(tmpDir, "dist");
|
|
// Pages Router only — no RSC pipeline, so separate build() calls work.
|
|
// For App Router, use createBuilder().buildApp() instead.
|
|
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");
|
|
const { server } = await startProdServer({
|
|
port: 0,
|
|
host: "127.0.0.1",
|
|
outDir,
|
|
});
|
|
const addr = server.address();
|
|
if (!addr || typeof addr === "string") {
|
|
throw new Error(`Failed to start production server for fixture ${fixtureDir}`);
|
|
}
|
|
|
|
return { port: addr.port, server, tmpDir };
|
|
}
|
|
|
|
describe("Pages i18n domain routing (production)", () => {
|
|
let tmpDir: string;
|
|
let prodServer: http.Server;
|
|
let prodPort: number;
|
|
|
|
beforeAll(async () => {
|
|
({
|
|
server: prodServer,
|
|
tmpDir,
|
|
port: prodPort,
|
|
} = await startProdFixture(PAGES_I18N_DOMAINS_FIXTURE_DIR, "vinext-pages-i18n-prod-"));
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
if (prodServer) {
|
|
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
|
|
}
|
|
if (tmpDir) {
|
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 15000);
|
|
|
|
it("redirects the root path to the preferred locale domain", async () => {
|
|
const res = await requestNodeServerWithHost(prodPort, "/", "example.com", {
|
|
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
|
|
});
|
|
|
|
expect(res.status).toBe(307);
|
|
expect(res.headers.location).toBe("http://example.fr/");
|
|
});
|
|
|
|
it("uses Accept-Language rather than NEXT_LOCALE to pick the preferred domain", async () => {
|
|
const res = await requestNodeServerWithHost(prodPort, "/", "example.com", {
|
|
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
|
|
Cookie: "NEXT_LOCALE=en",
|
|
});
|
|
|
|
expect(res.status).toBe(307);
|
|
expect(res.headers.location).toBe("http://example.fr/");
|
|
});
|
|
|
|
it("preserves the search string on root locale redirects", async () => {
|
|
const res = await requestNodeServerWithHost(
|
|
prodPort,
|
|
"/?utm=campaign&next=%2Fcheckout",
|
|
"example.com",
|
|
{
|
|
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
|
|
},
|
|
);
|
|
|
|
expect(res.status).toBe(307);
|
|
expect(res.headers.location).toBe("http://example.fr/?utm=campaign&next=%2Fcheckout");
|
|
});
|
|
|
|
it("does not redirect unprefixed non-root paths for locale detection", async () => {
|
|
const res = await requestNodeServerWithHost(prodPort, "/about", "example.com", {
|
|
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.location).toBeUndefined();
|
|
});
|
|
|
|
it("renders locale-switcher links with the target locale domain during SSR", async () => {
|
|
const res = await requestNodeServerWithHost(prodPort, "/about", "example.com");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toContain('href="http://example.fr/about" id="switch-locale"');
|
|
});
|
|
|
|
it("uses the matched domain default locale for request context", async () => {
|
|
const res = await requestNodeServerWithHost(prodPort, "/about", "example.fr");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toContain('<p id="locale">fr</p>');
|
|
expect(res.body).toContain('<p id="defaultLocale">fr</p>');
|
|
expect(res.body).toContain('href="/about" id="switch-locale"');
|
|
expect(res.body).toContain('"defaultLocale":"fr"');
|
|
expect(res.body).toContain(
|
|
'"domainLocales":[{"domain":"example.com","defaultLocale":"en"},{"domain":"example.fr","defaultLocale":"fr","http":true}]',
|
|
);
|
|
});
|
|
|
|
it("keys Pages ISR entries by i18n domain context", async () => {
|
|
const fr = await requestNodeServerWithHost(prodPort, "/isr-about", "example.fr");
|
|
expect(fr.status).toBe(200);
|
|
expect(fr.headers["x-vinext-cache"]).toBe("MISS");
|
|
expect(fr.body).toContain('<p id="locale">fr</p>');
|
|
expect(fr.body).toContain('<p id="defaultLocale">fr</p>');
|
|
|
|
const en = await requestNodeServerWithHost(prodPort, "/isr-about", "example.com");
|
|
expect(en.status).toBe(200);
|
|
expect(en.headers["x-vinext-cache"]).toBe("MISS");
|
|
expect(en.body).toContain('<p id="locale">en</p>');
|
|
expect(en.body).toContain('<p id="defaultLocale">en</p>');
|
|
expect(en.body).not.toContain('<p id="locale">fr</p>');
|
|
|
|
const enHit = await requestNodeServerWithHost(prodPort, "/isr-about", "example.com");
|
|
expect(enHit.status).toBe(200);
|
|
expect(enHit.headers["x-vinext-cache"]).toBe("HIT");
|
|
expect(enHit.body).toContain('<p id="locale">en</p>');
|
|
});
|
|
|
|
// Issue #1336 item 3: locale prefix must be stripped before API route matching.
|
|
//
|
|
// Ported from Next.js: test/e2e/middleware-redirects/test/index.test.ts
|
|
// (the "should redirect to api route with locale" case, which exercises
|
|
// /fr/api/ok hitting pages/api/ok.js)
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-redirects/test/index.test.ts
|
|
it("matches /api/ok without a locale prefix", async () => {
|
|
const res = await requestNodeServerWithHost(prodPort, "/api/ok", "example.com");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toBe("ok");
|
|
});
|
|
|
|
it("matches /fr/api/ok by stripping the locale prefix (issue #1336)", async () => {
|
|
const res = await requestNodeServerWithHost(prodPort, "/fr/api/ok", "example.com");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toBe("ok");
|
|
});
|
|
|
|
it("preserves query parameters when stripping the locale prefix from an API path", async () => {
|
|
const res = await requestNodeServerWithHost(
|
|
prodPort,
|
|
"/fr/api/ok?foo=bar&baz=qux",
|
|
"example.com",
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toBe("ok");
|
|
});
|
|
});
|
|
|
|
describe("Pages i18n domain routing with basePath (production)", () => {
|
|
let tmpDir: string;
|
|
let prodServer: http.Server;
|
|
let prodPort: number;
|
|
|
|
beforeAll(async () => {
|
|
({
|
|
server: prodServer,
|
|
tmpDir,
|
|
port: prodPort,
|
|
} = await startProdFixture(
|
|
PAGES_I18N_DOMAINS_BASEPATH_FIXTURE_DIR,
|
|
"vinext-pages-i18n-basepath-prod-",
|
|
));
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
if (prodServer) {
|
|
await new Promise<void>((resolve) => prodServer.close(() => resolve()));
|
|
}
|
|
if (tmpDir) {
|
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}, 15000);
|
|
|
|
it("preserves basePath and trailingSlash in root locale redirects", async () => {
|
|
const res = await requestNodeServerWithHost(prodPort, "/app/?utm=campaign", "example.com", {
|
|
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
|
|
});
|
|
|
|
expect(res.status).toBe(307);
|
|
expect(res.headers.location).toBe("http://example.fr/app/?utm=campaign");
|
|
});
|
|
|
|
it("renders locale-switcher links with basePath on cross-domain hrefs", async () => {
|
|
const res = await requestNodeServerWithHost(prodPort, "/app/about/", "example.com");
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toContain('href="http://example.fr/app/about" id="switch-locale"');
|
|
});
|
|
});
|