Skip to content
Merged
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
4 changes: 3 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -925,5 +925,7 @@
"924": "Invalid interception route: %s",
"925": "You cannot define a route with the same specificity as an optional catch-all route (\"%s\" and \"/[[...%s]]\").",
"926": "Optional route parameters are not yet supported (\"[%s]\") in route \"%s\".",
"927": "No debug targets found"
"927": "No debug targets found",
"928": "Unable to get server address",
"929": "No pages or app directory found."
}
2 changes: 2 additions & 0 deletions packages/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@
"@types/react-is": "18.2.4",
"@types/semver": "7.3.1",
"@types/send": "0.14.4",
"@types/serve-handler": "6.1.4",
"@types/shell-quote": "1.7.1",
"@types/tar": "6.1.5",
"@types/text-table": "0.2.1",
Expand Down Expand Up @@ -315,6 +316,7 @@
"schema-utils3": "npm:[email protected]",
"semver": "7.3.2",
"send": "0.18.0",
"serve-handler": "6.1.6",
"server-only": "0.0.1",
"setimmediate": "1.0.5",
"shell-quote": "1.7.3",
Expand Down
37 changes: 37 additions & 0 deletions packages/next/src/bin/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type { NextTelemetryOptions } from '../cli/next-telemetry.js'
import type { NextStartOptions } from '../cli/next-start.js'
import type { NextInfoOptions } from '../cli/next-info.js'
import type { NextDevOptions } from '../cli/next-dev.js'
import type { NextAnalyzeOptions } from '../cli/next-analyze.js'
import type { NextBuildOptions } from '../cli/next-build.js'
import type { NextTypegenOptions } from '../cli/next-typegen.js'

Expand Down Expand Up @@ -198,6 +199,42 @@ program
})
.usage('[directory] [options]')

program
Copy link
Member

Choose a reason for hiding this comment

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

Does it make sense to have a --no-build option in case you already built with all the right flags to emit the analyzer output?

Copy link
Member Author

@wbinnssmith wbinnssmith Nov 10, 2025

Choose a reason for hiding this comment

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

The problem with this is that would make next analyze --no-build a no-op without also doing next analyze --no-build --serve

Alternatively, we could do optional subcommands:

next analyze -- does both
next analyze build -- only does build
next analyze serve -- only does serve

Or similarly, we make next analyze default to both and have

next analyze -- does both
next analyze --no-serve -- only does build
next analyze --no-build -- only does serve

.command('experimental-analyze')
.description(
'Analyze bundle output. Does not produce build artifacts. Only compatible with Turbopack.'
)
.argument(
'[directory]',
`A directory on which to analyze the application. ${italic(
'If no directory is provided, the current directory will be used.'
)}`
)
.option('--no-mangling', 'Disables mangling.')
.option('--profile', 'Enables production profiling for React.')
.option('--serve', 'Serve the bundle analyzer in a browser after analysis.')
.addOption(
new Option(
'--port <port>',
'Specify a port number to serve the analyzer on.'
)
.implies({ serve: true })
.argParser(parseValidPositiveInteger)
.default(4000)
.env('PORT')
)
.action((directory: string, options: NextAnalyzeOptions) => {
return import('../cli/next-analyze.js')
.then((mod) => mod.nextAnalyze(options, directory))
.then(() => {
if (!options.serve) {
// The Next.js process is held open by something on the event loop. Exit manually like the `build` command does.
// TODO: Fix the underlying issue so this is not necessary.
process.exit(0)
}
})
})

