Files
cloudflare__vinext/tests/app-rsc-response-finalizer.test.ts
T
James Anderson 46ab691094 fix(i18n): normalise default-locale paths before route matching (#1409)
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)
2026-05-22 13:55:03 +01:00

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();
});
});