Skip to content

Commit f41cdfe

Browse files
committed
Introduce next analyze: a built-in bundle analyzer for Turbopack
1 parent b681bc9 commit f41cdfe

File tree

16 files changed

+786
-165
lines changed

16 files changed

+786
-165
lines changed

packages/next/errors.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -924,5 +924,7 @@
924924
"923": "%s is being parsed as a normalized route, but it has a route group or parallel route segment.",
925925
"924": "Invalid interception route: %s",
926926
"925": "You cannot define a route with the same specificity as an optional catch-all route (\"%s\" and \"/[[...%s]]\").",
927-
"926": "Optional route parameters are not yet supported (\"[%s]\") in route \"%s\"."
927+
"926": "Optional route parameters are not yet supported (\"[%s]\") in route \"%s\".",
928+
"927": "Unable to get server address",
929+
"928": "No pages or app directory found."
928930
}

packages/next/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@
210210
"@types/react-is": "18.2.4",
211211
"@types/semver": "7.3.1",
212212
"@types/send": "0.14.4",
213+
"@types/serve-handler": "6.1.4",
213214
"@types/shell-quote": "1.7.1",
214215
"@types/tar": "6.1.5",
215216
"@types/text-table": "0.2.1",
@@ -315,6 +316,7 @@
315316
"schema-utils3": "npm:[email protected]",
316317
"semver": "7.3.2",
317318
"send": "0.18.0",
319+
"serve-handler": "6.1.6",
318320
"server-only": "0.0.1",
319321
"setimmediate": "1.0.5",
320322
"shell-quote": "1.7.3",

packages/next/src/bin/next.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type { NextTelemetryOptions } from '../cli/next-telemetry.js'
2727
import type { NextStartOptions } from '../cli/next-start.js'
2828
import type { NextInfoOptions } from '../cli/next-info.js'
2929
import type { NextDevOptions } from '../cli/next-dev.js'
30+
import type { NextAnalyzeOptions } from '../cli/next-analyze.js'
3031
import type { NextBuildOptions } from '../cli/next-build.js'
3132
import type { NextTypegenOptions } from '../cli/next-typegen.js'
3233

@@ -198,6 +199,40 @@ program
198199
})
199200
.usage('[directory] [options]')
200201

