mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
46ab691094
Mirrors Next.js's server-side default-locale path normalisation in
`packages/next/src/server/lib/router-utils/resolve-routes.ts` (lines
~250-263): every request that arrives without a locale prefix is
spliced with `/${defaultLocale}` before any `next.config.js`
redirect / rewrite / header rule (or filesystem route) match runs.
Without this, rules like `source: '/:locale/to-sv'` with
`locale: false` failed to match requests for `/to-sv` because there
was no segment for the `:locale` placeholder. After normalisation the
matcher sees `/en/to-sv` and the rule matches with `:locale=en`.
Per the merged PR #1382's description, the issue text's call for a 308
redirect from `/en/page` to `/page` is incorrect: Next.js serves
the same content under both URLs and normalises the path internally.
This change implements that internal normalisation; no redirect.
The new helper lives next to the existing pages i18n helpers so both
routers can use it. Wired into:
- `prod-server.ts` (Pages Router prod)
- `app-rsc-handler.ts` (App Router dev + prod)
- `index.ts` (Pages Router dev plugin)
- `deploy.ts` (Cloudflare Workers entry template)
The domain-mapped default locale (`i18n.domains[].defaultLocale`) is
preferred over the global default when the inbound host matches,
matching Next.js's `domainLocale.defaultLocale` branch in
resolve-routes.ts. `/_next/*` and `/__vinext/*` paths are excluded
to mirror Next.js's exception for build assets and prerender manifests.
The fallback "retain the original unprefixed source" branch in
`applyLocaleToRoutes` is intentionally kept as belt-and-braces: the
new normalisation pass renders it dead-code for ordinary requests, but
it harmlessly preserves backwards compatibility with any matcher
caller that still passes raw, unnormalised pathnames.
Refs #1336 (item 4)
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
import { describe, expect, it } from "vite-plus/test";
|
|
import { VINEXT_RSC_VARY_HEADER } from "../packages/vinext/src/server/app-rsc-cache-busting.js";
|
|
import { finalizeAppRscResponse } from "../packages/vinext/src/server/app-rsc-response-finalizer.js";
|
|
import type { RequestContext } from "../packages/vinext/src/config/config-matchers.js";
|
|
|
|
function makeRequestContext(headers: Headers = new Headers()): RequestContext {
|
|
return {
|
|
headers,
|
|
cookies: {},
|
|
query: new URLSearchParams(),
|
|
host: "example.com",
|
|
};
|
|
}
|
|
|
|
// ── config headers applied to non-redirect responses ────────────────────
|
|
|
|
describe("finalizeAppRscResponse — config header application", () => {
|
|
it("applies a matching config header to a 200 response", () => {
|
|
// Behavior: /about page response gets x-added header from next.config.js headers[].
|
|
// Regression: expected null to be "config"
|
|
const response = new Response("body", { status: 200 });
|
|
const request = new Request("http://example.com/about");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [{ source: "/about", headers: [{ key: "x-added", value: "config" }] }],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("x-added")).toBe("config");
|
|
});
|
|
|
|
it("adds the App Router RSC vary header when no config headers are configured", () => {
|
|
// Behavior: App Router responses always carry the RSC vary key, even when
|
|
// no next.config.js headers match. This covers app route handlers that
|
|
// return their own Response object instead of using app page helpers.
|
|
// Ported from Next.js:
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/vary-header/test/index.test.ts
|
|
const response = new Response("body", { status: 200, headers: { "x-existing": "keep" } });
|
|
const request = new Request("http://example.com/about");
|
|
|
|
const result = finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(result).toBe(response);
|
|
expect(result.headers.get("x-existing")).toBe("keep");
|
|
expect(result.headers.get("vary")).toBe(VINEXT_RSC_VARY_HEADER);
|
|
});
|
|
|
|
it("does not apply config headers when source pattern does not match", () => {
|
|
// Behavior: /blog response is unaffected by a config header scoped to /about.
|
|
// Regression: expected "config" to be null.
|
|
const response = new Response("body", { status: 200 });
|
|
const request = new Request("http://example.com/blog");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [{ source: "/about", headers: [{ key: "x-added", value: "config" }] }],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("x-added")).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── App Router RSC vary header ──────────────────────────────────────────
|
|
|
|
describe("finalizeAppRscResponse — App Router RSC vary header", () => {
|
|
it("preserves custom Vary values while appending the internal RSC vary key", () => {
|
|
const response = new Response("body", { status: 200, headers: { Vary: "User-Agent" } });
|
|
const request = new Request("http://example.com/normal");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("vary")).toBe(`User-Agent, ${VINEXT_RSC_VARY_HEADER}`);
|
|
});
|
|
|
|
it("does not duplicate RSC vary tokens already set by app page helpers", () => {
|
|
const response = new Response("body", {
|
|
status: 200,
|
|
headers: { Vary: VINEXT_RSC_VARY_HEADER },
|
|
});
|
|
const request = new Request("http://example.com/about");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("vary")).toBe(VINEXT_RSC_VARY_HEADER);
|
|
});
|
|
|
|
it("preserves wildcard Vary semantics", () => {
|
|
const response = new Response("body", { status: 200, headers: { Vary: "*" } });
|
|
const request = new Request("http://example.com/about");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("vary")).toBe("*");
|
|
});
|
|
});
|
|
|
|
// ── redirect responses skipped ──────────────────────────────────────────
|
|
|
|
describe("finalizeAppRscResponse — redirect responses are not mutated", () => {
|
|
it("does not throw when called with an immutable 307 redirect response", () => {
|
|
// Behavior: Response.redirect() creates immutable headers; calling finalizeAppRscResponse
|
|
// on such a response must never throw "Cannot modify immutable headers".
|
|
// Regression: TypeError: Cannot modify immutable headers
|
|
const response = Response.redirect("http://example.com/new", 307);
|
|
const request = new Request("http://example.com/old");
|
|
|
|
expect(() =>
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [{ source: "/old", headers: [{ key: "x-added", value: "yes" }] }],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(),
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("does not apply config headers to a mutable 308 permanent redirect", () => {
|
|
// Behavior: 308 redirect responses skip config header application regardless of mutability.
|
|
// Regression: expected "yes" to be null — header applied to redirect response.
|
|
const response = new Response(null, { status: 308, headers: { Location: "/new" } });
|
|
const request = new Request("http://example.com/old");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [{ source: "/old", headers: [{ key: "x-added", value: "yes" }] }],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("x-added")).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── basePath stripping ──────────────────────────────────────────────────
|
|
|
|
describe("finalizeAppRscResponse — basePath stripping before pattern matching", () => {
|
|
it("strips basePath before matching config header source patterns", () => {
|
|
// Behavior: config header source "/about" applies to request "/app/about" when basePath="/app".
|
|
// Regression: expected null to be "config" — header not matched because /app/about ≠ /about.
|
|
const response = new Response("body", { status: 200 });
|
|
const request = new Request("http://example.com/app/about");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "/app",
|
|
configHeaders: [{ source: "/about", headers: [{ key: "x-added", value: "config" }] }],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("x-added")).toBe("config");
|
|
});
|
|
|
|
it("does not strip basePath when pathname only shares a string prefix (segment boundary)", () => {
|
|
// Behavior: /app2/page with basePath /app must not strip /app, because /app2 is a
|
|
// different path segment. The config header source "/2/page" must not match.
|
|
// Regression: expected "yes" to be null — basePath incorrectly stripped past segment
|
|
// boundary, turning /app2/page into /2/page which then matched the source.
|
|
const response = new Response("body", { status: 200 });
|
|
const request = new Request("http://example.com/app2/page");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "/app",
|
|
configHeaders: [{ source: "/2/page", headers: [{ key: "x-wrong-strip", value: "yes" }] }],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("x-wrong-strip")).toBeNull();
|
|
});
|
|
|
|
it("strips nested basePath correctly", () => {
|
|
// Behavior: config header source "/guide" applies to /docs/v2/guide when basePath="/docs/v2".
|
|
// Regression: expected null to be "config".
|
|
const response = new Response("body", { status: 200 });
|
|
const request = new Request("http://example.com/docs/v2/guide");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "/docs/v2",
|
|
configHeaders: [{ source: "/guide", headers: [{ key: "x-added", value: "config" }] }],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("x-added")).toBe("config");
|
|
});
|
|
});
|
|
|
|
// ── request context snapshot ────────────────────────────────────────────
|
|
|
|
describe("finalizeAppRscResponse — has/missing conditions use original request context", () => {
|
|
it("applies header only when has-condition matches the provided request context", () => {
|
|
// Behavior: config header with has[type=header] applies only when the original request
|
|
// carries the expected header. The requestContext is the pre-middleware snapshot.
|
|
// Regression: header applied unconditionally (requestContext ignored).
|
|
const response = new Response("body", { status: 200 });
|
|
const request = new Request("http://example.com/about");
|
|
const reqCtxWithFlag = makeRequestContext(new Headers({ "x-preview": "1" }));
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [
|
|
{
|
|
source: "/about",
|
|
has: [{ type: "header", key: "x-preview", value: "1" }],
|
|
headers: [{ key: "x-conditional", value: "yes" }],
|
|
},
|
|
],
|
|
i18nConfig: null,
|
|
requestContext: reqCtxWithFlag,
|
|
});
|
|
|
|
expect(response.headers.get("x-conditional")).toBe("yes");
|
|
});
|
|
|
|
it("does not apply header when has-condition does not match the request context", () => {
|
|
// Behavior: header skipped when the has-condition fails for the original request.
|
|
// Regression: expected "yes" to be null — condition bypassed.
|
|
const response = new Response("body", { status: 200 });
|
|
const request = new Request("http://example.com/about");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [
|
|
{
|
|
source: "/about",
|
|
has: [{ type: "header", key: "x-preview", value: "1" }],
|
|
headers: [{ key: "x-conditional", value: "yes" }],
|
|
},
|
|
],
|
|
i18nConfig: null,
|
|
requestContext: makeRequestContext(), // no x-preview header
|
|
});
|
|
|
|
expect(response.headers.get("x-conditional")).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── default-locale path normalisation (issue #1336, item 4) ────────────
|
|
|
|
describe("finalizeAppRscResponse — default-locale path normalisation", () => {
|
|
it("matches a config header rule with a :locale placeholder against an unprefixed request", () => {
|
|
// Behavior: a header rule sourced at "/:locale/about" must match a request to
|
|
// "/about" when the i18n default locale is "en", because Next.js splices the
|
|
// default locale into unprefixed paths before config header matching.
|
|
// Without normalisation this header would only fire for "/en/about".
|
|
const response = new Response("body", { status: 200 });
|
|
const request = new Request("http://example.com/about");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [
|
|
{ source: "/:locale/about", headers: [{ key: "x-localized", value: "yes" }] },
|
|
],
|
|
i18nConfig: { locales: ["en", "fr"], defaultLocale: "en" },
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("x-localized")).toBe("yes");
|
|
});
|
|
|
|
it("matches a domain-mapped default locale, not the global one, when the host matches", () => {
|
|
// Behavior: when the request host matches a domain entry, that domain's
|
|
// defaultLocale wins over the global default. A rule for "/:locale/about"
|
|
// on example.fr (defaultLocale "fr") must match "/about" by treating it
|
|
// as "/fr/about" rather than "/en/about".
|
|
const response = new Response("body", { status: 200 });
|
|
const request = new Request("http://example.fr/about");
|
|
|
|
finalizeAppRscResponse(response, request, {
|
|
basePath: "",
|
|
configHeaders: [
|
|
{ source: "/fr/about", headers: [{ key: "x-fr", value: "yes" }] },
|
|
{ source: "/en/about", headers: [{ key: "x-en", value: "yes" }] },
|
|
],
|
|
i18nConfig: {
|
|
locales: ["en", "fr"],
|
|
defaultLocale: "en",
|
|
domains: [{ domain: "example.fr", defaultLocale: "fr" }],
|
|
},
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("x-fr")).toBe("yes");
|
|
expect(response.headers.get("x-en")).toBeNull();
|
|
});
|
|
});
|