Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@ export default __createAppRscHandler({
scriptNonce,
searchParams,
renderMode,
segmentPrefetchPath,
Comment thread
yunus25jmi1 marked this conversation as resolved.
}) {
const PageComponent = route.page?.default;
const __segmentConfig = __resolveAppPageSegmentConfig({
Expand Down
12 changes: 10 additions & 2 deletions packages/vinext/src/server/app-rsc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ type DispatchMatchedPageOptions<TRoute> = {
scriptNonce?: string;
searchParams: URLSearchParams;
renderMode: AppRscRenderMode;
segmentPrefetchPath: string | null;
};

type DispatchMatchedRouteHandlerOptions<TRoute> = {
Expand Down Expand Up @@ -312,8 +313,14 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
const normalized = normalizeRscRequest(request, options.basePath);
if (normalized instanceof Response) return normalized;

const { url, isRscRequest, interceptionContextHeader, mountedSlotsHeader, renderMode } =
normalized;
const {
url,
isRscRequest,
interceptionContextHeader,
mountedSlotsHeader,
renderMode,
segmentPrefetchPath,
} = normalized;
let { pathname, cleanPathname } = normalized;
// Canonical (external) pathname the user requested. Middleware rewrites and
// next.config.js rewrites mutate `cleanPathname` so internal route matching
Expand Down Expand Up @@ -604,6 +611,7 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
scriptNonce,
searchParams: url.searchParams,
renderMode,
segmentPrefetchPath,
});
}

Expand Down
52 changes: 40 additions & 12 deletions packages/vinext/src/server/app-rsc-request-normalization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
parseAppRscRenderMode,
type AppRscRenderMode,
} from "./app-rsc-render-mode.js";
import { extractSegmentPrefetchRsc } from "./app-segment-prefetch-normalizer.js";
import { badRequestResponse, notFoundResponse } from "./http-error-responses.js";

export { normalizeMountedSlotsHeader } from "./app-mounted-slots-header.js";
Expand All @@ -40,6 +41,17 @@ export type NormalizedRscRequest = {
renderMode: AppRscRenderMode;
/** Disabled ClientReuseManifest hint. Never authorizes skip transport in this stage. */
clientReuseManifest: ClientReuseManifestParseResult;
/**
* When present, this is a segment-prefetch RSC request for a specific
* route segment (e.g. "/_tree", "/dashboard/__PAGE__"). The cleanPathname
* is the original page path after the .segments/ prefix was stripped.
*
* Currently threaded through DispatchMatchedPageOptions for Phase 1 plumbing.
* Not yet consumed by render — the handler returns full-page RSC for all
* segment prefetch requests until Phase 2 adds segment-level response
* generation.
*/
segmentPrefetchPath: string | null;
};

