Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/nuxt-cli/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, object> } | undefined
nuxt.hook('nitro:init', (nitro) => {
nitroOptions = nitro.options
})

showBanner(nuxt)
await nuxt.ready()

Expand Down Expand Up @@ -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)
Expand Down
140 changes: 140 additions & 0 deletions packages/nuxt-cli/src/utils/route-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { styleText } from 'node:util'

import { truncate, visibleWidth } from './formatting'

type RouteRule = Record<string, any>

/** 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 `:_<index>` 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<string>): 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<string, object> | 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')
}
86 changes: 86 additions & 0 deletions packages/nuxt-cli/test/unit/utils/route-rules.spec.ts
Original file line number Diff line number Diff line change
@@ -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('')
})
})
Loading