mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
cli: build partial entries --debug-build-paths arg (#85052)
Add `--debug-build-paths` CLI option to next build for selective route building. This enables building only specific routes for faster development and debugging. ### Changes - Add `--debug-build-paths=<patterns>` CLI option - Support comma-separated paths and glob patterns - Filter collected routes after normal file system scanning - Works with both app router and pages router ### Usage #### Build specific routes ```sh next build --debug-build-paths="app/page.tsx,app/dashboard/page.tsx" next build --debug-build-paths="pages/index.tsx,pages/about.tsx" ``` #### Use glob patterns ```sh next build --debug-build-paths "app/**/page.tsx" next build --debug-build-paths "pages/*.tsx" ``` #### Mix both routers ``` next build --debug-build-paths "app/page.tsx,pages/index.tsx" ```
This commit is contained in:
@@ -888,5 +888,7 @@
|
||||
"887": "`cacheLife()` is only available with the `cacheComponents` config.",
|
||||
"888": "Unknown \\`cacheLife()\\` profile \"%s\" is not configured in next.config.js\\nmodule.exports = {\n cacheLife: {\n \"%s\": ...\\n }\n}",
|
||||
"889": "Unknown \\`cacheLife()\\` profile \"%s\" is not configured in next.config.js\\nmodule.exports = {\n cacheLife: {\n \"%s\": ...\\n }\n}",
|
||||
"890": "Received an underlying cookies object that does not match either `cookies` or `mutableCookies`"
|
||||
"890": "Received an underlying cookies object that does not match either `cookies` or `mutableCookies`",
|
||||
"891": "Failed to read build paths file \"%s\": %s",
|
||||
"892": "Failed to resolve glob pattern \"%s\": %s"
|
||||
}
|
||||
|
||||
@@ -154,6 +154,10 @@ program
|
||||
'--experimental-next-config-strip-types',
|
||||
'Use Node.js native TypeScript resolution for next.config.(ts|mts)'
|
||||
)
|
||||
.option(
|
||||
'--debug-build-paths <patterns>',
|
||||
'Comma-separated glob patterns or explicit paths for selective builds. Examples: "app/*", "app/page.tsx", "app/**/page.tsx"'
|
||||
)
|
||||
.action((directory: string, options: NextBuildOptions) => {
|
||||
if (options.experimentalNextConfigStripTypes) {
|
||||
process.env.__NEXT_NODE_NATIVE_TS_LOADER_ENABLED = 'true'
|
||||
|
||||
@@ -910,7 +910,9 @@ export default async function build(
|
||||
appDirOnly = false,
|
||||
bundler = Bundler.Turbopack,
|
||||
experimentalBuildMode: 'default' | 'compile' | 'generate' | 'generate-env',
|
||||
traceUploadUrl: string | undefined
|
||||
traceUploadUrl: string | undefined,
|
||||
debugBuildAppPaths?: string[],
|
||||
debugBuildPagePaths?: string[]
|
||||
): Promise<void> {
|
||||
const isCompileMode = experimentalBuildMode === 'compile'
|
||||
const isGenerateMode = experimentalBuildMode === 'generate'
|
||||
@@ -1166,6 +1168,20 @@ export default async function build(
|
||||
.traceAsyncFn(() => collectPagesFiles(pagesDir, validFileMatcher))
|
||||
: []
|
||||
|
||||
// Apply debug build paths filter if specified
|
||||
// If debugBuildPagePaths is defined (even if empty), only build specified pages
|
||||
if (debugBuildPagePaths !== undefined) {
|
||||
if (debugBuildPagePaths.length > 0) {
|
||||
const debugPathsSet = new Set(debugBuildPagePaths)
|
||||
pagesPaths = pagesPaths.filter((pagePath) =>
|
||||
debugPathsSet.has(pagePath)
|
||||
)
|
||||
} else {
|
||||
// Empty array means build no pages
|
||||
pagesPaths = []
|
||||
}
|
||||
}
|
||||
|
||||
const middlewareDetectionRegExp = new RegExp(
|
||||
`^${MIDDLEWARE_FILENAME}\\.(?:${config.pageExtensions.join('|')})$`
|
||||
)
|
||||
@@ -1275,7 +1291,7 @@ export default async function build(
|
||||
let layoutPaths: string[]
|
||||
|
||||
if (Boolean(process.env.NEXT_PRIVATE_APP_PATHS)) {
|
||||
// used for testing?
|
||||
// used for testing
|
||||
appPaths = providedAppPaths
|
||||
layoutPaths = []
|
||||
} else {
|
||||
@@ -1286,6 +1302,20 @@ export default async function build(
|
||||
|
||||
appPaths = result.appPaths
|
||||
layoutPaths = result.layoutPaths
|
||||
|
||||
// Apply debug build paths filter if specified
|
||||
// If debugBuildAppPaths is defined (even if empty), only build specified app paths
|
||||
if (debugBuildAppPaths !== undefined) {
|
||||
if (debugBuildAppPaths.length > 0) {
|
||||
const debugPathsSet = new Set(debugBuildAppPaths)
|
||||
appPaths = appPaths.filter((appPath) =>
|
||||
debugPathsSet.has(appPath)
|
||||
)
|
||||
} else {
|
||||
// Empty array means build no app paths
|
||||
appPaths = []
|
||||
}
|
||||
}
|
||||
// Note: defaultPaths are not used in the build process, only for slot detection in generating route types
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import { getProjectDir } from '../lib/get-project-dir'
|
||||
import { enableMemoryDebuggingMode } from '../lib/memory/startup'
|
||||
import { disableMemoryDebuggingMode } from '../lib/memory/shutdown'
|
||||
import { parseBundlerArgs } from '../lib/bundler'
|
||||
import {
|
||||
resolveBuildPaths,
|
||||
parseBuildPathsInput,
|
||||
} from '../lib/resolve-build-paths'
|
||||
|
||||
export type NextBuildOptions = {
|
||||
debug?: boolean
|
||||
@@ -26,9 +30,10 @@ export type NextBuildOptions = {
|
||||
experimentalBuildMode: 'default' | 'compile' | 'generate' | 'generate-env'
|
||||
experimentalUploadTrace?: string
|
||||
experimentalNextConfigStripTypes?: boolean
|
||||
debugBuildPaths?: string
|
||||
}
|
||||
|
||||
const nextBuild = (options: NextBuildOptions, directory?: string) => {
|
||||
const nextBuild = async (options: NextBuildOptions, directory?: string) => {
|
||||
process.on('SIGTERM', () => process.exit(143))
|
||||
process.on('SIGINT', () => process.exit(130))
|
||||
|
||||
@@ -41,6 +46,7 @@ const nextBuild = (options: NextBuildOptions, directory?: string) => {
|
||||
experimentalAppOnly,
|
||||
experimentalBuildMode,
|
||||
experimentalUploadTrace,
|
||||
debugBuildPaths,
|
||||
} = options
|
||||
|
||||
let traceUploadUrl: string | undefined
|
||||
@@ -81,6 +87,27 @@ const nextBuild = (options: NextBuildOptions, directory?: string) => {
|
||||
|
||||
const bundler = parseBundlerArgs(options)
|
||||
|
||||
// Resolve selective build paths
|
||||
let resolvedAppPaths: string[] | undefined
|
||||
let resolvedPagePaths: string[] | undefined
|
||||
|
||||
if (debugBuildPaths) {
|
||||
try {
|
||||
const patterns = parseBuildPathsInput(debugBuildPaths)
|
||||
|
||||
if (patterns.length > 0) {
|
||||
const resolved = await resolveBuildPaths(patterns, dir)
|
||||
// Pass empty arrays to indicate "build nothing" vs undefined for "build everything"
|
||||
resolvedAppPaths = resolved.appPaths
|
||||
resolvedPagePaths = resolved.pagePaths
|
||||
}
|
||||
} catch (err) {
|
||||
printAndExit(
|
||||
`Failed to resolve build paths: ${isError(err) ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return build(
|
||||
dir,
|
||||
profile,
|
||||
@@ -90,7 +117,9 @@ const nextBuild = (options: NextBuildOptions, directory?: string) => {
|
||||
experimentalAppOnly,
|
||||
bundler,
|
||||
experimentalBuildMode,
|
||||
traceUploadUrl
|
||||
traceUploadUrl,
|
||||
resolvedAppPaths,
|
||||
resolvedPagePaths
|
||||
)
|
||||
.catch((err) => {
|
||||
if (experimentalDebugMemoryUsage) {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { promisify } from 'util'
|
||||
import globOriginal from 'next/dist/compiled/glob'
|
||||
import * as Log from '../build/output/log'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import isError from './is-error'
|
||||
|
||||
const glob = promisify(globOriginal)
|
||||
|
||||
interface ResolvedBuildPaths {
|
||||
appPaths: string[]
|
||||
pagePaths: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves glob patterns and explicit paths to actual file paths
|
||||
* Categorizes them into App Router and Pages Router paths
|
||||
*
|
||||
* @param patterns - Array of glob patterns or explicit paths
|
||||
* @param projectDir - Root project directory
|
||||
* @returns Object with categorized app and page paths
|
||||
*/
|
||||
export async function resolveBuildPaths(
|
||||
patterns: string[],
|
||||
projectDir: string
|
||||
): Promise<ResolvedBuildPaths> {
|
||||
const appPaths: Set<string> = new Set()
|
||||
const pagePaths: Set<string> = new Set()
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const trimmed = pattern.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect if pattern is glob pattern (contains glob special chars)
|
||||
const isGlobPattern = /[*?[\]{}!]/.test(trimmed)
|
||||
|
||||
if (isGlobPattern) {
|
||||
try {
|
||||
// Resolve glob pattern
|
||||
const matches = (await glob(trimmed, {
|
||||
cwd: projectDir,
|
||||
})) as string[]
|
||||
|
||||
if (matches.length === 0) {
|
||||
Log.warn(`Glob pattern "${trimmed}" did not match any files`)
|
||||
}
|
||||
|
||||
for (const file of matches) {
|
||||
// Skip directories, only process files
|
||||
if (!fs.statSync(path.join(projectDir, file)).isDirectory()) {
|
||||
categorizeAndAddPath(file, appPaths, pagePaths)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to resolve glob pattern "${trimmed}": ${
|
||||
isError(error) ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Explicit path - categorize based on prefix
|
||||
categorizeAndAddPath(trimmed, appPaths, pagePaths, projectDir)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
appPaths: Array.from(appPaths).sort(),
|
||||
pagePaths: Array.from(pagePaths).sort(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorizes a file path to either app or pages router based on its prefix,
|
||||
* and normalizes it to the format expected by Next.js internal build system.
|
||||
*
|
||||
* The internal build system expects:
|
||||
* - App router: paths with leading slash (e.g., "/page.tsx", "/dashboard/page.tsx")
|
||||
* - Pages router: paths with leading slash (e.g., "/index.tsx", "/about.tsx")
|
||||
*
|
||||
* Examples:
|
||||
* - "app/page.tsx" → appPaths.add("/page.tsx")
|
||||
* - "app/dashboard/page.tsx" → appPaths.add("/dashboard/page.tsx")
|
||||
* - "pages/index.tsx" → pagePaths.add("/index.tsx")
|
||||
* - "pages/about.tsx" → pagePaths.add("/about.tsx")
|
||||
* - "/page.tsx" → appPaths.add("/page.tsx") (already in app router format)
|
||||
*/
|
||||
function categorizeAndAddPath(
|
||||
filePath: string,
|
||||
appPaths: Set<string>,
|
||||
pagePaths: Set<string>,
|
||||
projectDir?: string
|
||||
): void {
|
||||
// Normalize path separators to forward slashes (Windows compatibility)
|
||||
const normalized = filePath.replace(/\\/g, '/')
|
||||
|
||||
// Skip non-file entries (like directories without extensions)
|
||||
if (normalized.endsWith('/')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (normalized.startsWith('app/')) {
|
||||
// App router path: remove 'app/' prefix and ensure leading slash
|
||||
// "app/page.tsx" → "/page.tsx"
|
||||
// "app/dashboard/page.tsx" → "/dashboard/page.tsx"
|
||||
const withoutPrefix = normalized.slice(4) // Remove "app/"
|
||||
appPaths.add('/' + withoutPrefix)
|
||||
} else if (normalized.startsWith('pages/')) {
|
||||
// Pages router path: remove 'pages/' prefix and add leading slash
|
||||
// "pages/index.tsx" → "/index.tsx"
|
||||
// "pages/about.tsx" → "/about.tsx"
|
||||
const withoutPrefix = normalized.slice(6) // Remove "pages/"
|
||||
pagePaths.add('/' + withoutPrefix)
|
||||
} else if (normalized.startsWith('/')) {
|
||||
// Leading slash suggests app router format (already in correct format)
|
||||
// "/page.tsx" → "/page.tsx" (no change needed)
|
||||
appPaths.add(normalized)
|
||||
} else {
|
||||
// No obvious prefix - try to detect based on file existence
|
||||
if (projectDir) {
|
||||
const appPath = path.join(projectDir, 'app', normalized)
|
||||
const pagesPath = path.join(projectDir, 'pages', normalized)
|
||||
|
||||
if (fs.existsSync(appPath)) {
|
||||
appPaths.add('/' + normalized)
|
||||
} else if (fs.existsSync(pagesPath)) {
|
||||
pagePaths.add('/' + normalized)
|
||||
} else {
|
||||
// Default to pages router for paths without clear indicator
|
||||
pagePaths.add('/' + normalized)
|
||||
}
|
||||
} else {
|
||||
// Without projectDir context, default to pages router
|
||||
pagePaths.add('/' + normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse build paths from comma-separated format
|
||||
* Supports:
|
||||
* - Comma-separated values: "app/page.tsx,app/about/page.tsx"
|
||||
*
|
||||
* @param input - String input to parse
|
||||
* @returns Array of path patterns
|
||||
*/
|
||||
export function parseBuildPathsInput(input: string): string[] {
|
||||
// Comma-separated values
|
||||
return input
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function About() {
|
||||
return <h1>App About</h1>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Dashboard() {
|
||||
return <h1>App Dashboard</h1>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Debug Build Paths Test',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Home() {
|
||||
return <h1>App Home</h1>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { nextTestSetup } from 'e2e-utils'
|
||||
|
||||
describe('debug-build-paths', () => {
|
||||
const { next, skipped } = nextTestSetup({
|
||||
files: __dirname,
|
||||
skipDeployment: true,
|
||||
skipStart: true,
|
||||
})
|
||||
|
||||
if (skipped) return
|
||||
|
||||
describe('explicit path formats', () => {
|
||||
it('should build single page with pages/ prefix', async () => {
|
||||
const buildResult = await next.build({
|
||||
args: ['--debug-build-paths', 'pages/foo.tsx'],
|
||||
})
|
||||
expect(buildResult.exitCode).toBe(0)
|
||||
expect(buildResult.cliOutput).toBeDefined()
|
||||
|
||||
// Should only build the specified page
|
||||
expect(buildResult.cliOutput).toContain('Route (pages)')
|
||||
expect(buildResult.cliOutput).toContain('○ /foo')
|
||||
// Should not build other pages
|
||||
expect(buildResult.cliOutput).not.toContain('○ /bar')
|
||||
// Should not build app routes
|
||||
expect(buildResult.cliOutput).not.toContain('Route (app)')
|
||||
})
|
||||
|
||||
it('should build multiple pages routes', async () => {
|
||||
const buildResult = await next.build({
|
||||
args: ['--debug-build-paths', 'pages/foo.tsx,pages/bar.tsx'],
|
||||
})
|
||||
expect(buildResult.exitCode).toBe(0)
|
||||
expect(buildResult.cliOutput).toBeDefined()
|
||||
|
||||
// Should build both specified pages
|
||||
expect(buildResult.cliOutput).toContain('Route (pages)')
|
||||
expect(buildResult.cliOutput).toContain('○ /foo')
|
||||
expect(buildResult.cliOutput).toContain('○ /bar')
|
||||
// Should not build app routes
|
||||
expect(buildResult.cliOutput).not.toContain('Route (app)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('glob pattern matching', () => {
|
||||
it('should match app and pages routes with glob patterns', async () => {
|
||||
const buildResult = await next.build({
|
||||
args: ['--debug-build-paths', 'pages/*.tsx,app/page.tsx'],
|
||||
})
|
||||
expect(buildResult.exitCode).toBe(0)
|
||||
expect(buildResult.cliOutput).toBeDefined()
|
||||
|
||||
// Should build pages matching the glob
|
||||
expect(buildResult.cliOutput).toContain('Route (pages)')
|
||||
expect(buildResult.cliOutput).toContain('○ /foo')
|
||||
expect(buildResult.cliOutput).toContain('○ /bar')
|
||||
|
||||
// Should build the specified app route
|
||||
expect(buildResult.cliOutput).toContain('Route (app)')
|
||||
expect(buildResult.cliOutput).toContain('○ /')
|
||||
// Should not build other app routes
|
||||
expect(buildResult.cliOutput).not.toContain('○ /about')
|
||||
expect(buildResult.cliOutput).not.toContain('○ /dashboard')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
type ResponseData = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
) {
|
||||
res.status(200).json({ message: 'Hello from API' })
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <h1>Bar</h1>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Page() {
|
||||
return <h1>Foo</h1>
|
||||
}
|
||||
Reference in New Issue
Block a user