diff --git a/packages/vinext/src/build/nitro-route-rules.ts b/packages/vinext/src/build/nitro-route-rules.ts new file mode 100644 index 00000000..3be106eb --- /dev/null +++ b/packages/vinext/src/build/nitro-route-rules.ts @@ -0,0 +1,121 @@ +import { appRouter, type AppRoute } from "../routing/app-router.js"; +import { apiRouter, pagesRouter, type Route } from "../routing/pages-router.js"; +import { buildReportRows, type RouteRow } from "./report.js"; + +// Mirrors Nitro's NitroRouteConfig — hand-rolled because nitropack is not a direct dependency. +export type NitroRouteRuleConfig = Record & { + swr?: boolean | number; + cache?: unknown; + static?: boolean; + isr?: boolean | number; + prerender?: boolean; +}; + +export type NitroRouteRules = Record; + +/** + * Scans the filesystem for route files and generates Nitro `routeRules` for ISR routes. + * + * Note: this duplicates the filesystem scanning that `printBuildReport` also performs. + * The `nitro.setup` hook runs during Nitro initialization (before the build), while + * `printBuildReport` runs after the build, so sharing results is non-trivial. This is + * a future optimization target. + * + * Unlike `printBuildReport`, this path does not receive `prerenderResult`, so routes + * classified as `unknown` by static analysis (which `printBuildReport` might upgrade + * to `static` via speculative prerender) are skipped here. + */ +export async function collectNitroRouteRules(options: { + appDir?: string | null; + pagesDir?: string | null; + pageExtensions: string[]; +}): Promise { + const { appDir, pageExtensions, pagesDir } = options; + + let appRoutes: AppRoute[] = []; + let pageRoutes: Route[] = []; + let apiRoutes: Route[] = []; + + if (appDir) { + appRoutes = await appRouter(appDir, pageExtensions); + } + + if (pagesDir) { + const [pages, apis] = await Promise.all([ + pagesRouter(pagesDir, pageExtensions), + apiRouter(pagesDir, pageExtensions), + ]); + pageRoutes = pages; + apiRoutes = apis; + } + + return generateNitroRouteRules(buildReportRows({ appRoutes, pageRoutes, apiRoutes })); +} + +export function generateNitroRouteRules(rows: RouteRow[]): NitroRouteRules { + const rules: NitroRouteRules = {}; + + for (const row of rows) { + if ( + row.type === "isr" && + typeof row.revalidate === "number" && + Number.isFinite(row.revalidate) && + row.revalidate > 0 + ) { + rules[convertToNitroPattern(row.pattern)] = { swr: row.revalidate }; + } + } + + return rules; +} + +/** + * Converts vinext's internal `:param` route syntax to Nitro's `/**` glob + * pattern format. Nitro's `routeRules` use radix3's `toRouteMatcher` which + * documents exact paths and `/**` globs, not `:param` segments. + * + * /blog/:slug -> /blog/** + * /docs/:slug+ -> /docs/** + * /docs/:slug* -> /docs/** + * /about -> /about (unchanged) + */ +export function convertToNitroPattern(pattern: string): string { + return pattern.replace(/\/:([a-zA-Z_][a-zA-Z0-9_-]*)([+*]?)(\/|$)/g, "/**$3"); +} + +export function mergeNitroRouteRules( + existingRouteRules: Record | undefined, + generatedRouteRules: NitroRouteRules, +): { + routeRules: Record; + skippedRoutes: string[]; +} { + const routeRules = { ...existingRouteRules }; + const skippedRoutes: string[] = []; + + for (const [route, generatedRule] of Object.entries(generatedRouteRules)) { + const existingRule = routeRules[route]; + + if (existingRule && hasUserDefinedCacheRule(existingRule)) { + skippedRoutes.push(route); + continue; + } + + routeRules[route] = { + ...existingRule, + ...generatedRule, + }; + } + + return { routeRules, skippedRoutes }; +} + +function hasUserDefinedCacheRule(rule: NitroRouteRuleConfig): boolean { + return ( + rule.swr !== undefined || + rule.cache !== undefined || + rule.static !== undefined || + rule.isr !== undefined || + rule.prerender !== undefined + ); +} diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 8a25adaa..57695440 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -9,6 +9,7 @@ import { import { generateServerEntry as _generateServerEntry } from "./entries/pages-server-entry.js"; import { generateClientEntry as _generateClientEntry } from "./entries/pages-client-entry.js"; import { appRouter, invalidateAppRouteCache } from "./routing/app-router.js"; +import type { NitroRouteRuleConfig } from "./build/nitro-route-rules.js"; import { createValidFileMatcher } from "./routing/file-matcher.js"; import { createSSRHandler } from "./server/dev-server.js"; import { handleApiRoute } from "./server/api-handler.js"; @@ -1168,6 +1169,16 @@ export interface VinextOptions { }; } +interface NitroSetupContext { + options: { + dev?: boolean; + routeRules?: Record; + }; + logger?: { + warn?: (message: string) => void; + }; +} + export default function vinext(options: VinextOptions = {}): PluginOption[] { const viteMajorVersion = getViteMajorVersion(); let root: string; @@ -4369,6 +4380,40 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }; })(), + { + name: "vinext:nitro-route-rules", + nitro: { + setup: async (nitro: NitroSetupContext) => { + if (nitro.options.dev) return; + if (!nextConfig) return; + if (!hasAppDir && !hasPagesDir) return; + + const { collectNitroRouteRules, mergeNitroRouteRules } = + await import("./build/nitro-route-rules.js"); + const generatedRouteRules = await collectNitroRouteRules({ + appDir: hasAppDir ? appDir : null, + pagesDir: hasPagesDir ? pagesDir : null, + pageExtensions: nextConfig.pageExtensions, + }); + + if (Object.keys(generatedRouteRules).length === 0) return; + + const { routeRules, skippedRoutes } = mergeNitroRouteRules( + nitro.options.routeRules, + generatedRouteRules, + ); + + nitro.options.routeRules = routeRules; + + if (skippedRoutes.length > 0) { + const warn = nitro.logger?.warn ?? console.warn; + warn( + `[vinext] Skipping generated Nitro routeRules for routes with existing exact cache config: ${skippedRoutes.join(", ")}`, + ); + } + }, + }, + } as Plugin & { nitro: { setup: (nitro: NitroSetupContext) => Promise } }, // Nitro plugin extension convention: https://nitro.build/guide/plugins // Vite can emit empty SSR manifest entries for modules that Rollup inlines // into another chunk. Pages Router looks up assets by page module path at // runtime, so rebuild those mappings from the emitted client bundle. diff --git a/tests/nitro-route-rules.test.ts b/tests/nitro-route-rules.test.ts new file mode 100644 index 00000000..301b013f --- /dev/null +++ b/tests/nitro-route-rules.test.ts @@ -0,0 +1,331 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Plugin } from "vite-plus"; +import vinext from "../packages/vinext/src/index.js"; +import { + collectNitroRouteRules, + convertToNitroPattern, + generateNitroRouteRules, + mergeNitroRouteRules, + type NitroRouteRuleConfig, +} from "../packages/vinext/src/build/nitro-route-rules.js"; + +const tempDirs: string[] = []; + +interface NitroSetupTarget { + options: { + dev?: boolean; + routeRules?: Record; + }; + logger?: { + warn?: (message: string) => void; + }; +} + +interface NitroSetupPlugin extends Plugin { + nitro?: { + setup?: (nitro: NitroSetupTarget) => Promise | void; + }; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function isPlugin(plugin: unknown): plugin is Plugin { + return !!plugin && !Array.isArray(plugin) && typeof plugin === "object" && "name" in plugin; +} + +function findNamedPlugin(plugins: ReturnType, name: string) { + return plugins.find((plugin): plugin is Plugin => isPlugin(plugin) && plugin.name === name); +} + +function makeTempProject(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(root); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ name: "test-project", private: true }, null, 2), + ); + return root; +} + +function writeProjectFile(root: string, relativePath: string, content: string): void { + const filePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); +} + +function createAppProject(): string { + const root = makeTempProject("vinext-nitro-app-"); + writeProjectFile( + root, + "app/layout.tsx", + "export default function RootLayout({ children }: { children: React.ReactNode }) { return {children}; }\n", + ); + writeProjectFile( + root, + "app/page.tsx", + "export default function Home() { return
home
; }\n", + ); + writeProjectFile( + root, + "app/blog/[slug]/page.tsx", + [ + "export const revalidate = 60;", + "export default async function BlogPage() {", + " return
blog
;", + "}", + "", + ].join("\n"), + ); + return root; +} + +function createPagesProject(): string { + const root = makeTempProject("vinext-nitro-pages-"); + writeProjectFile( + root, + "pages/index.tsx", + "export default function Home() { return
home
; }\n", + ); + writeProjectFile( + root, + "pages/blog/[slug].tsx", + [ + "export async function getStaticProps() {", + " return { props: {}, revalidate: 45 };", + "}", + "", + "export default function BlogPage() {", + " return
blog
;", + "}", + "", + ].join("\n"), + ); + return root; +} + +async function initializeNitroSetupPlugin(root: string): Promise { + const plugins = vinext({ appDir: root, rsc: false }) as ReturnType; + const configPlugin = findNamedPlugin(plugins, "vinext:config") as Plugin & { + config?: ( + config: { root: string; plugins: unknown[] }, + env: { command: "build"; mode: string }, + ) => Promise; + }; + if (!configPlugin?.config) { + throw new Error("vinext:config plugin not found"); + } + + // Passing empty plugins array means hasNitroPlugin=false in the closure, + // but the nitro.setup hook doesn't gate on hasNitroPlugin (Nitro calls it directly). + await configPlugin.config({ root, plugins: [] }, { command: "build", mode: "production" }); + + const nitroPlugin = findNamedPlugin(plugins, "vinext:nitro-route-rules") as NitroSetupPlugin; + if (!nitroPlugin?.nitro?.setup) { + throw new Error("vinext:nitro-route-rules plugin not found"); + } + + return nitroPlugin; +} + +describe("convertToNitroPattern", () => { + it("leaves static routes unchanged", () => { + expect(convertToNitroPattern("/")).toBe("/"); + expect(convertToNitroPattern("/about")).toBe("/about"); + expect(convertToNitroPattern("/blog/featured")).toBe("/blog/featured"); + }); + + it("converts :param segments to /** globs", () => { + expect(convertToNitroPattern("/blog/:slug")).toBe("/blog/**"); + expect(convertToNitroPattern("/users/:id/posts")).toBe("/users/**/posts"); + }); + + it("converts :param+ catch-all segments to /** globs", () => { + expect(convertToNitroPattern("/docs/:slug+")).toBe("/docs/**"); + }); + + it("converts :param* optional catch-all segments to /** globs", () => { + expect(convertToNitroPattern("/docs/:slug*")).toBe("/docs/**"); + }); +}); + +describe("generateNitroRouteRules", () => { + it("returns empty object when no ISR routes exist", () => { + const rows = [ + { pattern: "/", type: "static" as const }, + { pattern: "/about", type: "ssr" as const }, + { pattern: "/api/data", type: "api" as const }, + ]; + + expect(generateNitroRouteRules(rows)).toEqual({}); + }); + + it("converts dynamic segments to Nitro glob patterns", () => { + const rows = [ + { pattern: "/", type: "isr" as const, revalidate: 120 }, + { pattern: "/blog/:slug", type: "isr" as const, revalidate: 60 }, + { pattern: "/docs/:slug+", type: "isr" as const, revalidate: 30 }, + { pattern: "/products/:id*", type: "isr" as const, revalidate: 15 }, + ]; + + expect(generateNitroRouteRules(rows)).toEqual({ + "/": { swr: 120 }, + "/blog/**": { swr: 60 }, + "/docs/**": { swr: 30 }, + "/products/**": { swr: 15 }, + }); + }); + + // In practice, buildReportRows never produces an ISR row with Infinity + // (classifyAppRoute maps Infinity to "static"), but generateNitroRouteRules + // should handle it defensively since Infinity serializes to null in JSON. + it("ignores Infinity revalidate defensively", () => { + const rows = [ + { pattern: "/isr", type: "isr" as const, revalidate: Infinity }, + { pattern: "/valid", type: "isr" as const, revalidate: 10 }, + ]; + + expect(generateNitroRouteRules(rows)).toEqual({ + "/valid": { swr: 10 }, + }); + }); +}); + +describe("mergeNitroRouteRules", () => { + it("merges generated swr into existing exact rules with unrelated fields", () => { + const result = mergeNitroRouteRules( + { + "/blog/:slug": { headers: { "x-test": "1" } }, + }, + { + "/blog/:slug": { swr: 60 }, + }, + ); + + expect(result.routeRules).toEqual({ + "/blog/:slug": { + headers: { "x-test": "1" }, + swr: 60, + }, + }); + expect(result.skippedRoutes).toEqual([]); + }); + + it("does not override explicit user cache rules on exact collisions", () => { + const result = mergeNitroRouteRules( + { + "/blog/:slug": { cache: { swr: true, maxAge: 600 } }, + }, + { + "/blog/:slug": { swr: 60 }, + }, + ); + + expect(result.routeRules).toEqual({ + "/blog/:slug": { cache: { swr: true, maxAge: 600 } }, + }); + expect(result.skippedRoutes).toEqual(["/blog/:slug"]); + }); +}); + +describe("collectNitroRouteRules", () => { + it("collects App Router ISR rules from scanned routes", async () => { + const root = createAppProject(); + + const routeRules = await collectNitroRouteRules({ + appDir: path.join(root, "app"), + pagesDir: null, + pageExtensions: ["tsx", "ts", "jsx", "js"], + }); + + expect(routeRules).toEqual({ + "/blog/**": { swr: 60 }, + }); + }); + + it("collects Pages Router ISR rules from scanned routes", async () => { + const root = createPagesProject(); + + const routeRules = await collectNitroRouteRules({ + appDir: null, + pagesDir: path.join(root, "pages"), + pageExtensions: ["tsx", "ts", "jsx", "js"], + }); + + expect(routeRules).toEqual({ + "/blog/**": { swr: 45 }, + }); + }); +}); + +describe("vinext Nitro setup integration", () => { + it("merges generated route rules into Nitro before build", async () => { + const root = createAppProject(); + const nitroPlugin = await initializeNitroSetupPlugin(root); + const warn = vi.fn(); + const nitro = { + options: { + dev: false, + routeRules: { + "/blog/**": { headers: { "x-test": "1" } }, + }, + }, + logger: { warn }, + }; + + await nitroPlugin.nitro!.setup!(nitro); + + expect(nitro.options.routeRules).toEqual({ + "/blog/**": { + headers: { "x-test": "1" }, + swr: 60, + }, + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it("keeps user cache rules intact and warns once", async () => { + const root = createAppProject(); + const nitroPlugin = await initializeNitroSetupPlugin(root); + const warn = vi.fn(); + const nitro = { + options: { + dev: false, + routeRules: { + "/blog/**": { swr: 600 }, + }, + }, + logger: { warn }, + }; + + await nitroPlugin.nitro!.setup!(nitro); + + expect(nitro.options.routeRules).toEqual({ + "/blog/**": { swr: 600 }, + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain("/blog/**"); + }); + + it("skips route rule generation during Nitro dev", async () => { + const root = createAppProject(); + const nitroPlugin = await initializeNitroSetupPlugin(root); + const nitro = { + options: { + dev: true, + routeRules: {}, + }, + }; + + await nitroPlugin.nitro!.setup!(nitro); + + expect(nitro.options.routeRules).toEqual({}); + }); +});