diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index c7d7aea9d..222369e6a 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -6,6 +6,7 @@ */ import path from "node:path"; import fs from "node:fs"; +import { createHash } from "node:crypto"; import { compareRoutes, decodeRouteSegment } from "./utils.js"; import { scanWithExtensions, type ValidFileMatcher } from "./file-matcher.js"; import { validateRoutePatterns } from "./route-validation.js"; @@ -151,6 +152,7 @@ export type AppRouteSemanticIds = { route: string; page: string | null; routeHandler: string | null; + rootBoundary: RootBoundaryId | null; layouts: readonly string[]; templates: readonly string[]; /** @@ -164,9 +166,81 @@ export type AppRouteGraphParallelSlot = ParallelSlot & { id: string; }; -export type AppRouteGraphRoute = Omit & { +export type AppRouteGraphRoute = Omit & { ids: AppRouteSemanticIds; parallelSlots: AppRouteGraphParallelSlot[]; + rootParamNames: string[]; +}; + +type Flavor = T & { readonly __flavor?: Brand }; + +export type GraphVersion = Flavor; +export type RootBoundaryId = Flavor; + +export type RouteManifestRoute = { + id: string; + pattern: string; + patternParts: readonly string[]; + isDynamic: boolean; + paramNames: readonly string[]; + rootParamNames: readonly string[]; + rootBoundaryId: RootBoundaryId | null; + pageId: string | null; + routeHandlerId: string | null; + layoutIds: readonly string[]; + templateIds: readonly string[]; + slotIds: readonly string[]; +}; + +export type RouteManifestPage = { + id: string; + routeId: string; + pattern: string; +}; + +export type RouteManifestRouteHandler = { + id: string; + routeId: string; + pattern: string; +}; + +export type RouteManifestLayout = { + id: string; + treePath: string; + rootBoundaryId: RootBoundaryId | null; +}; + +export type RouteManifestTemplate = { + id: string; + treePath: string; + rootBoundaryId: RootBoundaryId | null; +}; + +export type RouteManifestSlot = { + id: string; + key: string; + name: string; +}; + +export type RouteManifestRootBoundary = { + id: RootBoundaryId; + layoutId: string; + treePath: string; +}; + +export type StaticSegmentGraph = { + routes: ReadonlyMap; + pages: ReadonlyMap; + routeHandlers: ReadonlyMap; + layouts: ReadonlyMap; + templates: ReadonlyMap; + slots: ReadonlyMap; + rootBoundaries: ReadonlyMap; +}; + +export type RouteManifest = { + graphVersion: GraphVersion; + segmentGraph: StaticSegmentGraph; }; function createAppRouteGraphRouteId(pattern: string): string { @@ -193,10 +267,184 @@ function createAppRouteGraphSlotId(slotName: string, ownerTreePath: string): str return `slot:${slotName}:${ownerTreePath}`; } +function createAppRouteGraphRootBoundaryId(treePath: string): RootBoundaryId { + return `root-boundary:${treePath}`; +} + +function compareStableStrings(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function sortedMapValues(map: ReadonlyMap): T[] { + return Array.from(map.entries()) + .sort(([left], [right]) => compareStableStrings(left, right)) + .map(([, value]) => value); +} + +function createRouteManifest(routes: readonly AppRouteGraphRoute[]): RouteManifest { + const segmentGraph = createStaticSegmentGraph(routes); + + return { + graphVersion: createRouteManifestGraphVersion(segmentGraph), + segmentGraph, + }; +} + +function createStaticSegmentGraph(routes: readonly AppRouteGraphRoute[]): StaticSegmentGraph { + const routeEntries = new Map(); + const pages = new Map(); + const routeHandlers = new Map(); + const layouts = new Map(); + const templates = new Map(); + const slots = new Map(); + const rootBoundaries = new Map(); + + for (const route of routes) { + routeEntries.set(route.ids.route, { + id: route.ids.route, + pattern: route.pattern, + patternParts: [...route.patternParts], + isDynamic: route.isDynamic, + paramNames: [...route.params], + rootParamNames: [...route.rootParamNames], + rootBoundaryId: route.ids.rootBoundary, + pageId: route.ids.page, + routeHandlerId: route.ids.routeHandler, + layoutIds: [...route.ids.layouts], + templateIds: [...route.ids.templates], + slotIds: route.parallelSlots.map((slot) => slot.id).sort(compareStableStrings), + }); + + if (route.ids.page) { + pages.set(route.ids.page, { + id: route.ids.page, + routeId: route.ids.route, + pattern: route.pattern, + }); + } + + if (route.ids.routeHandler) { + routeHandlers.set(route.ids.routeHandler, { + id: route.ids.routeHandler, + routeId: route.ids.route, + pattern: route.pattern, + }); + } + + for (const [index, layoutId] of route.ids.layouts.entries()) { + const treePosition = route.layoutTreePositions[index]; + assertRouteManifestTreePosition("layout", route, layoutId, treePosition); + + const treePath = createAppRouteGraphTreePath(route.routeSegments, treePosition); + const existingLayout = layouts.get(layoutId); + if (existingLayout) { + assertRouteManifestRootBoundary("layout", route, layoutId, existingLayout.rootBoundaryId); + } + layouts.set(layoutId, { + id: layoutId, + treePath, + rootBoundaryId: route.ids.rootBoundary, + }); + + if (index === 0 && route.ids.rootBoundary) { + rootBoundaries.set(route.ids.rootBoundary, { + id: route.ids.rootBoundary, + layoutId, + treePath, + }); + } + } + + for (const [index, templateId] of route.ids.templates.entries()) { + const treePosition = route.templateTreePositions?.[index]; + assertRouteManifestTreePosition("template", route, templateId, treePosition); + + const existingTemplate = templates.get(templateId); + if (existingTemplate) { + assertRouteManifestRootBoundary( + "template", + route, + templateId, + existingTemplate.rootBoundaryId, + ); + } + templates.set(templateId, { + id: templateId, + treePath: createAppRouteGraphTreePath(route.routeSegments, treePosition), + rootBoundaryId: route.ids.rootBoundary, + }); + } + + // Slots are boundary-agnostic in this minimal read model; unlike layouts + // and templates, they do not carry rootBoundaryId facts to guard. + for (const slot of route.parallelSlots) { + slots.set(slot.id, { + id: slot.id, + key: slot.key, + name: slot.name, + }); + } + } + + return { + routes: routeEntries, + pages, + routeHandlers, + layouts, + templates, + slots, + rootBoundaries, + }; +} + +function assertRouteManifestTreePosition( + kind: "layout" | "template", + route: AppRouteGraphRoute, + id: string, + treePosition: number | undefined, +): asserts treePosition is number { + if (treePosition !== undefined) return; + + throw new Error( + `[vinext] App route graph invariant violated: missing ${kind} tree position for ${id} on ${route.pattern}`, + ); +} + +function assertRouteManifestRootBoundary( + kind: "layout" | "template", + route: AppRouteGraphRoute, + id: string, + existingRootBoundaryId: RootBoundaryId | null, +): void { + if (existingRootBoundaryId === route.ids.rootBoundary) return; + + throw new Error( + `[vinext] App route graph invariant violated: ${kind} ${id} is shared across root boundaries (${existingRootBoundaryId ?? "none"} and ${route.ids.rootBoundary ?? "none"}) on ${route.pattern}`, + ); +} + +function createRouteManifestGraphVersion(segmentGraph: StaticSegmentGraph): GraphVersion { + // The manifest hash is canonical only if top-level map keys are sorted and + // inner route arrays keep their own semantic order: layoutIds/templateIds in + // tree-position order, and slotIds in compareStableStrings order. + const stableShape = { + routes: sortedMapValues(segmentGraph.routes), + pages: sortedMapValues(segmentGraph.pages), + routeHandlers: sortedMapValues(segmentGraph.routeHandlers), + layouts: sortedMapValues(segmentGraph.layouts), + templates: sortedMapValues(segmentGraph.templates), + slots: sortedMapValues(segmentGraph.slots), + rootBoundaries: sortedMapValues(segmentGraph.rootBoundaries), + }; + return `graph:${createHash("sha256").update(JSON.stringify(stableShape)).digest("hex")}`; +} + export async function buildAppRouteGraph( appDir: string, matcher: ValidFileMatcher, -): Promise<{ routes: AppRouteGraphRoute[] }> { +): Promise<{ routes: AppRouteGraphRoute[]; routeManifest: RouteManifest }> { // Find all page.tsx and route.ts files, excluding @slot directories // (slot pages are not standalone routes — they're rendered as props of their parent layout) // and _private folders (Next.js convention for colocated non-route files). @@ -262,7 +510,7 @@ export async function buildAppRouteGraph( // Sort: static routes first, then dynamic, then catch-all routes.sort(compareRoutes); - return { routes }; + return { routes, routeManifest: createRouteManifest(routes) }; } function hasParallelSlotDirectory(dir: string): boolean { @@ -705,6 +953,20 @@ export function computeRootParamNames( return names; } +function resolveRootBoundaryId( + routeSegments: readonly string[], + layoutTreePositions: readonly number[], +): RootBoundaryId | null { + const rootLayoutPosition = layoutTreePositions[0]; + if (rootLayoutPosition === undefined) return null; + + // Position 0 is the app root layout and still owns a real root boundary. + // Only a missing layout position means the route is layoutless. + return createAppRouteGraphRootBoundaryId( + createAppRouteGraphTreePath(routeSegments, rootLayoutPosition), + ); +} + function createAppRouteSemanticIds(input: { pattern: string; pagePath: string | null; @@ -723,6 +985,7 @@ function createAppRouteSemanticIds(input: { route: createAppRouteGraphRouteId(input.pattern), page: input.pagePath ? createAppRouteGraphPageId(input.pattern) : null, routeHandler: input.routePath ? createAppRouteGraphRouteHandlerId(input.pattern) : null, + rootBoundary: resolveRootBoundaryId(input.routeSegments, input.layoutTreePositions), layouts: input.layoutTreePositions.map((treePosition) => createAppRouteGraphLayoutId(createAppRouteGraphTreePath(input.routeSegments, treePosition)), ), diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index 0396888ef..09a8da2b7 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -15,39 +15,65 @@ */ import { createValidFileMatcher, type ValidFileMatcher } from "./file-matcher.js"; import { createRouteTrieCache, matchRouteWithTrie } from "./route-matching.js"; -import { buildAppRouteGraph, type AppRoute, type AppRouteGraphRoute } from "./app-route-graph.js"; +import { + buildAppRouteGraph, + type AppRoute, + type AppRouteGraphRoute, + type RouteManifest, +} from "./app-route-graph.js"; export type { AppRoute } from "./app-route-graph.js"; export { computeRootParamNames } from "./app-route-graph.js"; +type AppRouteGraph = { + routes: AppRouteGraphRoute[]; + routeManifest: RouteManifest; +}; + // Cache for app routes -let cachedRoutes: AppRouteGraphRoute[] | null = null; +let cachedGraph: AppRouteGraph | null = null; let cachedAppDir: string | null = null; let cachedPageExtensionsKey: string | null = null; export function invalidateAppRouteCache(): void { - cachedRoutes = null; + cachedGraph = null; cachedAppDir = null; cachedPageExtensionsKey = null; } /** - * Scan the app/ directory and return a list of routes. + * Scan the app/ directory and return the route graph. + * TODO(#726): Layer 4 should consume this read model directly once the + * navigation planner owns route graph facts. + * + * @internal */ -export async function appRouter( +export async function appRouteGraph( appDir: string, pageExtensions?: readonly string[], matcher?: ValidFileMatcher, -): Promise { +): Promise { matcher ??= createValidFileMatcher(pageExtensions); const pageExtensionsKey = JSON.stringify(matcher.extensions); - if (cachedRoutes && cachedAppDir === appDir && cachedPageExtensionsKey === pageExtensionsKey) { - return cachedRoutes; + if (cachedGraph && cachedAppDir === appDir && cachedPageExtensionsKey === pageExtensionsKey) { + return cachedGraph; } const graph = await buildAppRouteGraph(appDir, matcher); - cachedRoutes = graph.routes; + cachedGraph = graph; cachedAppDir = appDir; cachedPageExtensionsKey = pageExtensionsKey; + return graph; +} + +/** + * Scan the app/ directory and return a list of routes. + */ +export async function appRouter( + appDir: string, + pageExtensions?: readonly string[], + matcher?: ValidFileMatcher, +): Promise { + const graph = await appRouteGraph(appDir, pageExtensions, matcher); return graph.routes; } diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index 45008761f..c3f7a1979 100644 --- a/tests/app-route-graph.test.ts +++ b/tests/app-route-graph.test.ts @@ -5,8 +5,8 @@ import path from "node:path"; import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js"; import { buildAppRouteGraph, - type AppRoute, type AppRouteGraphRoute, + type RouteManifest, } from "../packages/vinext/src/routing/app-route-graph.js"; const EMPTY_PAGE = "export default function Page() { return null; }\n"; @@ -31,7 +31,7 @@ async function writeAppFile(appDir: string, relativePath: string, contents: stri await writeFile(filePath, contents); } -function findRoute(routes: AppRoute[], pattern: string): AppRoute { +function findRoute(routes: readonly AppRouteGraphRoute[], pattern: string): AppRouteGraphRoute { const route = routes.find((candidate) => candidate.pattern === pattern); if (!route) { throw new Error(`Expected route ${pattern} to be materialized`); @@ -39,6 +39,43 @@ function findRoute(routes: AppRoute[], pattern: string): AppRoute { return route; } +function snapshotRouteManifest(manifest: RouteManifest) { + return { + graphVersion: manifest.graphVersion, + routes: Array.from(manifest.segmentGraph.routes.entries()), + layouts: Array.from(manifest.segmentGraph.layouts.entries()), + pages: Array.from(manifest.segmentGraph.pages.entries()), + routeHandlers: Array.from(manifest.segmentGraph.routeHandlers.entries()), + templates: Array.from(manifest.segmentGraph.templates.entries()), + slots: Array.from(manifest.segmentGraph.slots.entries()), + rootBoundaries: Array.from(manifest.segmentGraph.rootBoundaries.entries()), + }; +} + +async function withReverseLocaleCompare(run: () => Promise): Promise { + const originalLocaleCompare = Reflect.get(String.prototype, "localeCompare"); + if (typeof originalLocaleCompare !== "function") { + throw new Error("Expected String.prototype.localeCompare to be a function"); + } + // This proves RouteManifest graphVersion canonicalization does not depend on + // locale-sensitive sorting. Keep the patched window scoped to graph building. + Object.defineProperty(String.prototype, "localeCompare", { + configurable: true, + value(this: string, compareString: string) { + return Reflect.apply(originalLocaleCompare, compareString, [this]); + }, + }); + + try { + return await run(); + } finally { + Object.defineProperty(String.prototype, "localeCompare", { + configurable: true, + value: originalLocaleCompare, + }); + } +} + async function createSemanticIdsFixture(appDir: string): Promise { await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); await writeAppFile(appDir, "(marketing)/layout.tsx", EMPTY_LAYOUT); @@ -240,6 +277,7 @@ describe("App Router route graph builder", () => { route: "route:/blog/:slug", page: "page:/blog/:slug", routeHandler: null, + rootBoundary: "root-boundary:/", layouts: ["layout:/", "layout:/(marketing)", "layout:/(marketing)/blog/[slug]"], templates: ["template:/(marketing)/blog/[slug]"], slots: { @@ -253,6 +291,120 @@ describe("App Router route graph builder", () => { }); }); + it("exposes a minimal RouteManifest read model keyed by semantic ids", async () => { + await withTempApp(async (appDir) => { + await createSemanticIdsFixture(appDir); + await writeAppFile(appDir, "(marketing)/api/route.ts", EMPTY_ROUTE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const manifest = graph.routeManifest; + const segmentGraph = manifest.segmentGraph; + + expect(manifest.graphVersion).toMatch(/^graph:[a-f0-9]{64}$/); + expect(segmentGraph.routes.get("route:/blog/:slug")).toEqual({ + id: "route:/blog/:slug", + pattern: "/blog/:slug", + patternParts: ["blog", ":slug"], + isDynamic: true, + paramNames: ["slug"], + rootParamNames: [], + rootBoundaryId: "root-boundary:/", + pageId: "page:/blog/:slug", + routeHandlerId: null, + layoutIds: ["layout:/", "layout:/(marketing)", "layout:/(marketing)/blog/[slug]"], + templateIds: ["template:/(marketing)/blog/[slug]"], + slotIds: ["slot:modal:/(marketing)/blog/[slug]"], + }); + expect(segmentGraph.routes.get("route:/api")).toEqual({ + id: "route:/api", + pattern: "/api", + patternParts: ["api"], + isDynamic: false, + paramNames: [], + rootParamNames: [], + rootBoundaryId: "root-boundary:/", + pageId: null, + routeHandlerId: "route-handler:/api", + layoutIds: ["layout:/", "layout:/(marketing)"], + templateIds: [], + slotIds: [], + }); + expect(segmentGraph.pages.get("page:/blog/:slug")).toEqual({ + id: "page:/blog/:slug", + routeId: "route:/blog/:slug", + pattern: "/blog/:slug", + }); + expect(segmentGraph.routeHandlers.get("route-handler:/api")).toEqual({ + id: "route-handler:/api", + routeId: "route:/api", + pattern: "/api", + }); + expect(segmentGraph.layouts.get("layout:/(marketing)/blog/[slug]")).toEqual({ + id: "layout:/(marketing)/blog/[slug]", + treePath: "/(marketing)/blog/[slug]", + rootBoundaryId: "root-boundary:/", + }); + expect(segmentGraph.templates.get("template:/(marketing)/blog/[slug]")).toEqual({ + id: "template:/(marketing)/blog/[slug]", + treePath: "/(marketing)/blog/[slug]", + rootBoundaryId: "root-boundary:/", + }); + expect(segmentGraph.slots.get("slot:modal:/(marketing)/blog/[slug]")).toEqual({ + id: "slot:modal:/(marketing)/blog/[slug]", + key: "modal@(marketing)/blog/[slug]/@modal", + name: "modal", + }); + expect(segmentGraph.rootBoundaries.get("root-boundary:/")).toEqual({ + id: "root-boundary:/", + layoutId: "layout:/", + treePath: "/", + }); + }); + }); + + it("mints distinct root boundary ids for route-group root layouts", async () => { + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "(marketing)/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "(marketing)/marketing/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "(shop)/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "(shop)/shop/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const rootBoundaryIds = graph.routes + .map((route) => route.ids.rootBoundary) + .sort((left, right) => { + const leftKey = String(left); + const rightKey = String(right); + if (leftKey < rightKey) return -1; + if (leftKey > rightKey) return 1; + return 0; + }); + + expect(rootBoundaryIds).toEqual(["root-boundary:/(marketing)", "root-boundary:/(shop)"]); + expect(Array.from(graph.routeManifest.segmentGraph.rootBoundaries.keys()).sort()).toEqual([ + "root-boundary:/(marketing)", + "root-boundary:/(shop)", + ]); + }); + }); + + it("uses null rootBoundaryId when a route has no layout boundary", async () => { + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layoutless/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const route = findRoute(graph.routes, "/layoutless"); + + expect(route.ids.rootBoundary).toBeNull(); + expect(graph.routeManifest.segmentGraph.routes.get("route:/layoutless")).toMatchObject({ + id: "route:/layoutless", + rootBoundaryId: null, + layoutIds: [], + }); + expect(graph.routeManifest.segmentGraph.rootBoundaries.size).toBe(0); + }); + }); + it("keeps semantic ids stable across different filesystem roots", async () => { const firstIds = await withTempApp(async (appDir) => { await createSemanticIdsFixture(appDir); @@ -270,6 +422,42 @@ describe("App Router route graph builder", () => { expect(secondIds).toEqual(firstIds); }); + it("keeps RouteManifest graph output stable across different filesystem roots", async () => { + const firstManifest = await withTempApp(async (appDir) => { + await createSemanticIdsFixture(appDir); + await writeAppFile(appDir, "(marketing)/api/route.ts", EMPTY_ROUTE); + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + return snapshotRouteManifest(graph.routeManifest); + }); + + const secondManifest = await withTempApp(async (appDir) => { + await createSemanticIdsFixture(appDir); + await writeAppFile(appDir, "(marketing)/api/route.ts", EMPTY_ROUTE); + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + return snapshotRouteManifest(graph.routeManifest); + }); + + expect(secondManifest).toEqual(firstManifest); + }); + + it("does not let locale collation affect RouteManifest graphVersion", async () => { + const graphVersions = await withTempApp(async (appDir) => { + await createSemanticIdsFixture(appDir); + + const normalGraph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const reverseLocaleGraph = await withReverseLocaleCompare(() => + buildAppRouteGraph(appDir, createValidFileMatcher()), + ); + + return [ + normalGraph.routeManifest.graphVersion, + reverseLocaleGraph.routeManifest.graphVersion, + ]; + }); + + expect(graphVersions[1]).toBe(graphVersions[0]); + }); + it("links inherited parallel slot to a mirrored sub-page (literal segments)", async () => { await withTempApp(async (appDir) => { await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); diff --git a/tests/cache-proof.test.ts b/tests/cache-proof.test.ts index f17684861..b6d55ba98 100644 --- a/tests/cache-proof.test.ts +++ b/tests/cache-proof.test.ts @@ -30,6 +30,7 @@ describe("disabled cache proof model", () => { route: "route:/shop/:id", page: "page:/shop/:id", routeHandler: null, + rootBoundary: "root-boundary:/", layouts: ["layout:/", "layout:/shop/[id]"], templates: [], slots: { diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 6dc08eef1..ca1b8c21a 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -145,6 +145,7 @@ describe("App Router generated manifest construction", () => { route: "route:/dashboard/:id", page: "page:/dashboard/:id", routeHandler: "route-handler:/dashboard/:id", + rootBoundary: "root-boundary:/", layouts: ["layout:/", "layout:/dashboard"], templates: ["template:/dashboard"], slots: { diff --git a/tests/routing.test.ts b/tests/routing.test.ts index 28985dd29..bced05f02 100644 --- a/tests/routing.test.ts +++ b/tests/routing.test.ts @@ -10,6 +10,7 @@ import { type Route, } from "../packages/vinext/src/routing/pages-router.js"; import { + appRouteGraph, appRouter, computeRootParamNames, matchAppRoute, @@ -333,6 +334,18 @@ describe("appRouter - route discovery", () => { expect(apiPatterns).toContain("/api/hello"); }); + it("caches the route manifest with the route list", async () => { + invalidateAppRouteCache(); + + const graph = await appRouteGraph(APP_FIXTURE_DIR); + const routes = await appRouter(APP_FIXTURE_DIR); + const cachedGraph = await appRouteGraph(APP_FIXTURE_DIR); + + expect(routes).toBe(graph.routes); + expect(cachedGraph).toBe(graph); + expect(graph.routeManifest.segmentGraph.routes.size).toBeGreaterThan(0); + }); + it("rejects page and route handler files at the same app route", async () => { // Next.js docs forbid page.js and route.js at the same normalized route: // https://github.com/vercel/next.js/blob/ae61573e062e900050b8e6b24626e450accc4570/docs/01-app/01-getting-started/15-route-handlers.mdx#L150-L163