mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
01b64a1f71
* refactor(app-rsc): introduce app-rsc-request-normalization module
The RSC entry handler inlined all URL parsing, pathname decoding, basePath
checking, RSC detection, and header sanitization directly in its
_handleRequest template. That made the logic impossible to unit-test, hard to
audit for security, and duplicated normalizeMountedSlotsHeader in three places.
Create server/app-rsc-request-normalization.ts to own the full normalization
pipeline as a single, typed function (normalizeRscRequest). The function
returns a discriminated Response | NormalizedRscRequest, encoding early exits
(400/404) in the type rather than as throw-then-catch pairs.
The ordering within the function is security-critical and documented step by
step:
1. Parse URL
2. Protocol-relative guard (on raw pathname, before normalizePath collapses //)
3. Strict segment-wise percent-decode (malformed encoding -> 400)
4. normalizePath (collapse //, resolve . and .. segments)
5. basePath check + strip (missing prefix -> 404; /__vinext/ bypasses)
6/7. RSC detection and cleanPathname
8. Sanitize X-Vinext-Interception-Context (strip null bytes)
9. Normalize x-vinext-mounted-slots (dedup, sort)
Consolidate normalizeMountedSlotsHeader: the function existed independently in
isr-cache.ts and as a private copy in app-elements.ts. Both now import from
the new module. isr-cache.ts re-exports it for callers already importing from
there.
43 new behavior tests cover the security-sensitive paths: protocol-relative
variants (literal, backslash, %5C, %2F), malformed percent sequences, basePath
enforcement, %2F preservation, RSC detection by suffix and Accept header,
interception context sanitization, and mounted-slots normalization.
* refactor(app-rsc): extract response finalisation into typed helper
Config header application runs inline in the generated RSC entry handler()
as a template string with no tests. Every response variant (page, route
handler, server action, not-found) goes through handler(), but the shared
finalisation logic cannot be tested or reused — a classic rot factory.
Create server/app-rsc-response-finalizer.ts that owns:
- Skip 3xx redirect responses. Response.redirect() produces immutable Headers
that throw on write; Next.js also deliberately excludes config headers from
redirects.
- Normalize the request pathname (segment-wise percent-decode + collapse)
before matching config header source patterns.
- Strip basePath at a segment boundary before matching. The old inline code
used pathname.startsWith(basePath) which incorrectly matched /app2/page when
basePath is /app; stripBasePath() enforces the separator boundary.
- Apply matching config headers via applyConfigHeadersToResponse, preserving
middleware-set header precedence.
The generated entry now calls __finalizeAppRscResponse(response, request, {...})
in place of ~15 lines of template logic, and drops the now-unused
applyConfigHeadersToResponse and normalizePathnameForRouteMatch imports from
the generated code.
Tests cover: header applied to 200, empty configHeaders no-op, source
pattern miss, immutable 307 no throw, mutable 308 header skipped, basePath
strip, basePath segment-boundary (regression for startsWith bug), nested
basePath, has-condition match, has-condition miss.
197 lines
8.4 KiB
TypeScript
197 lines
8.4 KiB
TypeScript
import { describe, expect, it } from "vite-plus/test";
|
|
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" }] }],
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("x-added")).toBe("config");
|
|
});
|
|
|
|
it("does not apply config headers when none are configured", () => {
|
|
// Behavior: empty configHeaders list → response returned as-is with no mutation.
|
|
// Regression: unexpected header appears, or function throws.
|
|
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: [],
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(result).toBe(response);
|
|
expect(result.headers.get("x-existing")).toBe("keep");
|
|
});
|
|
|
|
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" }] }],
|
|
requestContext: makeRequestContext(),
|
|
});
|
|
|
|
expect(response.headers.get("x-added")).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── 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" }] }],
|
|
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" }] }],
|
|
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" }] }],
|
|
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" }] }],
|
|
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" }] }],
|
|
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" }],
|
|
},
|
|
],
|
|
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" }],
|
|
},
|
|
],
|
|
requestContext: makeRequestContext(), // no x-preview header
|
|
});
|
|
|
|
expect(response.headers.get("x-conditional")).toBeNull();
|
|
});
|
|
});
|