program
.command('dev', { isDefault: true })
.description(
Expand Down
249 changes: 249 additions & 0 deletions packages/next/src/build/analyze/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import type { NextConfigComplete } from '../../server/config-shared'
import type { __ApiPreviewProps } from '../../server/api-utils'

import { setGlobal } from '../../trace'
import * as Log from '../output/log'
import * as path from 'node:path'
import loadConfig from '../../server/config'
import { PHASE_ANALYZE } from '../../shared/lib/constants'
import { turbopackAnalyze, type AnalyzeContext } from '../turbopack-analyze'
import { durationToString } from '../duration-to-string'
import { cp, writeFile, mkdir } from 'node:fs/promises'
import {
collectAppFiles,
collectPagesFiles,
createPagesMapping,
} from '../entries'
import { createValidFileMatcher } from '../../server/lib/find-page-file'
import { findPagesDir } from '../../lib/find-pages-dir'
import { PAGE_TYPES } from '../../lib/page-types'
import loadCustomRoutes from '../../lib/load-custom-routes'
import { generateRoutesManifest } from '../generate-routes-manifest'
import { checkIsAppPPREnabled } from '../../server/lib/experimental/ppr'
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
import http from 'node:http'

// @ts-expect-error types are in @types/serve-handler
import serveHandler from 'next/dist/compiled/serve-handler'
import { Telemetry } from '../../telemetry/storage'
import { eventAnalyzeCompleted } from '../../telemetry/events'
import { traceGlobals } from '../../trace/shared'
import type { RoutesManifest } from '..'

const ANALYZE_PATH = '.next/diagnostics/analyze'
Copy link
Member

Choose a reason for hiding this comment

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

Path needs to respect distDir from config


export type AnalyzeOptions = {
dir: string
reactProductionProfiling?: boolean
noMangling?: boolean
appDirOnly?: boolean
serve?: boolean
port?: number
}

export default async function analyze({
dir,
reactProductionProfiling = false,
noMangling = false,
appDirOnly = false,
serve = false,
port = 4000,
}: AnalyzeOptions): Promise<void> {
try {
const config: NextConfigComplete = await loadConfig(PHASE_ANALYZE, dir, {
silent: false,
reactProductionProfiling,
})

process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || ''

const distDir = path.join(dir, '.next')
const telemetry = new Telemetry({ distDir })
setGlobal('phase', PHASE_ANALYZE)
setGlobal('distDir', distDir)
setGlobal('telemetry', telemetry)

Log.info('Analyzing a production build...')

const analyzeContext: AnalyzeContext = {
config,
dir,
distDir,
noMangling,
appDirOnly,
}

const { duration: analyzeDuration, shutdownPromise } =
await turbopackAnalyze(analyzeContext)

const durationString = durationToString(analyzeDuration)
Log.event(
`Analyze data created successfully in ${durationString}. To explore it, run \`next experimental-analyze --serve\`.`
)

await shutdownPromise

await cp(
path.join(__dirname, '../../bundle-analyzer'),
path.join(dir, ANALYZE_PATH),
{ recursive: true }
)

// Collect and write routes for the bundle analyzer
const routes = await collectRoutesForAnalyze(dir, config, appDirOnly)

await mkdir(path.join(dir, ANALYZE_PATH, 'data'), { recursive: true })
await writeFile(
path.join(dir, ANALYZE_PATH, 'data', 'routes.json'),
JSON.stringify(routes, null, 2)
)

telemetry.record(
eventAnalyzeCompleted({
success: true,
durationInSeconds: Math.round(analyzeDuration),
totalPageCount: routes.length,
})
)

if (serve) {
await startServer(path.join(dir, ANALYZE_PATH), port)
}
} catch (e) {
const telemetry = traceGlobals.get('telemetry') as Telemetry | undefined
if (telemetry) {
telemetry.record(
eventAnalyzeCompleted({
success: false,
})
)
}

throw e
}
}

/**
* Collects all routes from the project for the bundle analyzer.
* Returns a list of route paths (both static and dynamic).
*/
async function collectRoutesForAnalyze(
dir: string,
config: NextConfigComplete,
appDirOnly: boolean
): Promise<string[]> {
const { pagesDir, appDir } = findPagesDir(dir)
const validFileMatcher = createValidFileMatcher(config.pageExtensions, appDir)

let appType: RoutesManifest['appType']
if (pagesDir && appDir) {
appType = 'hybrid'
} else if (pagesDir) {
appType = 'pages'
} else if (appDir) {
appType = 'app'
} else {
throw new Error('No pages or app directory found.')
}

const { appPaths } = appDir
? await collectAppFiles(appDir, validFileMatcher)
: { appPaths: [] }
const pagesPaths = pagesDir
? await collectPagesFiles(pagesDir, validFileMatcher)
: null

const appMapping = await createPagesMapping({
pagePaths: appPaths,
isDev: false,
pagesType: PAGE_TYPES.APP,
pageExtensions: config.pageExtensions,
pagesDir,
appDir,
appDirOnly,
})

const pagesMapping = pagesPaths
? await createPagesMapping({
pagePaths: pagesPaths,
isDev: false,
pagesType: PAGE_TYPES.PAGES,
pageExtensions: config.pageExtensions,
pagesDir,
appDir,
appDirOnly,
})
: null

const pageKeys = {
pages: pagesMapping ? Object.keys(pagesMapping) : [],
app: appMapping
? Object.keys(appMapping).map((key) => normalizeAppPath(key))
: undefined,
}

// Load custom routes
const { redirects, headers, rewrites } = await loadCustomRoutes(config)

// Compute restricted redirect paths
const restrictedRedirectPaths = ['/_next'].map((pathPrefix) =>
config.basePath ? `${config.basePath}${pathPrefix}` : pathPrefix
)

const isAppPPREnabled = checkIsAppPPREnabled(config.experimental.ppr)

// Generate routes manifest
const { routesManifest } = generateRoutesManifest({
appType,
pageKeys,
config,
redirects,
headers,
rewrites,
restrictedRedirectPaths,
isAppPPREnabled,
})

return routesManifest.dynamicRoutes
.map((r) => r.page)
.concat(routesManifest.staticRoutes.map((r) => r.page))
}

function startServer(dir: string, port: number): Promise<void> {
const server = http.createServer((req, res) => {
return serveHandler(req, res, {
public: dir,
})
})

return new Promise((resolve, reject) => {
function onError(err: Error) {
server.close(() => {
reject(err)
})
}

server.on('error', onError)

server.listen(port, 'localhost', () => {
const address = server.address()
if (address == null) {
reject(new Error('Unable to get server address'))
return
}

// No longer needed after startup
server.removeListener('error', onError)

let addressString
if (typeof address === 'string') {
addressString = address
} else {
addressString = `${address.address === '::' ? 'localhost' : address.address}:${address.port}`
}

Log.info(`Bundle analyzer available at http://${addressString}`)
resolve()
})
})
}
Loading
Loading