/**
Expand All @@ -59,14 +71,14 @@ export type NormalizedRscRequest = {
* 4. Collapse double-slashes, resolve `.` and `..` segments (normalizePath)
* 5. basePath check + strip — 404 when pathname lacks the basePath prefix.
* `/__vinext/` bypasses this for internal prerender endpoints.
* 6. RSC detection: `.rsc` suffix only. RSC headers do not select payload
* rendering at the canonical HTML URL, so caches that ignore Vary cannot
* store Flight responses under HTML URLs.
* 7. cleanPathname — pathname with `.rsc` suffix stripped
* 8. Sanitize X-Vinext-Interception-Context — strip null bytes (header injection)
* 9. Normalize x-vinext-mounted-slots — dedup and sort for canonical cache keys
* 10. Read semantic render mode for refresh/action payload rendering
* 11. Parse disabled ClientReuseManifest hints on canonical RSC payload requests
* 6. Segment-prefetch detection: `.segments/*.segment.rsc` URLs are normalized back
* to the original page path. The segment path is extracted for downstream handling.
* 7. RSC detection: `.rsc` suffix only. Segment-prefetch requests are always treated as RSC.
* 8. cleanPathname — pathname with `.rsc` suffix stripped
* 9. Sanitize X-Vinext-Interception-Context — strip null bytes (header injection)
* 10. Normalize x-vinext-mounted-slots — dedup and sort for canonical cache keys
* 11. Read semantic render mode for refresh/action payload rendering
* 12. Parse disabled ClientReuseManifest hints on canonical RSC payload requests
*
* @returns A 400 or 404 Response for invalid or out-of-scope inputs,
* or a NormalizedRscRequest for valid requests.
Expand Down Expand Up @@ -107,19 +119,34 @@ export function normalizeRscRequest(
pathname = stripBasePath(pathname, basePath);
}

// Steps 6-7: RSC detection and cleanPathname.
const isRscRequest = pathname.endsWith(".rsc");
// Step 6: Segment-prefetch URL detection.
// extractSegmentPrefetchRsc returns null for non-matching paths (single
// regex run) so no separate match guard is needed.
let segmentPrefetchPath: string | null = null;
const extracted = extractSegmentPrefetchRsc(pathname);
if (extracted) {
segmentPrefetchPath = extracted.segmentPath;
pathname = extracted.originalPathname;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The matchSegmentPrefetchRsc guard is redundant — extractSegmentPrefetchRsc already returns null for non-matches, so the if (extracted) check handles it. Running the regex twice on every segment-prefetch request is wasteful on a hot path.

Suggested change
}
let segmentPrefetchPath: string | null = null;
const extracted = extractSegmentPrefetchRsc(pathname);
if (extracted) {
segmentPrefetchPath = extracted.segmentPath;
pathname = extracted.originalPathname;
}


// Steps 7-8: RSC detection and cleanPathname.
// Segment-prefetch requests are always treated as RSC requests regardless
// of whether the rewritten pathname ends with .rsc, because the original
// URL had a .segment.rsc suffix.
const isRscRequest = pathname.endsWith(".rsc") || segmentPrefetchPath !== null;
const cleanPathname = stripRscSuffix(pathname);

// Step 8: Sanitize X-Vinext-Interception-Context.
// Step 9: Sanitize X-Vinext-Interception-Context.
// Null bytes in header values can be used for injection in some HTTP stacks.
const interceptionContextHeader =
request.headers.get(VINEXT_INTERCEPTION_CONTEXT_HEADER)?.replaceAll("\0", "") || null;

// Step 9: Normalize mounted-slots header for canonical cache keying.
// Step 10: Normalize mounted-slots header for canonical cache keying.
const mountedSlotsHeader = normalizeMountedSlotsHeader(
request.headers.get(VINEXT_MOUNTED_SLOTS_HEADER),
);

// Step 11: Read semantic render mode for refresh/action payload rendering.
const renderMode = isRscRequest
? parseAppRscRenderMode(request.headers.get(VINEXT_RSC_RENDER_MODE_HEADER))
: APP_RSC_RENDER_MODE_NAVIGATION;
Expand All @@ -136,5 +163,6 @@ export function normalizeRscRequest(
interceptionContextHeader,
mountedSlotsHeader,
renderMode,
segmentPrefetchPath,
};
}
57 changes: 57 additions & 0 deletions packages/vinext/src/server/app-segment-prefetch-normalizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Segment-prefetch RSC URL normalizer.
*
* Matches and extracts segment-prefetch URLs following the Next.js convention:
* /page-path.segments/_tree.segment.rsc
* /page-path.segments/_index.segment.rsc
* /page-path.segments/dashboard/__PAGE__.segment.rsc
*
* The normalizer extracts the original page pathname and the segment path,
* allowing the request to be routed through the normal RSC pipeline with
* the segment metadata set as request markers.
*
* Constants match Next.js: RSC_SEGMENTS_DIR_SUFFIX = '.segments',
* RSC_SEGMENT_SUFFIX = '.segment.rsc'
*/

const RSC_SEGMENTS_DIR_SUFFIX = ".segments";
const RSC_SEGMENT_SUFFIX = ".segment.rsc";

const SEGMENT_PREFETCH_PATTERN = new RegExp(
`^(/.*)${escapeRegex(RSC_SEGMENTS_DIR_SUFFIX)}(/.*)${escapeRegex(RSC_SEGMENT_SUFFIX)}$`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first capture group (/.*) is greedy, which means for a path like /foo.segments/bar.segments/_tree.segment.rsc, the first group would capture /foo.segments/bar (consuming up to the last .segments). This is probably the correct behavior (the last .segments is the real protocol delimiter), but it's worth a brief comment explaining this is intentional, and ideally a test case for a path containing a literal .segments in the page portion.

Also — minor style nit: escapeRegex is called at module scope before its definition (line 24). This works because of hoisting, but reads oddly. Consider moving the helper above its first use.

);

function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

type SegmentPrefetchResult = {
/** The original page pathname without segment-prefetch suffixes (e.g. "/dashboard"). */
originalPathname: string;
/** The segment path (e.g. "/_tree", "/_index", "/dashboard/__PAGE__"). */
segmentPath: string;
};

/**
* Check if a pathname matches the segment-prefetch URL pattern and extract the
* original page pathname and segment path.
*
* Returns null when the pathname does not match the expected pattern.
* This is the single entry point — prefer it over a separate match + extract
* to avoid running the regex twice on the hot path.
*/
export function extractSegmentPrefetchRsc(pathname: string): SegmentPrefetchResult | null {
const match = pathname.match(SEGMENT_PREFETCH_PATTERN);
if (!match) return null;
return { originalPathname: match[1], segmentPath: match[2] };
}

