From 7e8701def3ab8e830ac738bfcdd3c065c5b71ea3 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 22 Mar 2026 21:33:06 -0700 Subject: [PATCH 1/8] fix: map route segment revalidate to Nitro routeRules SWR - Add generateNitroRouteRules() to convert ISR routes to Nitro format - Add writeBundle hook (vinext:nitro-route-rules) that emits routeRules to .output/nitro.json when Nitro plugin is detected - Closes #648 --- packages/vinext/src/build/report.ts | 14 +++++ packages/vinext/src/index.ts | 69 +++++++++++++++++++++++ tests/build-report.test.ts | 87 +++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 4e4cfdfe..534af580 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -839,6 +839,20 @@ export function formatBuildReport(rows: RouteRow[], routerLabel = "app"): string return lines.join("\n"); } +// ─── Nitro routeRules generation ──────────────────────────────────────────────── + +export type NitroRouteRules = Record; + +export function generateNitroRouteRules(rows: RouteRow[]): NitroRouteRules { + const rules: NitroRouteRules = {}; + for (const row of rows) { + if (row.type === "isr" && row.revalidate !== undefined && row.revalidate > 0) { + rules[row.pattern] = { swr: row.revalidate }; + } + } + return rules; +} + // ─── Directory detection ────────────────────────────────────────────────────── export function findDir(root: string, ...candidates: string[]): string | null { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 8a25adaa..38300cd5 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -9,6 +9,11 @@ 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 { + buildReportRows, + generateNitroRouteRules, + findDir, +} from "./build/report.js"; import { createValidFileMatcher } from "./routing/file-matcher.js"; import { createSSRHandler } from "./server/dev-server.js"; import { handleApiRoute } from "./server/api-handler.js"; @@ -4369,6 +4374,70 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }; })(), + // Nitro routeRules integration: + // When Nitro plugin is present, extract ISR route configs (export const revalidate = N) + // from app/ and pages/ directories and generate Nitro routeRules in the output. + { + name: "vinext:nitro-route-rules", + apply: "build", + enforce: "post", + writeBundle: { + sequential: true, + order: "post", + async handler(options) { + if (!hasNitroPlugin) return; + + const envName = this.environment?.name; + if (envName !== "rsc" && envName !== "ssr") return; + + const buildRoot = this.environment?.config?.root ?? process.cwd(); + const nitroOutputPath = path.join(buildRoot, ".output", "nitro.json"); + + const appDir = findDir(buildRoot, "app", "src/app"); + const pagesDir = findDir(buildRoot, "pages", "src/pages"); + + if (!appDir && !pagesDir) return; + + let appRoutes: any[] = []; + let pageRoutes: any[] = []; + let apiRoutes: any[] = []; + + if (appDir) { + appRoutes = await appRouter(appDir, nextConfig?.pageExtensions); + } + if (pagesDir) { + const [pages, apis] = await Promise.all([ + pagesRouter(pagesDir, nextConfig?.pageExtensions), + apiRouter(pagesDir, nextConfig?.pageExtensions), + ]); + pageRoutes = pages; + apiRoutes = apis; + } + + const rows = buildReportRows({ appRoutes, pageRoutes, apiRoutes }); + const routeRules = generateNitroRouteRules(rows); + + if (Object.keys(routeRules).length === 0) return; + + let nitroJson: Record = {}; + if (fs.existsSync(nitroOutputPath)) { + try { + nitroJson = JSON.parse(fs.readFileSync(nitroOutputPath, "utf-8")); + } catch { + // ignore parse errors + } + } + + if (!nitroJson.routeRules) { + nitroJson.routeRules = {}; + } + + Object.assign(nitroJson.routeRules, routeRules); + + fs.writeFileSync(nitroOutputPath, JSON.stringify(nitroJson, null, 2)); + }, + }, + }, // 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/build-report.test.ts b/tests/build-report.test.ts index d1e63e32..01ecaa6f 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -19,6 +19,7 @@ import { buildReportRows, formatBuildReport, printBuildReport, + generateNitroRouteRules, } from "../packages/vinext/src/build/report.js"; import { invalidateAppRouteCache } from "../packages/vinext/src/routing/app-router.js"; import { invalidateRouteCache } from "../packages/vinext/src/routing/pages-router.js"; @@ -739,3 +740,89 @@ describe("printBuildReport respects pageExtensions", () => { expect(output).toContain("/about"); }); }); + +// ─── generateNitroRouteRules ─────────────────────────────────────────────────── + +describe("generateNitroRouteRules", () => { + it("returns empty object when no ISR routes", () => { + 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("returns empty object for empty rows", () => { + expect(generateNitroRouteRules([])).toEqual({}); + }); + + it("maps ISR route to swr rule", () => { + const rows = [{ pattern: "/blog", type: "isr" as const, revalidate: 60 }]; + expect(generateNitroRouteRules(rows)).toEqual({ + "/blog": { swr: 60 }, + }); + }); + + it("maps multiple ISR routes to swr rules", () => { + const rows = [ + { pattern: "/blog", type: "isr" as const, revalidate: 60 }, + { pattern: "/about", type: "static" as const }, + { pattern: "/products", type: "isr" as const, revalidate: 30 }, + { pattern: "/api/data", type: "api" as const }, + ]; + expect(generateNitroRouteRules(rows)).toEqual({ + "/blog": { swr: 60 }, + "/products": { swr: 30 }, + }); + }); + + it("preserves root / pattern", () => { + const rows = [{ pattern: "/", type: "isr" as const, revalidate: 120 }]; + expect(generateNitroRouteRules(rows)).toEqual({ + "/": { swr: 120 }, + }); + }); + + it("handles nested ISR routes", () => { + const rows = [ + { pattern: "/blog/posts", type: "isr" as const, revalidate: 10 }, + { pattern: "/blog/posts/[slug]", type: "isr" as const, revalidate: 20 }, + ]; + expect(generateNitroRouteRules(rows)).toEqual({ + "/blog/posts": { swr: 10 }, + "/blog/posts/[slug]": { swr: 20 }, + }); + }); + + it("filters out non-ISR routes even if they have revalidate metadata", () => { + const rows = [ + { pattern: "/", type: "static" as const }, + { pattern: "/ssr", type: "ssr" as const }, + { pattern: "/unknown", type: "unknown" as const }, + { pattern: "/api", type: "api" as const }, + { pattern: "/isr", type: "isr" as const, revalidate: 5 }, + ]; + expect(generateNitroRouteRules(rows)).toEqual({ + "/isr": { swr: 5 }, + }); + }); + + it("uses revalidate value as swr number", () => { + const rows = [{ pattern: "/cache", type: "isr" as const, revalidate: 10 }]; + const result = generateNitroRouteRules(rows); + expect(result["/cache"].swr).toBe(10); + expect(typeof result["/cache"].swr).toBe("number"); + }); + + it("handles ISR routes with Infinity revalidate (treated as static)", () => { + const rows = [ + { pattern: "/static", type: "static" as const }, + { pattern: "/isr", type: "isr" as const, revalidate: Infinity }, + ]; + const result = generateNitroRouteRules(rows); + expect(result).toEqual({ + "/isr": { swr: Infinity }, + }); + }); +}); From b5edd235294f80d62f16c4355b80b98fac7e9554 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 22 Mar 2026 23:18:50 -0700 Subject: [PATCH 2/8] fix: address review feedback for Nitro routeRules SWR - Add Number.isFinite() guard to prevent Infinity serializing to null in JSON - Replace Object.assign with per-route deep merge to preserve existing Nitro route fields - Add parse error handling with warning + skip to avoid clobbering invalid nitro.json - Fix Infinity test case to assert no rule is produced (reflects real behavior) - Fix test pattern from [slug] to :slug (matches AppRoute.pattern syntax) Note: shims.test.ts failures are pre-existing on main (React 19 SSR hook issues) --- packages/vinext/src/build/report.ts | 7 ++++++- packages/vinext/src/index.ts | 18 +++++++++++++++--- tests/build-report.test.ts | 10 ++++------ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 534af580..dad2b2ec 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -846,7 +846,12 @@ export type NitroRouteRules = Record; export function generateNitroRouteRules(rows: RouteRow[]): NitroRouteRules { const rules: NitroRouteRules = {}; for (const row of rows) { - if (row.type === "isr" && row.revalidate !== undefined && row.revalidate > 0) { + if ( + row.type === "isr" && + typeof row.revalidate === "number" && + Number.isFinite(row.revalidate) && + row.revalidate > 0 + ) { rules[row.pattern] = { swr: row.revalidate }; } } diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 38300cd5..7e2aa303 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4420,19 +4420,31 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (Object.keys(routeRules).length === 0) return; let nitroJson: Record = {}; + let nitroJsonParseError = false; if (fs.existsSync(nitroOutputPath)) { try { nitroJson = JSON.parse(fs.readFileSync(nitroOutputPath, "utf-8")); - } catch { - // ignore parse errors + } catch (error) { + nitroJsonParseError = true; + console.warn( + `[vinext] Failed to parse existing nitro.json at ${nitroOutputPath}:`, + error instanceof Error ? error.message : String(error), + ); } } + if (nitroJsonParseError) return; + if (!nitroJson.routeRules) { nitroJson.routeRules = {}; } - Object.assign(nitroJson.routeRules, routeRules); + for (const [route, rule] of Object.entries(routeRules)) { + nitroJson.routeRules[route] = { + ...(nitroJson.routeRules[route] ?? {}), + ...rule, + }; + } fs.writeFileSync(nitroOutputPath, JSON.stringify(nitroJson, null, 2)); }, diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 01ecaa6f..56fc901e 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -787,11 +787,11 @@ describe("generateNitroRouteRules", () => { it("handles nested ISR routes", () => { const rows = [ { pattern: "/blog/posts", type: "isr" as const, revalidate: 10 }, - { pattern: "/blog/posts/[slug]", type: "isr" as const, revalidate: 20 }, + { pattern: "/blog/posts/:slug", type: "isr" as const, revalidate: 20 }, ]; expect(generateNitroRouteRules(rows)).toEqual({ "/blog/posts": { swr: 10 }, - "/blog/posts/[slug]": { swr: 20 }, + "/blog/posts/:slug": { swr: 20 }, }); }); @@ -815,14 +815,12 @@ describe("generateNitroRouteRules", () => { expect(typeof result["/cache"].swr).toBe("number"); }); - it("handles ISR routes with Infinity revalidate (treated as static)", () => { + it("does not generate swr rules for ISR routes with Infinity revalidate", () => { const rows = [ { pattern: "/static", type: "static" as const }, { pattern: "/isr", type: "isr" as const, revalidate: Infinity }, ]; const result = generateNitroRouteRules(rows); - expect(result).toEqual({ - "/isr": { swr: Infinity }, - }); + expect(result).toEqual({}); }); }); From ef0597a4fbdb6e59bd8838e4e951bd4ab458b12c Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 22 Mar 2026 23:46:45 -0700 Subject: [PATCH 3/8] fix: format import statement in index.ts --- packages/vinext/src/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 7e2aa303..628d6463 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -9,11 +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 { - buildReportRows, - generateNitroRouteRules, - findDir, -} from "./build/report.js"; +import { buildReportRows, generateNitroRouteRules, findDir } from "./build/report.js"; import { createValidFileMatcher } from "./routing/file-matcher.js"; import { createSSRHandler } from "./server/dev-server.js"; import { handleApiRoute } from "./server/api-handler.js"; From 6edb1743c3af4ef4f36b21ab8c38cf1cbd5f56fc Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Sun, 22 Mar 2026 23:51:51 -0700 Subject: [PATCH 4/8] fix: remove unnecessary ?? {} in spread operator to satisfy lint --- packages/vinext/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 628d6463..397286a6 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4437,7 +4437,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { for (const [route, rule] of Object.entries(routeRules)) { nitroJson.routeRules[route] = { - ...(nitroJson.routeRules[route] ?? {}), + ...nitroJson.routeRules[route], ...rule, }; } From 247890f139e7d6bf66690e1c6a894f210c61c718 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 23 Mar 2026 00:11:54 -0700 Subject: [PATCH 5/8] fix: prefix unused handler parameter with underscore to satisfy lint --- packages/vinext/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 397286a6..e03bf9d8 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4380,7 +4380,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { writeBundle: { sequential: true, order: "post", - async handler(options) { + async handler(_options) { if (!hasNitroPlugin) return; const envName = this.environment?.name; From 5ff1d4e232f8fa90f3f6055133e3f79682e34bb3 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 23 Mar 2026 13:21:14 -0700 Subject: [PATCH 6/8] fix: nitro routeRules integration - duplicate execution, mkdirSync, type safety, and tests - Add hasAppDir guard to prevent duplicate writeBundle execution in App Router multi-env builds - Add mkdirSync with recursive:true before writing nitro.json to handle missing .output/ dir - Use proper AppRoute[] and Route[] types instead of any[] - Add clarifying comment on Infinity revalidate defensive test case - Add TODO note (verified with URLPattern test) that vinext :param syntax works natively with Nitro's rou3 router - Add integration tests for nitro.json file I/O: directory creation, content correctness, merge behavior, and empty routeRules handling --- packages/vinext/src/build/report.ts | 3 + packages/vinext/src/index.ts | 13 ++- tests/build-report.test.ts | 146 +++++++++++++++++++++++++++- 3 files changed, 157 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index dad2b2ec..84faa6e5 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -843,6 +843,9 @@ export function formatBuildReport(rows: RouteRow[], routerLabel = "app"): string export type NitroRouteRules = Record; +// Note: Nitro uses rou3 (URLPattern-compatible) which natively supports vinext's +// :param syntax. URLPattern test confirms /blog/:slug matches /blog/my-post correctly. +// Patterns like /blog/:slug work directly in Nitro routeRules without conversion. export function generateNitroRouteRules(rows: RouteRow[]): NitroRouteRules { const rules: NitroRouteRules = {}; for (const row of rows) { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index e03bf9d8..fbd90a53 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -5,10 +5,11 @@ import { apiRouter, invalidateRouteCache, matchRoute, + type Route, } from "./routing/pages-router.js"; 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 { appRouter, invalidateAppRouteCache, type AppRoute } from "./routing/app-router.js"; import { buildReportRows, generateNitroRouteRules, findDir } from "./build/report.js"; import { createValidFileMatcher } from "./routing/file-matcher.js"; import { createSSRHandler } from "./server/dev-server.js"; @@ -4385,6 +4386,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const envName = this.environment?.name; if (envName !== "rsc" && envName !== "ssr") return; + // Avoid running twice in multi-environment App Router builds. Pages Router + // only has ssr, so we let it run there. App Router has both rsc and ssr. + if (envName === "ssr" && hasAppDir) return; const buildRoot = this.environment?.config?.root ?? process.cwd(); const nitroOutputPath = path.join(buildRoot, ".output", "nitro.json"); @@ -4394,9 +4398,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (!appDir && !pagesDir) return; - let appRoutes: any[] = []; - let pageRoutes: any[] = []; - let apiRoutes: any[] = []; + let appRoutes: AppRoute[] = []; + let pageRoutes: Route[] = []; + let apiRoutes: Route[] = []; if (appDir) { appRoutes = await appRouter(appDir, nextConfig?.pageExtensions); @@ -4442,6 +4446,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }; } + fs.mkdirSync(path.dirname(nitroOutputPath), { recursive: true }); fs.writeFileSync(nitroOutputPath, JSON.stringify(nitroJson, null, 2)); }, }, diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 56fc901e..36cd3464 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -5,10 +5,11 @@ * logic for both Pages Router and App Router routes, using real fixture files * where integration testing is needed. */ -import { describe, it, expect, afterEach } from "vite-plus/test"; +import { describe, it, expect, afterEach, beforeEach } from "vite-plus/test"; import path from "node:path"; import os from "node:os"; import fs from "node:fs/promises"; +import fsSync from "node:fs"; import { hasNamedExport, extractExportConstString, @@ -20,6 +21,7 @@ import { formatBuildReport, printBuildReport, generateNitroRouteRules, + type NitroRouteRules, } from "../packages/vinext/src/build/report.js"; import { invalidateAppRouteCache } from "../packages/vinext/src/routing/app-router.js"; import { invalidateRouteCache } from "../packages/vinext/src/routing/pages-router.js"; @@ -818,9 +820,151 @@ describe("generateNitroRouteRules", () => { it("does not generate swr rules for ISR routes with Infinity revalidate", () => { const rows = [ { pattern: "/static", type: "static" as const }, + // 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. { pattern: "/isr", type: "isr" as const, revalidate: Infinity }, ]; const result = generateNitroRouteRules(rows); expect(result).toEqual({}); }); }); + +// ─── Nitro routeRules file I/O integration ──────────────────────────────────── + +describe("generateNitroRouteRules file output", () => { + let tmpDir: string; + + function nitroOutputPath() { + return path.join(tmpDir, ".output", "nitro.json"); + } + + beforeEach(() => { + tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "vinext-nitro-test-")); + }); + + afterEach(() => { + fsSync.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeNitroJson(content: object): void { + const outputPath = nitroOutputPath(); + fsSync.mkdirSync(path.dirname(outputPath), { recursive: true }); + fsSync.writeFileSync(outputPath, JSON.stringify(content, null, 2), "utf-8"); + } + + function readNitroJson(): object { + const outputPath = nitroOutputPath(); + if (!fsSync.existsSync(outputPath)) return {}; + return JSON.parse(fsSync.readFileSync(outputPath, "utf-8")); + } + + function mergeNitroRouteRules(routeRules: NitroRouteRules): void { + if (Object.keys(routeRules).length === 0) return; + + const outputPath = nitroOutputPath(); + const nitroOutputDir = path.dirname(outputPath); + fsSync.mkdirSync(nitroOutputDir, { recursive: true }); + + let nitroJson: Record = {}; + if (fsSync.existsSync(outputPath)) { + try { + nitroJson = JSON.parse(fsSync.readFileSync(outputPath, "utf-8")); + } catch { + // ignore parse errors + } + } + + if (!nitroJson.routeRules) { + nitroJson.routeRules = {}; + } + + for (const [route, rule] of Object.entries(routeRules)) { + nitroJson.routeRules[route] = { + ...nitroJson.routeRules[route], + ...rule, + }; + } + + fsSync.writeFileSync(outputPath, JSON.stringify(nitroJson, null, 2), "utf-8"); + } + + it("creates .output directory if it does not exist", () => { + const outputPath = nitroOutputPath(); + expect(fsSync.existsSync(path.dirname(outputPath))).toBe(false); + + const rows = [{ pattern: "/isr", type: "isr" as const, revalidate: 60 }]; + const routeRules = generateNitroRouteRules(rows); + mergeNitroRouteRules(routeRules); + + expect(fsSync.existsSync(path.dirname(outputPath))).toBe(true); + expect(fsSync.existsSync(outputPath)).toBe(true); + }); + + it("writes correct nitro.json content for ISR routes", () => { + const rows = [ + { pattern: "/blog", type: "isr" as const, revalidate: 60 }, + { pattern: "/products", type: "isr" as const, revalidate: 30 }, + ]; + const routeRules = generateNitroRouteRules(rows); + mergeNitroRouteRules(routeRules); + + const result = readNitroJson(); + expect(result).toEqual({ + routeRules: { + "/blog": { swr: 60 }, + "/products": { swr: 30 }, + }, + }); + }); + + it("merges with existing routeRules in nitro.json", () => { + writeNitroJson({ + routeRules: { + "/existing": { swr: 120 }, + }, + }); + + const rows = [{ pattern: "/blog", type: "isr" as const, revalidate: 60 }]; + const routeRules = generateNitroRouteRules(rows); + mergeNitroRouteRules(routeRules); + + const result = readNitroJson(); + expect(result).toEqual({ + routeRules: { + "/existing": { swr: 120 }, + "/blog": { swr: 60 }, + }, + }); + }); + + it("overwrites existing swr values for the same route", () => { + writeNitroJson({ + routeRules: { + "/blog": { swr: 120 }, + }, + }); + + const rows = [{ pattern: "/blog", type: "isr" as const, revalidate: 60 }]; + const routeRules = generateNitroRouteRules(rows); + mergeNitroRouteRules(routeRules); + + const result = readNitroJson(); + expect(result).toEqual({ + routeRules: { + "/blog": { swr: 60 }, + }, + }); + }); + + it("does not write when no ISR routes exist", () => { + const rows = [ + { pattern: "/about", type: "static" as const }, + { pattern: "/ssr", type: "ssr" as const }, + ]; + const routeRules = generateNitroRouteRules(rows); + mergeNitroRouteRules(routeRules); + + expect(fsSync.existsSync(nitroOutputPath())).toBe(false); + }); +}); From ae5031754cabe517df0a910756a4f2cf6ea5186b Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 24 Mar 2026 18:39:37 -0700 Subject: [PATCH 7/8] fix: configure Nitro routeRules before build Nitro reads routeRules from config during compilation, so mutating .output/nitro.json after writeBundle was the wrong integration point. Move ISR rule generation into nitro.setup and cover merge behavior with dedicated tests. --- .../vinext/src/build/nitro-route-rules.ts | 94 ++++++ packages/vinext/src/build/report.ts | 22 -- packages/vinext/src/index.ts | 111 +++---- tests/build-report.test.ts | 231 +------------ tests/nitro-route-rules.test.ts | 304 ++++++++++++++++++ 5 files changed, 436 insertions(+), 326 deletions(-) create mode 100644 packages/vinext/src/build/nitro-route-rules.ts create mode 100644 tests/nitro-route-rules.test.ts 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..1cdcdf9b --- /dev/null +++ b/packages/vinext/src/build/nitro-route-rules.ts @@ -0,0 +1,94 @@ +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"; + +export type NitroRouteRuleConfig = Record & { + swr?: boolean | number; + cache?: unknown; + static?: boolean; + isr?: boolean | number; + prerender?: boolean; +}; + +export type NitroRouteRules = Record; + +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[row.pattern] = { swr: row.revalidate }; + } + } + + return rules; +} + +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/build/report.ts b/packages/vinext/src/build/report.ts index 84faa6e5..4e4cfdfe 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -839,28 +839,6 @@ export function formatBuildReport(rows: RouteRow[], routerLabel = "app"): string return lines.join("\n"); } -// ─── Nitro routeRules generation ──────────────────────────────────────────────── - -export type NitroRouteRules = Record; - -// Note: Nitro uses rou3 (URLPattern-compatible) which natively supports vinext's -// :param syntax. URLPattern test confirms /blog/:slug matches /blog/my-post correctly. -// Patterns like /blog/:slug work directly in Nitro routeRules without conversion. -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[row.pattern] = { swr: row.revalidate }; - } - } - return rules; -} - // ─── Directory detection ────────────────────────────────────────────────────── export function findDir(root: string, ...candidates: string[]): string | null { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index fbd90a53..f8b384c5 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -5,12 +5,11 @@ import { apiRouter, invalidateRouteCache, matchRoute, - type Route, } from "./routing/pages-router.js"; import { generateServerEntry as _generateServerEntry } from "./entries/pages-server-entry.js"; import { generateClientEntry as _generateClientEntry } from "./entries/pages-client-entry.js"; -import { appRouter, invalidateAppRouteCache, type AppRoute } from "./routing/app-router.js"; -import { buildReportRows, generateNitroRouteRules, findDir } from "./build/report.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"; @@ -1170,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; @@ -4371,86 +4380,40 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }, }; })(), - // Nitro routeRules integration: - // When Nitro plugin is present, extract ISR route configs (export const revalidate = N) - // from app/ and pages/ directories and generate Nitro routeRules in the output. { name: "vinext:nitro-route-rules", - apply: "build", - enforce: "post", - writeBundle: { - sequential: true, - order: "post", - async handler(_options) { - if (!hasNitroPlugin) return; - - const envName = this.environment?.name; - if (envName !== "rsc" && envName !== "ssr") return; - // Avoid running twice in multi-environment App Router builds. Pages Router - // only has ssr, so we let it run there. App Router has both rsc and ssr. - if (envName === "ssr" && hasAppDir) return; - - const buildRoot = this.environment?.config?.root ?? process.cwd(); - const nitroOutputPath = path.join(buildRoot, ".output", "nitro.json"); - - const appDir = findDir(buildRoot, "app", "src/app"); - const pagesDir = findDir(buildRoot, "pages", "src/pages"); - - if (!appDir && !pagesDir) return; - - let appRoutes: AppRoute[] = []; - let pageRoutes: Route[] = []; - let apiRoutes: Route[] = []; - - if (appDir) { - appRoutes = await appRouter(appDir, nextConfig?.pageExtensions); - } - if (pagesDir) { - const [pages, apis] = await Promise.all([ - pagesRouter(pagesDir, nextConfig?.pageExtensions), - apiRouter(pagesDir, nextConfig?.pageExtensions), - ]); - pageRoutes = pages; - apiRoutes = apis; - } - - const rows = buildReportRows({ appRoutes, pageRoutes, apiRoutes }); - const routeRules = generateNitroRouteRules(rows); + 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(routeRules).length === 0) return; + if (Object.keys(generatedRouteRules).length === 0) return; - let nitroJson: Record = {}; - let nitroJsonParseError = false; - if (fs.existsSync(nitroOutputPath)) { - try { - nitroJson = JSON.parse(fs.readFileSync(nitroOutputPath, "utf-8")); - } catch (error) { - nitroJsonParseError = true; - console.warn( - `[vinext] Failed to parse existing nitro.json at ${nitroOutputPath}:`, - error instanceof Error ? error.message : String(error), - ); - } - } - - if (nitroJsonParseError) return; + const { routeRules, skippedRoutes } = mergeNitroRouteRules( + nitro.options.routeRules, + generatedRouteRules, + ); - if (!nitroJson.routeRules) { - nitroJson.routeRules = {}; - } + nitro.options.routeRules = routeRules; - for (const [route, rule] of Object.entries(routeRules)) { - nitroJson.routeRules[route] = { - ...nitroJson.routeRules[route], - ...rule, - }; + 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(", ")}`, + ); } - - fs.mkdirSync(path.dirname(nitroOutputPath), { recursive: true }); - fs.writeFileSync(nitroOutputPath, JSON.stringify(nitroJson, null, 2)); }, }, - }, + } as Plugin & { nitro: { setup: (nitro: NitroSetupContext) => Promise } }, // 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/build-report.test.ts b/tests/build-report.test.ts index 36cd3464..d1e63e32 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -5,11 +5,10 @@ * logic for both Pages Router and App Router routes, using real fixture files * where integration testing is needed. */ -import { describe, it, expect, afterEach, beforeEach } from "vite-plus/test"; +import { describe, it, expect, afterEach } from "vite-plus/test"; import path from "node:path"; import os from "node:os"; import fs from "node:fs/promises"; -import fsSync from "node:fs"; import { hasNamedExport, extractExportConstString, @@ -20,8 +19,6 @@ import { buildReportRows, formatBuildReport, printBuildReport, - generateNitroRouteRules, - type NitroRouteRules, } from "../packages/vinext/src/build/report.js"; import { invalidateAppRouteCache } from "../packages/vinext/src/routing/app-router.js"; import { invalidateRouteCache } from "../packages/vinext/src/routing/pages-router.js"; @@ -742,229 +739,3 @@ describe("printBuildReport respects pageExtensions", () => { expect(output).toContain("/about"); }); }); - -// ─── generateNitroRouteRules ─────────────────────────────────────────────────── - -describe("generateNitroRouteRules", () => { - it("returns empty object when no ISR routes", () => { - 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("returns empty object for empty rows", () => { - expect(generateNitroRouteRules([])).toEqual({}); - }); - - it("maps ISR route to swr rule", () => { - const rows = [{ pattern: "/blog", type: "isr" as const, revalidate: 60 }]; - expect(generateNitroRouteRules(rows)).toEqual({ - "/blog": { swr: 60 }, - }); - }); - - it("maps multiple ISR routes to swr rules", () => { - const rows = [ - { pattern: "/blog", type: "isr" as const, revalidate: 60 }, - { pattern: "/about", type: "static" as const }, - { pattern: "/products", type: "isr" as const, revalidate: 30 }, - { pattern: "/api/data", type: "api" as const }, - ]; - expect(generateNitroRouteRules(rows)).toEqual({ - "/blog": { swr: 60 }, - "/products": { swr: 30 }, - }); - }); - - it("preserves root / pattern", () => { - const rows = [{ pattern: "/", type: "isr" as const, revalidate: 120 }]; - expect(generateNitroRouteRules(rows)).toEqual({ - "/": { swr: 120 }, - }); - }); - - it("handles nested ISR routes", () => { - const rows = [ - { pattern: "/blog/posts", type: "isr" as const, revalidate: 10 }, - { pattern: "/blog/posts/:slug", type: "isr" as const, revalidate: 20 }, - ]; - expect(generateNitroRouteRules(rows)).toEqual({ - "/blog/posts": { swr: 10 }, - "/blog/posts/:slug": { swr: 20 }, - }); - }); - - it("filters out non-ISR routes even if they have revalidate metadata", () => { - const rows = [ - { pattern: "/", type: "static" as const }, - { pattern: "/ssr", type: "ssr" as const }, - { pattern: "/unknown", type: "unknown" as const }, - { pattern: "/api", type: "api" as const }, - { pattern: "/isr", type: "isr" as const, revalidate: 5 }, - ]; - expect(generateNitroRouteRules(rows)).toEqual({ - "/isr": { swr: 5 }, - }); - }); - - it("uses revalidate value as swr number", () => { - const rows = [{ pattern: "/cache", type: "isr" as const, revalidate: 10 }]; - const result = generateNitroRouteRules(rows); - expect(result["/cache"].swr).toBe(10); - expect(typeof result["/cache"].swr).toBe("number"); - }); - - it("does not generate swr rules for ISR routes with Infinity revalidate", () => { - const rows = [ - { pattern: "/static", type: "static" as const }, - // 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. - { pattern: "/isr", type: "isr" as const, revalidate: Infinity }, - ]; - const result = generateNitroRouteRules(rows); - expect(result).toEqual({}); - }); -}); - -// ─── Nitro routeRules file I/O integration ──────────────────────────────────── - -describe("generateNitroRouteRules file output", () => { - let tmpDir: string; - - function nitroOutputPath() { - return path.join(tmpDir, ".output", "nitro.json"); - } - - beforeEach(() => { - tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "vinext-nitro-test-")); - }); - - afterEach(() => { - fsSync.rmSync(tmpDir, { recursive: true, force: true }); - }); - - function writeNitroJson(content: object): void { - const outputPath = nitroOutputPath(); - fsSync.mkdirSync(path.dirname(outputPath), { recursive: true }); - fsSync.writeFileSync(outputPath, JSON.stringify(content, null, 2), "utf-8"); - } - - function readNitroJson(): object { - const outputPath = nitroOutputPath(); - if (!fsSync.existsSync(outputPath)) return {}; - return JSON.parse(fsSync.readFileSync(outputPath, "utf-8")); - } - - function mergeNitroRouteRules(routeRules: NitroRouteRules): void { - if (Object.keys(routeRules).length === 0) return; - - const outputPath = nitroOutputPath(); - const nitroOutputDir = path.dirname(outputPath); - fsSync.mkdirSync(nitroOutputDir, { recursive: true }); - - let nitroJson: Record = {}; - if (fsSync.existsSync(outputPath)) { - try { - nitroJson = JSON.parse(fsSync.readFileSync(outputPath, "utf-8")); - } catch { - // ignore parse errors - } - } - - if (!nitroJson.routeRules) { - nitroJson.routeRules = {}; - } - - for (const [route, rule] of Object.entries(routeRules)) { - nitroJson.routeRules[route] = { - ...nitroJson.routeRules[route], - ...rule, - }; - } - - fsSync.writeFileSync(outputPath, JSON.stringify(nitroJson, null, 2), "utf-8"); - } - - it("creates .output directory if it does not exist", () => { - const outputPath = nitroOutputPath(); - expect(fsSync.existsSync(path.dirname(outputPath))).toBe(false); - - const rows = [{ pattern: "/isr", type: "isr" as const, revalidate: 60 }]; - const routeRules = generateNitroRouteRules(rows); - mergeNitroRouteRules(routeRules); - - expect(fsSync.existsSync(path.dirname(outputPath))).toBe(true); - expect(fsSync.existsSync(outputPath)).toBe(true); - }); - - it("writes correct nitro.json content for ISR routes", () => { - const rows = [ - { pattern: "/blog", type: "isr" as const, revalidate: 60 }, - { pattern: "/products", type: "isr" as const, revalidate: 30 }, - ]; - const routeRules = generateNitroRouteRules(rows); - mergeNitroRouteRules(routeRules); - - const result = readNitroJson(); - expect(result).toEqual({ - routeRules: { - "/blog": { swr: 60 }, - "/products": { swr: 30 }, - }, - }); - }); - - it("merges with existing routeRules in nitro.json", () => { - writeNitroJson({ - routeRules: { - "/existing": { swr: 120 }, - }, - }); - - const rows = [{ pattern: "/blog", type: "isr" as const, revalidate: 60 }]; - const routeRules = generateNitroRouteRules(rows); - mergeNitroRouteRules(routeRules); - - const result = readNitroJson(); - expect(result).toEqual({ - routeRules: { - "/existing": { swr: 120 }, - "/blog": { swr: 60 }, - }, - }); - }); - - it("overwrites existing swr values for the same route", () => { - writeNitroJson({ - routeRules: { - "/blog": { swr: 120 }, - }, - }); - - const rows = [{ pattern: "/blog", type: "isr" as const, revalidate: 60 }]; - const routeRules = generateNitroRouteRules(rows); - mergeNitroRouteRules(routeRules); - - const result = readNitroJson(); - expect(result).toEqual({ - routeRules: { - "/blog": { swr: 60 }, - }, - }); - }); - - it("does not write when no ISR routes exist", () => { - const rows = [ - { pattern: "/about", type: "static" as const }, - { pattern: "/ssr", type: "ssr" as const }, - ]; - const routeRules = generateNitroRouteRules(rows); - mergeNitroRouteRules(routeRules); - - expect(fsSync.existsSync(nitroOutputPath())).toBe(false); - }); -}); diff --git a/tests/nitro-route-rules.test.ts b/tests/nitro-route-rules.test.ts new file mode 100644 index 00000000..bb0014aa --- /dev/null +++ b/tests/nitro-route-rules.test.ts @@ -0,0 +1,304 @@ +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, + 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"); + } + + 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("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("preserves exact vinext route patterns for Nitro rules", () => { + 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: "/docs/:slug*", type: "isr" as const, revalidate: 15 }, + ]; + + expect(generateNitroRouteRules(rows)).toEqual({ + "/": { swr: 120 }, + "/blog/:slug": { swr: 60 }, + "/docs/:slug+": { swr: 30 }, + "/docs/:slug*": { swr: 15 }, + }); + }); + + 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/:slug": { 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/:slug": { 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/:slug": { headers: { "x-test": "1" } }, + }, + }, + logger: { warn }, + }; + + await nitroPlugin.nitro!.setup!(nitro); + + expect(nitro.options.routeRules).toEqual({ + "/blog/:slug": { + 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/:slug": { swr: 600 }, + }, + }, + logger: { warn }, + }; + + await nitroPlugin.nitro!.setup!(nitro); + + expect(nitro.options.routeRules).toEqual({ + "/blog/:slug": { swr: 600 }, + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain("/blog/:slug"); + }); + + 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({}); + }); +}); From 14c106e9b5bbaf5bb6d5ee3501fb6819be5e37dc Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 25 Mar 2026 12:33:00 -0700 Subject: [PATCH 8/8] fix: convert :param route patterns to /** globs for Nitro routeRules Nitro routeRules use radix3 toRouteMatcher which documents exact paths and /** glob patterns, not :param segments. Added convertToNitroPattern() to translate vinext internal route syntax before emitting routeRules keys. Also adds reviewer-requested documentation comments on the hand-rolled NitroRouteConfig type, plugin cast, redundant scanning note, and test guards. Signed-off-by: Divanshu Chauhan Made-with: Cursor --- .../vinext/src/build/nitro-route-rules.ts | 29 ++++++++++- packages/vinext/src/index.ts | 2 +- tests/nitro-route-rules.test.ts | 51 ++++++++++++++----- 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/packages/vinext/src/build/nitro-route-rules.ts b/packages/vinext/src/build/nitro-route-rules.ts index 1cdcdf9b..3be106eb 100644 --- a/packages/vinext/src/build/nitro-route-rules.ts +++ b/packages/vinext/src/build/nitro-route-rules.ts @@ -2,6 +2,7 @@ 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; @@ -12,6 +13,18 @@ export type NitroRouteRuleConfig = Record & { 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; @@ -49,13 +62,27 @@ export function generateNitroRouteRules(rows: RouteRow[]): NitroRouteRules { Number.isFinite(row.revalidate) && row.revalidate > 0 ) { - rules[row.pattern] = { swr: row.revalidate }; + 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, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index f8b384c5..57695440 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -4413,7 +4413,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } }, }, - } as Plugin & { nitro: { setup: (nitro: NitroSetupContext) => Promise } }, + } 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 index bb0014aa..301b013f 100644 --- a/tests/nitro-route-rules.test.ts +++ b/tests/nitro-route-rules.test.ts @@ -6,6 +6,7 @@ import type { Plugin } from "vite-plus"; import vinext from "../packages/vinext/src/index.js"; import { collectNitroRouteRules, + convertToNitroPattern, generateNitroRouteRules, mergeNitroRouteRules, type NitroRouteRuleConfig, @@ -122,6 +123,8 @@ async function initializeNitroSetupPlugin(root: string): Promise { + 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 = [ @@ -143,22 +167,25 @@ describe("generateNitroRouteRules", () => { expect(generateNitroRouteRules(rows)).toEqual({}); }); - it("preserves exact vinext route patterns for Nitro rules", () => { + 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: "/docs/:slug*", type: "isr" as const, revalidate: 15 }, + { pattern: "/products/:id*", type: "isr" as const, revalidate: 15 }, ]; expect(generateNitroRouteRules(rows)).toEqual({ "/": { swr: 120 }, - "/blog/:slug": { swr: 60 }, - "/docs/:slug+": { swr: 30 }, - "/docs/:slug*": { swr: 15 }, + "/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 }, @@ -219,7 +246,7 @@ describe("collectNitroRouteRules", () => { }); expect(routeRules).toEqual({ - "/blog/:slug": { swr: 60 }, + "/blog/**": { swr: 60 }, }); }); @@ -233,7 +260,7 @@ describe("collectNitroRouteRules", () => { }); expect(routeRules).toEqual({ - "/blog/:slug": { swr: 45 }, + "/blog/**": { swr: 45 }, }); }); }); @@ -247,7 +274,7 @@ describe("vinext Nitro setup integration", () => { options: { dev: false, routeRules: { - "/blog/:slug": { headers: { "x-test": "1" } }, + "/blog/**": { headers: { "x-test": "1" } }, }, }, logger: { warn }, @@ -256,7 +283,7 @@ describe("vinext Nitro setup integration", () => { await nitroPlugin.nitro!.setup!(nitro); expect(nitro.options.routeRules).toEqual({ - "/blog/:slug": { + "/blog/**": { headers: { "x-test": "1" }, swr: 60, }, @@ -272,7 +299,7 @@ describe("vinext Nitro setup integration", () => { options: { dev: false, routeRules: { - "/blog/:slug": { swr: 600 }, + "/blog/**": { swr: 600 }, }, }, logger: { warn }, @@ -281,10 +308,10 @@ describe("vinext Nitro setup integration", () => { await nitroPlugin.nitro!.setup!(nitro); expect(nitro.options.routeRules).toEqual({ - "/blog/:slug": { swr: 600 }, + "/blog/**": { swr: 600 }, }); expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0]?.[0]).toContain("/blog/:slug"); + expect(warn.mock.calls[0]?.[0]).toContain("/blog/**"); }); it("skips route rule generation during Nitro dev", async () => {