From 450042e45031235c71050a12435afd696b8738d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 11:30:03 +0000 Subject: [PATCH] refactor: clean up fs routing internals - nextAdapter: extract isRouteGroup/parseDynamicSegment helpers to replace four copies of segment-parsing regexes, and pull validateFilePath, pagePositionKey, and pageNode out of buildRoutes/emit - runtime: lift buildRouteDefinitions and FsRoutesApp out of the createFsRoutesEntriesWithHost closure; the slot component now flows through an explicit PageDefinitionContext instead of the host closure - tree: drop segmentsToUrl's dead normalization (inputs are already validated segments) and document the invariant instead - types: reuse MaybePromise from entryDefinition instead of redefining it - rsc/entry: deduplicate identical Response construction in renderEntryToResponse and the thrice-repeated RSC payload response No behavior change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UbzsQZeY8hbezpvMEpusbY --- packages/static/src/fs-routes/nextAdapter.ts | 136 ++++++++------ packages/static/src/fs-routes/runtime.tsx | 180 ++++++++++--------- packages/static/src/fs-routes/tree.ts | 12 +- packages/static/src/fs-routes/types.ts | 3 +- packages/static/src/rsc/entry.tsx | 66 +++---- 5 files changed, 213 insertions(+), 184 deletions(-) diff --git a/packages/static/src/fs-routes/nextAdapter.ts b/packages/static/src/fs-routes/nextAdapter.ts index 4f57590..d4c432d 100644 --- a/packages/static/src/fs-routes/nextAdapter.ts +++ b/packages/static/src/fs-routes/nextAdapter.ts @@ -71,6 +71,26 @@ const VALID_PARAM_NAME = /^[A-Za-z0-9_$]+$/; */ const URL_PATTERN_SPECIAL_CHARS = /[:*?+(){}\\]/; +/** + * Whether a segment is a route group (`(group)`), which does not reach the + * URL. + */ +function isRouteGroup(segment: string): boolean { + return segment.startsWith("(") && segment.endsWith(")"); +} + +/** + * Parses a dynamic segment (`[param]` or catch-all `[...param]`) into its + * param name, or returns `null` for any other segment. + */ +function parseDynamicSegment( + segment: string, +): { name: string; catchAll: boolean } | null { + const match = /^\[(\.\.\.)?(.+)\]$/.exec(segment); + if (!match) return null; + return { name: match[2]!, catchAll: match[1] !== undefined }; +} + /** * Rejects directory segments using Next.js syntaxes that this adapter does not * support, and segments FUNSTACK Router's URL patterns cannot express, so they @@ -94,14 +114,14 @@ function validateSegment(segment: string, filePath: string): void { ); } // Route groups do not reach the URL, so their names are unconstrained. - if (segment.startsWith("(") && segment.endsWith(")")) { + if (isRouteGroup(segment)) { return; } - const dynamic = /^\[(?:\.\.\.)?(.+)\]$/.exec(segment); + const dynamic = parseDynamicSegment(segment); if (dynamic) { - if (!VALID_PARAM_NAME.test(dynamic[1]!)) { + if (!VALID_PARAM_NAME.test(dynamic.name)) { throw new Error( - `Invalid param name "${dynamic[1]}" ("${segment}" in "${filePath}"). ` + + `Invalid param name "${dynamic.name}" ("${segment}" in "${filePath}"). ` + `Param names may only contain letters, digits, "_", and "$".`, ); } @@ -129,11 +149,9 @@ function validateSegment(segment: string, filePath: string): void { */ function urlSegment(segment: string): string | null { if (segment === "") return null; - if (segment.startsWith("(") && segment.endsWith(")")) return null; - const catchAll = /^\[\.\.\.(.+)\]$/.exec(segment); - if (catchAll) return `:${catchAll[1]}*`; - const dynamic = /^\[(.+)\]$/.exec(segment); - if (dynamic) return `:${dynamic[1]}`; + if (isRouteGroup(segment)) return null; + const dynamic = parseDynamicSegment(segment); + if (dynamic) return `:${dynamic.name}${dynamic.catchAll ? "*" : ""}`; return segment; } @@ -183,6 +201,46 @@ function compareNodes(a: FsRouteTreeNode, b: FsRouteTreeNode): number { return rankA.length - rankB.length; } +/** + * Validates every directory segment of a route file's path, including that no + * param name is used twice: a param name repeated on one path either fails + * URLPattern construction (within one route) or shadows the outer value + * (across a layout boundary), so it is rejected up front. + */ +function validateFilePath(dirs: string[], filePath: string): void { + const seenParamNames = new Set(); + for (const segment of dirs) { + validateSegment(segment, filePath); + const dynamic = parseDynamicSegment(segment); + if (dynamic) { + if (seenParamNames.has(dynamic.name)) { + throw new Error( + `Duplicate param name "${dynamic.name}" in "${filePath}": ` + + `a route may use each param name only once.`, + ); + } + seenParamNames.add(dynamic.name); + } + } +} + +/** + * Normalized route position of a page's directory path: route groups are + * dropped and dynamic segments are reduced to their kind, so that two pages + * get the same key exactly when they resolve to the same route (e.g. `[a]` + * and `[b]` at the same position, or the same path through different route + * groups). + */ +function pagePositionKey(dirs: string[]): string { + const parts: string[] = []; + for (const segment of dirs) { + if (isRouteGroup(segment)) continue; + const dynamic = parseDynamicSegment(segment); + parts.push(dynamic ? (dynamic.catchAll ? "[...]" : "[]") : segment); + } + return parts.join("/"); +} + function ensureDir(root: TrieNode, dirs: string[]): TrieNode { let current = root; for (const segment of dirs) { @@ -196,6 +254,13 @@ function ensureDir(root: TrieNode, dirs: string[]): TrieNode { return current; } +/** + * Builds the route tree node for a trie node's page, at the given path. + */ +function pageNode(node: TrieNode, path: string): FsRouteTreeNode { + return { path, module: node.page!, filePath: node.pageFile, page: true }; +} + /** * Converts a trie node into route tree nodes. * @@ -217,12 +282,7 @@ function emit(node: TrieNode, prefix: string[]): FsRouteTreeNode[] { if (node.layout) { const children: FsRouteTreeNode[] = []; if (node.page) { - children.push({ - path: "/", - module: node.page, - filePath: node.pageFile, - page: true, - }); + children.push(pageNode(node, "/")); } for (const child of childNodes) { children.push(...emit(child, [])); @@ -242,13 +302,7 @@ function emit(node: TrieNode, prefix: string[]): FsRouteTreeNode[] { const result: FsRouteTreeNode[] = []; if (node.page) { - const path = here.length === 0 ? "/" : `/${here.join("/")}`; - result.push({ - path, - module: node.page, - filePath: node.pageFile, - page: true, - }); + result.push(pageNode(node, here.length === 0 ? "/" : `/${here.join("/")}`)); } for (const child of childNodes) { result.push(...emit(child, here)); @@ -294,32 +348,16 @@ export function nextRoutes(options: NextRoutesOptions = {}): FsRoutesAdapter { // Exact directory each page/layout file lives in, to detect duplicate // files for the same node (e.g. `page.tsx` next to `page.jsx`). const filesByDir = new Map(); - // Route position of each page, with dynamic segments normalized so that - // e.g. `[a]` and `[b]` pages at the same position conflict. Layouts are - // exempt: multiple layouts at one position via route groups are valid - // (e.g. `(marketing)/layout.tsx` and `(shop)/layout.tsx`). + // Each page's normalized position (see pagePositionKey), to detect + // pages resolving to the same route. Layouts are exempt: multiple + // layouts at one position via route groups are valid (e.g. + // `(marketing)/layout.tsx` and `(shop)/layout.tsx`). const pagePositions = new Map(); for (const file of files) { const { dirs, base } = splitFilePath(file.filePath); const kind = classify(base, pageFileName, layoutFileName); if (!kind) continue; - const seenParamNames = new Set(); - for (const segment of dirs) { - validateSegment(segment, file.filePath); - const dynamic = /^\[(?:\.\.\.)?(.+)\]$/.exec(segment); - if (dynamic) { - // A param name used twice on one path either fails URLPattern - // construction (within one route) or shadows the outer value - // (across a layout boundary), so reject it up front. - if (seenParamNames.has(dynamic[1]!)) { - throw new Error( - `Duplicate param name "${dynamic[1]}" in "${file.filePath}": ` + - `a route may use each param name only once.`, - ); - } - seenParamNames.add(dynamic[1]!); - } - } + validateFilePath(dirs, file.filePath); const dirKey = `${kind} ${dirs.join("/")}`; const sameDir = filesByDir.get(dirKey); if (sameDir !== undefined) { @@ -330,17 +368,7 @@ export function nextRoutes(options: NextRoutesOptions = {}): FsRoutesAdapter { } filesByDir.set(dirKey, file.filePath); if (kind === "page") { - const position = dirs - .map(urlSegment) - .filter((segment) => segment !== null) - .map((segment) => - segment.startsWith(":") - ? segment.endsWith("*") - ? "[...]" - : "[]" - : segment, - ) - .join("/"); + const position = pagePositionKey(dirs); const conflicting = pagePositions.get(position); if (conflicting !== undefined) { throw new Error( diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index 8c2f63b..ba641a5 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -240,6 +240,97 @@ interface EnumeratedRoutes { chunks: Map; } +/** + * Per-page inputs for building the router route definitions: the per-node + * metadata, the nodes the page renders through (whose Server Component + * output is inlined), the page's params, and the client slot component + * standing in for Server Component nodes. + */ +interface PageDefinitionContext { + metas: Map; + pageChain: Set; + pageParams: Record; + RouteSlot: ComponentType; +} + +function buildRouteDefinitions( + nodes: FsRouteTreeNode[], + context: PageDefinitionContext, +): RouteDefinition[] { + const { metas, pageChain, pageParams, RouteSlot } = context; + return nodes.map((node): RouteDefinition => { + const meta = metas.get(node)!; + const Component = node.module.default; + const definition: { + id: string; + path?: string; + component?: ComponentType | ReactNode; + children?: RouteDefinition[]; + } = { id: meta.id }; + if (node.path !== undefined) { + definition.path = node.path; + } + if (Component) { + if (isClientReference(Component)) { + // A Client Component crosses the RSC boundary as a reference, so + // the router can render it in the browser. Pass the component + // itself so it receives the params of the current match, keeping + // them live across soft client-side navigation. + definition.component = Component as ComponentType; + } else { + // A Server Component crosses the RSC boundary only as its rendered + // output, so a client slot stands in for it: it renders the + // build-time output while the current params match this page's, + // and fetches the destination's pre-rendered chunk after a soft + // client-side navigation. Output is inlined only for nodes this + // page renders through; other nodes always resolve via chunks. + const slotProps: FsRouteSlotProps = { + route: meta.route, + paramNames: meta.paramNames, + chunks: meta.chunks, + }; + if (pageChain.has(node)) { + const params = pickParams(pageParams, meta.paramNames); + slotProps.initialKey = paramsKey(meta.paramNames, pageParams); + slotProps.initial = createElement(Component, { + params, + route: meta.route, + }); + } + definition.component = createElement(RouteSlot, slotProps); + } + } + if (node.children) { + definition.children = buildRouteDefinitions(node.children, context); + } + return definition; + }); +} + +function FsRoutesApp({ + tree, + metas, + page, + RouteSlot, +}: { + tree: FsRouteTreeNode[]; + metas: Map; + page: StaticPage; + RouteSlot: ComponentType; +}): ReactNode { + const routes = buildRouteDefinitions(tree, { + metas, + pageChain: new Set(page.chain), + pageParams: page.params, + RouteSlot, + }); + return createElement(Router, { + routes, + fallback: "static", + ssr: { path: page.urlPath }, + }); +} + /** * Builds FUNSTACK Router state for file-system routing and returns a * `getEntries` function (the default export expected by the `entries` plugin @@ -264,88 +355,6 @@ export function createFsRoutesEntriesWithHost( ): () => GetEntriesResult { const { modules, base, root: Root, adapter = nextRoutes() } = options; - function buildRouteDefinitions( - nodes: FsRouteTreeNode[], - metas: Map, - pageChain: Set, - pageParams: Record, - ): RouteDefinition[] { - return nodes.map((node): RouteDefinition => { - const meta = metas.get(node)!; - const Component = node.module.default; - const definition: { - id: string; - path?: string; - component?: ComponentType | ReactNode; - children?: RouteDefinition[]; - } = { id: meta.id }; - if (node.path !== undefined) { - definition.path = node.path; - } - if (Component) { - if (isClientReference(Component)) { - // A Client Component crosses the RSC boundary as a reference, so - // the router can render it in the browser. Pass the component - // itself so it receives the params of the current match, keeping - // them live across soft client-side navigation. - definition.component = Component as ComponentType; - } else { - // A Server Component crosses the RSC boundary only as its rendered - // output, so a client slot stands in for it: it renders the - // build-time output while the current params match this page's, - // and fetches the destination's pre-rendered chunk after a soft - // client-side navigation. Output is inlined only for nodes this - // page renders through; other nodes always resolve via chunks. - const slotProps: FsRouteSlotProps = { - route: meta.route, - paramNames: meta.paramNames, - chunks: meta.chunks, - }; - if (pageChain.has(node)) { - const params = pickParams(pageParams, meta.paramNames); - slotProps.initialKey = paramsKey(meta.paramNames, pageParams); - slotProps.initial = createElement(Component, { - params, - route: meta.route, - }); - } - definition.component = createElement(host.RouteSlot, slotProps); - } - } - if (node.children) { - definition.children = buildRouteDefinitions( - node.children, - metas, - pageChain, - pageParams, - ); - } - return definition; - }); - } - - function FsRoutesApp({ - tree, - metas, - page, - }: { - tree: FsRouteTreeNode[]; - metas: Map; - page: StaticPage; - }): ReactNode { - const routes = buildRouteDefinitions( - tree, - metas, - new Set(page.chain), - page.params, - ); - return createElement(Router, { - routes, - fallback: "static", - ssr: { path: page.urlPath }, - }); - } - async function enumerateRoutes(): Promise { const warn = (message: string) => { console.warn(`[funstack] ${message}`); @@ -397,7 +406,12 @@ export function createFsRoutesEntriesWithHost( yield { path: urlPathToFilePath(page.urlPath), root: { default: Root }, - app: createElement(FsRoutesApp, { tree, metas, page }), + app: createElement(FsRoutesApp, { + tree, + metas, + page, + RouteSlot: host.RouteSlot, + }), }; } }; diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts index b64dd89..e807c4b 100644 --- a/packages/static/src/fs-routes/tree.ts +++ b/packages/static/src/fs-routes/tree.ts @@ -75,15 +75,13 @@ export function splitRoutePath(path: string): string[] { } /** - * Joins URL segments into a normalized absolute URL path. + * Joins URL segments into an absolute URL path. Segments are non-empty and + * carry no leading or trailing slashes ({@link splitRoutePath} filters + * empties, and substituted param values are validated), so joining yields a + * normalized path; only catch-all values contribute interior slashes. */ function segmentsToUrl(segments: string[]): string { - const joined = segments - .join("/") - .replace(/\/+/g, "/") - .replace(/^\//, "") - .replace(/\/$/, ""); - return joined === "" ? "/" : `/${joined}`; + return segments.length === 0 ? "/" : `/${segments.join("/")}`; } /** diff --git a/packages/static/src/fs-routes/types.ts b/packages/static/src/fs-routes/types.ts index 5edac71..0ae5c1e 100644 --- a/packages/static/src/fs-routes/types.ts +++ b/packages/static/src/fs-routes/types.ts @@ -1,7 +1,8 @@ import type { RouteHandle } from "@funstack/router"; import type { ComponentType, ReactNode } from "react"; +import type { MaybePromise } from "../entryDefinition"; -export type MaybePromise = T | Promise; +export type { MaybePromise }; /** * Opaque route object identifying the route of a page or layout, received as diff --git a/packages/static/src/rsc/entry.tsx b/packages/static/src/rsc/entry.tsx index 7248367..85d08de 100644 --- a/packages/static/src/rsc/entry.tsx +++ b/packages/static/src/rsc/entry.tsx @@ -83,6 +83,7 @@ async function renderEntryToResponse( >("ssr"); timings.push(`ssr-module;dur=${performance.now() - ssrModuleStart}`); + let ssrResult: Awaited>; if (ssrEnabled) { // SSR on: single RSC stream with full tree const rscStart = performance.now(); @@ -92,21 +93,13 @@ async function renderEntryToResponse( timings.push(`rsc;dur=${performance.now() - rscStart}`); const ssrStart = performance.now(); - const ssrResult = await ssrEntryModule.renderHTML(rootRscStream, { + ssrResult = await ssrEntryModule.renderHTML(rootRscStream, { appEntryMarker: marker, build: false, ssr: true, deferRegistry, }); timings.push(`ssr;dur=${performance.now() - ssrStart}`); - - return new Response(ssrResult.stream, { - status: ssrResult.status, - headers: { - "Content-type": "text/html", - "Server-Timing": timings.join(", "), - }, - }); } else { // SSR off: shell RSC for SSR, full RSC for client const rscStart = performance.now(); @@ -123,22 +116,22 @@ async function renderEntryToResponse( timings.push(`rsc;dur=${performance.now() - rscStart}`); const ssrStart = performance.now(); - const ssrResult = await ssrEntryModule.renderHTML(shellRscStream, { + ssrResult = await ssrEntryModule.renderHTML(shellRscStream, { appEntryMarker: marker, build: false, ssr: false, clientRscStream, }); timings.push(`ssr;dur=${performance.now() - ssrStart}`); - - return new Response(ssrResult.stream, { - status: ssrResult.status, - headers: { - "Content-type": "text/html", - "Server-Timing": timings.join(", "), - }, - }); } + + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + "Content-type": "text/html", + "Server-Timing": timings.join(", "), + }, + }); } /** @@ -183,6 +176,19 @@ export function isServeRSCError(error: unknown): error is ServeRSCError { return error instanceof Error && error.name === "ServeRSCError"; } +/** + * Builds an RSC payload response with Server-Timing headers. + */ +function rscResponse(body: BodyInit, timings: string[]): Response { + return new Response(body, { + status: 200, + headers: { + "content-type": "text/x-component;charset=utf-8", + "Server-Timing": timings.join(", "), + }, + }); +} + /** * Serves an RSC stream response */ @@ -221,13 +227,7 @@ export async function serveRSC(request: Request): Promise { }); timings.push(`rsc;dur=${performance.now() - rscStart}`); - return new Response(rootRscStream, { - status: 200, - headers: { - "content-type": "text/x-component;charset=utf-8", - "Server-Timing": timings.join(", "), - }, - }); + return rscResponse(rootRscStream, timings); } const moduleId = extractIDFromModulePath(pathname); @@ -255,22 +255,10 @@ export async function serveRSC(request: Request): Promise { const { state } = entry; switch (state.state) { case "streaming": { - return new Response(state.stream, { - status: 200, - headers: { - "content-type": "text/x-component;charset=utf-8", - "Server-Timing": timings.join(", "), - }, - }); + return rscResponse(state.stream, timings); } case "ready": { - return new Response(await entry.drainPromise, { - status: 200, - headers: { - "content-type": "text/x-component;charset=utf-8", - "Server-Timing": timings.join(", "), - }, - }); + return rscResponse(await entry.drainPromise, timings); } case "error": { throw new ServeRSCError(