202+
program
203+
.command('experimental-analyze')
204+
.description(
205+
'Analyze bundle output. Does not produce build artifacts. Only compatible with Turbopack.'
206+
)
207+
.argument(
208+
'[directory]',
209+
`A directory on which to analyze the application. ${italic(
210+
'If no directory is provided, the current directory will be used.'
211+
)}`
212+
)
213+
.option('--no-mangling', 'Disables mangling.')
214+
.option('--profile', 'Enables production profiling for React.')
215+
.option('--serve', 'Serve the bundle analyzer in a browser after analysis.')
216+
.addOption(
217+
new Option(
218+
'--port <port>',
219+
'Specify a port number to serve the analyzer on.'
220+
)
221+
.implies({ serve: true })
222+
.default(process.env.PORT ? parseInt(process.env.PORT, 10) : 4000)
223+
)
224+
.action((directory: string, options: NextAnalyzeOptions) => {
225+
return import('../cli/next-analyze.js')
226+
.then((mod) => mod.nextAnalyze(options, directory))
227+
.then(() => {
228+
if (!options.serve) {
229+
// The Next.js process is held open by something on the event loop. Exit manually like the `build` command does.
230+
// TODO: Fix the underlying issue so this is not necessary.
231+
process.exit(0)
232+
}
233+
})
234+
})
235+
201236
program
202237
.command('dev', { isDefault: true })
203238
.description(
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import type { NextConfigComplete } from '../../server/config-shared'
2+
import type { __ApiPreviewProps } from '../../server/api-utils'
3+
4+
import { setGlobal } from '../../trace'
5+
import * as Log from '../output/log'
6+
import * as path from 'node:path'
7+
import loadConfig from '../../server/config'
8+
import { PHASE_ANALYZE } from '../../shared/lib/constants'
9+
import { turbopackAnalyze, type AnalyzeContext } from '../turbopack-analyze'
10+
import { durationToString } from '../duration-to-string'
11+
import { cp, writeFile, mkdir } from 'node:fs/promises'
12+
import {
13+
collectAppFiles,
14+
collectPagesFiles,
15+
createPagesMapping,
16+
} from '../entries'
17+
import { createValidFileMatcher } from '../../server/lib/find-page-file'
18+
import { findPagesDir } from '../../lib/find-pages-dir'
19+
import { PAGE_TYPES } from '../../lib/page-types'
20+
import loadCustomRoutes from '../../lib/load-custom-routes'
21+
import { generateRoutesManifest } from '../generate-routes-manifest'
22+
import { checkIsAppPPREnabled } from '../../server/lib/experimental/ppr'
23+
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
24+
import http from 'node:http'
25+
26+
// @ts-expect-error types are in @types/serve-handler
27+
import serveHandler from 'next/dist/compiled/serve-handler'
28+
import { Telemetry } from '../../telemetry/storage'
29+
import { eventAnalyzeCompleted } from '../../telemetry/events'
30+
import { traceGlobals } from '../../trace/shared'
31+
import type { RoutesManifest } from '..'
32+
33+
const ANALYZE_PATH = '.next/diagnostics/analyze'
34+
35+
export type AnalyzeOptions = {
36+
dir: string
37+
reactProductionProfiling?: boolean
38+
noMangling?: boolean
39+
appDirOnly?: boolean
40+
serve?: boolean
41+
port?: number
42+
}
43+
44+
export default async function analyze({
45+
dir,
46+
reactProductionProfiling = false,
47+
noMangling = false,
48+
appDirOnly = false,
49+
serve = false,
50+
port = 4000,
51+
}: AnalyzeOptions): Promise<void> {
52+
try {
53+
const config: NextConfigComplete = await loadConfig(PHASE_ANALYZE, dir, {
54+
silent: false,
55+
reactProductionProfiling,
56+
})
57+
58+
process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || ''
59+
60+
const distDir = path.join(dir, '.next')
61+
const telemetry = new Telemetry({ distDir })
62+
setGlobal('phase', PHASE_ANALYZE)
63+
setGlobal('distDir', distDir)
64+
setGlobal('telemetry', telemetry)
65+
66+
Log.info('Analyzing a production build...')
67+
68+
const analyzeContext: AnalyzeContext = {
69+
config,
70+
dir,
71+
distDir,
72+
noMangling,
73+
appDirOnly,
74+
}
75+
76+
const { duration: analyzeDuration, shutdownPromise } =
77+
await turbopackAnalyze(analyzeContext)
78+
79+
const durationString = durationToString(analyzeDuration)
80+
Log.event(
81+
`Analyze data created successfully in ${durationString}. To explore it, run \`next experimental-analyze --serve\`.`
82+
)
83+
84+
await shutdownPromise
85+
86+
await cp(
87+
path.join(__dirname, '../../bundle-analyzer'),
88+
path.join(dir, ANALYZE_PATH),
89+
{ recursive: true }
90+
)
91+
92+
// Collect and write routes for the bundle analyzer
93+
const routes = await collectRoutesForAnalyze(dir, config, appDirOnly)
94+
95+
await mkdir(path.join(dir, ANALYZE_PATH, 'data'), { recursive: true })
96+
await writeFile(
97+
path.join(dir, ANALYZE_PATH, 'data', 'routes.json'),
98+
JSON.stringify(routes, null, 2)
99+
)
100+
101+
telemetry.record(
102+
eventAnalyzeCompleted({
103+
success: true,
104+
durationInSeconds: Math.round(analyzeDuration),
105+
totalPageCount: routes.length,
106+
})
107+
)
108+
109+
if (serve) {
110+
await startServer(path.join(dir, ANALYZE_PATH), port)
111+
}
112+
} catch (e) {
113+
const telemetry = traceGlobals.get('telemetry') as Telemetry | undefined
114+
if (telemetry) {
115+
telemetry.record(
116+
eventAnalyzeCompleted({
117+
success: false,
118+
})
119+
)
120+
}
121+
122+
throw e
123+
}
124+
}
125+
126+
/**
127+
* Collects all routes from the project for the bundle analyzer.
128+
* Returns a list of route paths (both static and dynamic).
129+
*/
130+
async function collectRoutesForAnalyze(
131+
dir: string,
132+
config: NextConfigComplete,
133+
appDirOnly: boolean
134+
): Promise<string[]> {
135+
const { pagesDir, appDir } = findPagesDir(dir)
136+
const validFileMatcher = createValidFileMatcher(config.pageExtensions, appDir)
137+
138+
let appType: RoutesManifest['appType']
139+
if (pagesDir && appDir) {
140+
appType = 'hybrid'
141+
} else if (pagesDir) {
142+
appType = 'pages'
143+
} else if (appDir) {
144+
appType = 'app'
145+
} else {
146+
throw new Error('No pages or app directory found.')
147+
}
148+
149+
const { appPaths } = appDir
150+
? await collectAppFiles(appDir, validFileMatcher)
151+
: { appPaths: [] }
152+
const pagesPaths = pagesDir
153+
? await collectPagesFiles(pagesDir, validFileMatcher)
154+
: null
155+
156+
const appMapping = await createPagesMapping({
157+
pagePaths: appPaths,
158+
isDev: false,
159+
pagesType: PAGE_TYPES.APP,
160+
pageExtensions: config.pageExtensions,
161+
pagesDir,
162+
appDir,
163+
appDirOnly,
164+
})
165+
166+
const pagesMapping = pagesPaths
167+
? await createPagesMapping({
168+
pagePaths: pagesPaths,
169+
isDev: false,
170+
pagesType: PAGE_TYPES.PAGES,
171+
pageExtensions: config.pageExtensions,
172+
pagesDir,
173+
appDir,
174+
appDirOnly,
175+
})
176+
: null
177+
178+
const pageKeys = {
179+
pages: pagesMapping ? Object.keys(pagesMapping) : [],
180+
app: appMapping
181+
? Object.keys(appMapping).map((key) => normalizeAppPath(key))
182+
: undefined,
183+
}
184+
185+
// Load custom routes
186+
const { redirects, headers, rewrites } = await loadCustomRoutes(config)
187+
188+
// Compute restricted redirect paths
189+
const restrictedRedirectPaths = ['/_next'].map((pathPrefix) =>
190+
config.basePath ? `${config.basePath}${pathPrefix}` : pathPrefix
191+
)
192+
193+
const isAppPPREnabled = checkIsAppPPREnabled(config.experimental.ppr)
194+
195+
// Generate routes manifest
196+
const { routesManifest } = generateRoutesManifest({
197+
appType,
198+
pageKeys,
199+
config,
200+
redirects,
201+
headers,
202+
rewrites,
203+
restrictedRedirectPaths,
204+
isAppPPREnabled,
205+
})
206+
207+
return routesManifest.dynamicRoutes
208+
.map((r) => r.page)
209+
.concat(routesManifest.staticRoutes.map((r) => r.page))
210+
}
211+
212+
function startServer(dir: string, port: number): Promise<void> {
213+
const server = http.createServer((req, res) => {
214+
return serveHandler(req, res, {
215+
public: dir,
216+
})
217+
})
218+
219+
return new Promise((resolve, reject) => {
220+
function onError(err: Error) {
221+
server.close(() => {
222+
reject(err)
223+
})
224+
}
225+
226+
server.on('error', onError)
227+
228+
server.listen(port, () => {
229+
const address = server.address()
230+
if (address == null) {
231+
reject(new Error('Unable to get server address'))
232+
return
233+
}
234+
235+
// No longer needed after startup
236+
server.removeListener('error', onError)
237+
238+
let addressString
239+
if (typeof address === 'string') {
240+
addressString = address
241+
} else {
242+
addressString = `${address.address === '::' ? 'localhost' : address.address}:${address.port}`
243+
}
244+
245+
Log.info(`Bundle analyzer available at http://${addressString}`)
246+
resolve()
247+
})
248+
})
249+
}

0 commit comments

Comments
 (0)