/**
* Check if a pathname matches the segment-prefetch URL pattern.
*
* Prefer extractSegmentPrefetchRsc() when you need the extracted path info,
* since it runs the regex once and returns null for non-matches.
*/
export function matchSegmentPrefetchRsc(pathname: string): boolean {
return SEGMENT_PREFETCH_PATTERN.test(pathname);
}
94 changes: 94 additions & 0 deletions tests/app-rsc-request-normalization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,3 +463,97 @@ describe("normalizeMountedSlotsHeader", () => {
expect(normalizeMountedSlotsHeader("modal")).toBe("modal");
});
});

// ── Segment-prefetch URL normalization ──────────────────────────────────────

describe("normalizeRscRequest — segment-prefetch URLs", () => {
it("detects a route tree prefetch and rewrites to the original page path", () => {
const result = normalized(
normalizeRscRequest(req("/dashboard.segments/_tree.segment.rsc"), ""),
);
expect(result.pathname).toBe("/dashboard");
expect(result.cleanPathname).toBe("/dashboard");
expect(result.isRscRequest).toBe(true);
expect(result.segmentPrefetchPath).toBe("/_tree");
});

it("detects a page segment prefetch and rewrites to the original page path", () => {
const result = normalized(
normalizeRscRequest(req("/dashboard.segments/__PAGE__.segment.rsc"), ""),
);
expect(result.pathname).toBe("/dashboard");
expect(result.cleanPathname).toBe("/dashboard");
expect(result.isRscRequest).toBe(true);
expect(result.segmentPrefetchPath).toBe("/__PAGE__");
});

it("handles root page segment-prefetch URL", () => {
const result = normalized(normalizeRscRequest(req("/.segments/_tree.segment.rsc"), ""));
expect(result.pathname).toBe("/");
expect(result.cleanPathname).toBe("/");
expect(result.isRscRequest).toBe(true);
expect(result.segmentPrefetchPath).toBe("/_tree");
});

it("handles a deeply nested segment path", () => {
const result = normalized(
normalizeRscRequest(req("/dashboard/settings.segments/tab/profile/__PAGE__.segment.rsc"), ""),
);
expect(result.pathname).toBe("/dashboard/settings");
expect(result.cleanPathname).toBe("/dashboard/settings");
expect(result.isRscRequest).toBe(true);
expect(result.segmentPrefetchPath).toBe("/tab/profile/__PAGE__");
});

it("preserves isRscRequest=false for non-segment-prefetch URLs", () => {
const result = normalized(normalizeRscRequest(req("/dashboard"), ""));
expect(result.isRscRequest).toBe(false);
expect(result.segmentPrefetchPath).toBeNull();
});

it("preserves isRscRequest=true for regular .rsc URLs", () => {
const result = normalized(normalizeRscRequest(req("/dashboard.rsc"), ""));
expect(result.isRscRequest).toBe(true);
expect(result.segmentPrefetchPath).toBeNull();
});

it("works with basePath", () => {
const result = normalized(
normalizeRscRequest(req("/app/dashboard.segments/_tree.segment.rsc"), "/app"),
);
expect(result.pathname).toBe("/dashboard");
expect(result.cleanPathname).toBe("/dashboard");
expect(result.isRscRequest).toBe(true);
expect(result.segmentPrefetchPath).toBe("/_tree");
});
});

describe("normalizeRscRequest — percent-encoded segment-prefetch URLs", () => {
it("detects %2Esegments as .segments after percent-decode step", () => {
// %2E is '.' — after step 3 (percent-decode), the path contains .segments
// which the match in step 6 should detect.
const result = normalized(
normalizeRscRequest(req("/dashboard%2Esegments/_tree%2Esegment%2Ersc"), ""),
);
// normalizePathnameForRouteMatchStrict decodes %2E to '.' (it preserves
// only %2F for path separator boundaries), so the regex matches.
expect(result.pathname).toBe("/dashboard");
expect(result.cleanPathname).toBe("/dashboard");
expect(result.isRscRequest).toBe(true);
expect(result.segmentPrefetchPath).toBe("/_tree");
});

it("does not match double-encoded %252Esegments", () => {
// The path /dashboard%252Esegments/_tree.segment.rsc:
// URL parser preserves %25, so pathname starts as /dashboard%252Esegments/...
// normalizePathnameForRouteMatchStrict decodes %25 -> %, leaving
// /dashboard%2Esegments/_tree.segment.rsc. Since %2E is not '.' at this
// point, the segment-prefetch regex does not match.
// isRscRequest is true because the path still ends with .rsc (.segment.rsc).
const result = normalized(
normalizeRscRequest(req("/dashboard%252Esegments/_tree.segment.rsc"), ""),
);
expect(result.segmentPrefetchPath).toBeNull();
expect(result.isRscRequest).toBe(true);
});
});
Loading
Loading