diff --git a/packages/nuxt-cli/src/commands/build.ts b/packages/nuxt-cli/src/commands/build.ts index beedc5733..2cb4e12d3 100644 --- a/packages/nuxt-cli/src/commands/build.ts +++ b/packages/nuxt-cli/src/commands/build.ts @@ -19,6 +19,7 @@ import { intro, logger, outro } from '../utils/logger' import { resolveRootDir } from '../utils/paths' import { createPhaseReporter, formatPhaseBreakdown } from '../utils/phase-reporter' import { startCpuProfile, stopCpuProfile } from '../utils/profile' +import { formatRouteRules } from '../utils/route-rules' import { resolveServerBuild } from '../utils/server-build' import { quietServerOutput } from '../utils/server-output' import { blankLineBefore } from '../utils/stdout' @@ -112,6 +113,13 @@ export default defineCommand({ const serverOutput = quietServerOutput(nuxt) const clientBundle = ctx.args.logLevel === 'silent' ? undefined : collectClientBundle(nuxt) + // Route rules are read once the build is done: pages and modules keep + // changing them until then, and a config reload replaces the object. + let nitroOptions: { routeRules?: Record } | undefined + nuxt.hook('nitro:init', (nitro) => { + nitroOptions = nitro.options + }) + showBanner(nuxt) await nuxt.ready() @@ -154,6 +162,14 @@ export default defineCommand({ logger.message(`${blankLineBefore()}${formatClientBundle(bundle, process.stdout.columns || 80)}\n`) } + const routeRules = nitroOptions && formatRouteRules(nitroOptions.routeRules, { + buildAssetsDir: nuxt.options.app.buildAssetsDir, + columns: process.stdout.columns || 80, + }) + if (routeRules) { + logger.message(`${routeRules}\n`) + } + const serverSummary = serverOutput.summary() if (serverSummary) { logger.success(serverSummary) diff --git a/packages/nuxt-cli/src/utils/route-rules.ts b/packages/nuxt-cli/src/utils/route-rules.ts new file mode 100644 index 000000000..12c6e40d2 --- /dev/null +++ b/packages/nuxt-cli/src/utils/route-rules.ts @@ -0,0 +1,140 @@ +import { styleText } from 'node:util' + +import { truncate, visibleWidth } from './formatting' + +type RouteRule = Record + +/** Rows listed before the rest are counted, as the client bundle summary does for chunks. */ +const MAX_ROUTES = 20 + +/** Rules Nuxt adds for its own endpoints, which say nothing about the app. */ +const INTERNAL_ROUTES = new Set(['/__nuxt_error']) + +export interface FormatRouteRulesOptions { + /** Nuxt's `app.buildAssetsDir`, whose cache headers Nuxt and Nitro set on their own. */ + buildAssetsDir?: string + /** Width to truncate rows to. */ + columns?: number +} + +/** Headers Nitro adds for `cors: true`, shown as `cors` rather than one by one. */ +const CORS_HEADERS = new Set([ + 'access-control-allow-origin', + 'access-control-allow-methods', + 'access-control-allow-headers', + 'access-control-max-age', +]) + +/** Rule keys given their own label; any other key is listed as it is. */ +const KNOWN_KEYS = new Set(['prerender', 'ssr', 'isr', 'swr', 'cache', 'redirect', 'proxy', 'cors', 'headers', 'appMiddleware']) + +function seconds(value: unknown): string { + return typeof value === 'number' ? ` ${value}s` : '' +} + +/** Nitro 2 names the redirect code `statusCode`, Nitro 3 `status`. */ +function target(value: { to?: string, status?: number, statusCode?: number }): string { + const status = value.status ?? value.statusCode + return `${value.to}${status ? styleText('dim', ` (${status})`) : ''}` +} + +/** One route's rules as short labels, in the order a reader weighs them: rendering, caching, routing, headers. */ +export function describeRouteRule(rule: RouteRule): string[] { + const labels: string[] = [] + + if (rule.prerender !== undefined) { + labels.push(rule.prerender ? 'prerender' : 'prerender: false') + } + if (rule.ssr === false) { + labels.push('ssr: false') + } + if (rule.isr !== undefined && rule.isr !== false) { + labels.push(`isr${seconds(rule.isr)}`) + } + // Nitro 2 keeps `swr` next to the `cache` it expands to; Nitro 3 only keeps `cache`. + if (rule.swr || rule.cache?.swr) { + labels.push(`swr${seconds(typeof rule.swr === 'number' ? rule.swr : rule.cache?.maxAge)}`) + } + else if (rule.cache === false) { + labels.push('cache: false') + } + else if (rule.cache) { + labels.push(`cache${seconds(rule.cache.maxAge)}`) + } + if (rule.redirect) { + labels.push(`redirect → ${target(rule.redirect)}`) + } + if (rule.proxy) { + labels.push(`proxy → ${target(rule.proxy)}`) + } + if (rule.cors) { + labels.push('cors') + } + const headers = Object.keys(rule.headers || {}).filter(name => !rule.cors || !CORS_HEADERS.has(name)) + if (headers.length) { + labels.push(`headers: ${headers.join(', ')}`) + } + const middleware = Object.entries(rule.appMiddleware || {}) + if (middleware.length) { + labels.push(`middleware: ${middleware.map(([name, enabled]) => enabled ? name : `-${name}`).join(', ')}`) + } + + for (const [key, value] of Object.entries(rule)) { + if (KNOWN_KEYS.has(key) || value === undefined) { + continue + } + labels.push(value === true ? key : `${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`) + } + return labels +} + +/** + * Nuxt renames unnamed segments of page rules to `:_` so that patterns + * sharing a prefix agree on parameter names; written back as the `*` they were. + */ +function displayRoute(route: string): string { + return route.replace(/\/:_\d+_*(?=\/|$)/g, '/*') +} + +/** Whether `route` only exists because Nuxt mirrored a cached page's rules onto its payload. */ +function isDerivedPayloadRoute(route: string, routes: Set): boolean { + if (!route.endsWith('/_payload.json')) { + return false + } + return routes.has(route.slice(0, -'/_payload.json'.length) || '/') +} + +/** + * The app's resolved route rules as a two-column table, or an empty string when + * it has none worth listing. + */ +export function formatRouteRules(routeRules: Record | undefined, options: FormatRouteRulesOptions = {}): string { + const columns = options.columns ?? 80 + const assetsPrefix = options.buildAssetsDir ? `/${options.buildAssetsDir.replace(/^\/+|\/+$/g, '')}/` : undefined + const routes = new Set(Object.keys(routeRules || {})) + const rows = Object.entries(routeRules || {}) + .filter(([route]) => !INTERNAL_ROUTES.has(route) + && !(assetsPrefix && route.startsWith(assetsPrefix)) + && !isDerivedPayloadRoute(route, routes)) + .map(([route, rule]) => [displayRoute(route), describeRouteRule(rule as RouteRule).join(styleText('dim', ', '))] as const) + .filter(([, labels]) => labels) + .sort(([a], [b]) => a.localeCompare(b)) + + if (!rows.length) { + return '' + } + + const shown = rows.slice(0, MAX_ROUTES) + const width = Math.max('Route'.length, ...shown.map(([route]) => visibleWidth(route))) + const pad = (text: string) => text + ' '.repeat(width - visibleWidth(text)) + const lines = [ + `${styleText('bold', 'Route rules')} ${styleText('dim', `${rows.length} ${rows.length === 1 ? 'route' : 'routes'}`)}`, + '', + styleText('dim', `${pad('Route')} Rules`), + ...shown.map(([route, labels]) => `${styleText('cyan', pad(route))} ${labels}`), + ] + if (rows.length > shown.length) { + lines.push(styleText('dim', `+${rows.length - shown.length} more routes`)) + } + return lines.map(line => truncate(line, columns)).join('\n') +} diff --git a/packages/nuxt-cli/test/unit/utils/route-rules.spec.ts b/packages/nuxt-cli/test/unit/utils/route-rules.spec.ts new file mode 100644 index 000000000..3c39c2d58 --- /dev/null +++ b/packages/nuxt-cli/test/unit/utils/route-rules.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' + +import { stripAnsi } from '../../../src/utils/formatting' +import { formatRouteRules } from '../../../src/utils/route-rules' + +describe('formatRouteRules', () => { + it('lists the app\'s rules as Nitro resolves them, without the ones Nuxt adds itself', () => { + // `nitro.options.routeRules` from a real `nuxt build`, after Nitro normalised + // the config and Nuxt added its own entries. + const routeRules = { + '/__nuxt_error': { cache: false }, + '/_nuxt/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } }, + '/': { prerender: true }, + '/blog/**': { swr: 3600, cache: { swr: true, maxAge: 3600 } }, + '/products/**': { isr: 600 }, + '/products/shoe': { isr: 60 }, + '/products/shoe/_payload.json': { ssr: true, isr: 60 }, + '/admin/**': { ssr: false }, + '/api/**': { + cors: true, + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': '*', + 'access-control-allow-headers': '*', + 'access-control-max-age': '0', + 'x-api': '1', + }, + }, + '/legacy/**': { redirect: { to: '/v2/**', statusCode: 301, _redirectStripBase: '/legacy' } }, + '/proxy/**': { proxy: { to: 'https://example.com/**', _proxyStripBase: '/proxy' } }, + '/no-cache': { cache: false }, + '/blog/:_2': { prerender: true }, + '/feed.xml': { robots: false, noScripts: true }, + } + + expect(stripAnsi(formatRouteRules(routeRules, { buildAssetsDir: '/_nuxt/', columns: 200 }))).toBe([ + 'Route rules 11 routes', + '', + 'Route Rules', + '/ prerender', + '/admin/** ssr: false', + '/api/** cors, headers: x-api', + '/blog/* prerender', + '/blog/** swr 3600s', + '/feed.xml robots: false, noScripts', + '/legacy/** redirect → /v2/** (301)', + '/no-cache cache: false', + '/products/** isr 600s', + '/products/shoe isr 60s', + '/proxy/** proxy → https://example.com/**', + ].join('\n')) + }) + + it('reads the shapes Nitro 3 resolves rules to', () => { + // `nitro.options.routeRules` from a real `nuxt build` on Nuxt 5 nightly with Nitro 3. + const routeRules = { + '/**': { ssr: true }, + '/__nuxt_error': { cache: false }, + '/blog/**': { cache: { swr: true, maxAge: 3600 } }, + '/api/**': { headers: { 'x-api': '1' }, cors: {} }, + '/old': { redirect: { to: '/new', status: 307 } }, + '/proxy/**': { proxy: { to: 'https://example.com/**', base: '/proxy' } }, + '/_nuxt//**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } }, + } + + expect(stripAnsi(formatRouteRules(routeRules, { buildAssetsDir: '/_nuxt/', columns: 200 })).split('\n').slice(3)).toEqual([ + '/api/** cors, headers: x-api', + '/blog/** swr 3600s', + '/old redirect → /new (307)', + '/proxy/** proxy → https://example.com/**', + ]) + }) + + it('lists the first 20 routes and counts the rest', () => { + const routeRules = Object.fromEntries(Array.from({ length: 23 }, (_, index) => [`/page-${String(index).padStart(2, '0')}`, { prerender: true }])) + const lines = stripAnsi(formatRouteRules(routeRules)).split('\n') + + expect(lines.at(-2)).toBe('/page-19 prerender') + expect(lines.at(-1)).toBe('+3 more routes') + }) + + it('prints nothing when only Nuxt\'s own rules are set', () => { + expect(formatRouteRules({ '/__nuxt_error': { cache: false } })).toBe('') + expect(formatRouteRules(undefined)).toBe('') + }) +})