Skip to content
121 changes: 121 additions & 0 deletions packages/vinext/src/build/nitro-route-rules.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> & {
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: consider importing this type from Nitro directly (e.g., import type { NitroRouteRules } from 'nitropack') if Nitro is a dependency, rather than hand-rolling the type. This ensures the type stays in sync with Nitro's actual API. If Nitro isn't a dependency (and shouldn't be), the hand-rolled type is fine — but add a comment noting it mirrors Nitro's NitroRouteConfig.

swr?: boolean | number;
cache?: unknown;
static?: boolean;
isr?: boolean | number;
prerender?: boolean;
};

export type NitroRouteRules = Record<string, { swr: number }>;

/**
* 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<NitroRouteRules> {
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 }));
Copy link
Contributor

Choose a reason for hiding this comment

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

Note: this re-scans the filesystem and re-reads every route file to extract revalidate — the same work printBuildReport does in cli.ts:513. For the initial implementation this is fine, but a follow-up optimization could cache route classification results from one pass and share them.

Also, printBuildReport supports a prerenderResult option that upgrades unknown routes to static when confirmed by speculative prerender. This path doesn't receive prerender results, so the route classification may differ slightly (some routes that printBuildReport would mark as static might appear as unknown here and be skipped). Worth documenting.

}

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<string, NitroRouteRuleConfig> | undefined,
generatedRouteRules: NitroRouteRules,
): {
routeRules: Record<string, NitroRouteRuleConfig>;
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
);
}
45 changes: 45 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Contributor

Choose a reason for hiding this comment

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

Good — import type is erased at runtime, so this doesn't pull in build/nitro-route-rules.js during dev. The prior review comment about "top-level import bloat" was incorrect.

import { createValidFileMatcher } from "./routing/file-matcher.js";
import { createSSRHandler } from "./server/dev-server.js";
import { handleApiRoute } from "./server/api-handler.js";
Expand Down Expand Up @@ -1168,6 +1169,16 @@ export interface VinextOptions {
};
}

interface NitroSetupContext {
options: {
dev?: boolean;
routeRules?: Record<string, NitroRouteRuleConfig>;
};
logger?: {
warn?: (message: string) => void;
};
}

export default function vinext(options: VinextOptions = {}): PluginOption[] {
const viteMajorVersion = getViteMajorVersion();
let root: string;
Expand Down Expand Up @@ -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<void> } }, // 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.
Expand Down
Loading
Loading