Skip to content

Commit d2ac68e

Browse files
committed
Introduce next analyze: a built-in bundle analyzer for Turbopack
1 parent 7d22e12 commit d2ac68e

File tree

16 files changed

+761
-165
lines changed

16 files changed

+761
-165
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -907,5 +907,6 @@
907907
"906": "Bindings not loaded yet, but they are being loaded, did you forget to await?",
908908
"907": "bindings not loaded yet. Either call `loadBindings` to wait for them to be available or ensure that `installBindings` has already been called.",
909909
"908": "Invalid flags should be run as node detached-flush dev ./path-to/project [eventsFile]",
910-
"909": "Failed to load SWC binary for %s/%s, see more info here: https://nextjs.org/docs/messages/failed-loading-swc"
910+
"909": "Failed to load SWC binary for %s/%s, see more info here: https://nextjs.org/docs/messages/failed-loading-swc",
911+
"910": "Unable to get server address"
911912
}

packages/next/package.json

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

packages/next/src/bin/next.ts

Lines changed: 29 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,34 @@ 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').then((mod) =>
226+
mod.nextAnalyze(options, directory)
227+
)
228+
})
229+
201230
program
202231
.command('dev', { isDefault: true })
203232
.description(
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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+
32+
const ANALYZE_PATH = '.next/diagnostics/analyze'
33+
34+
export type AnalyzeOptions = {
35+
dir: string
36+
reactProductionProfiling?: boolean
37+
noMangling?: boolean
38+
appDirOnly?: boolean
39+
serve?: boolean
40+
port?: number
41+
}
42+
43+
export default async function analyze({
44+
dir,
45+
reactProductionProfiling = false,
46+
noMangling = false,
47+
appDirOnly = false,
48+
serve = false,
49+
port = 4000,
50+
}: AnalyzeOptions): Promise<void> {
51+
try {
52+
const config: NextConfigComplete = await loadConfig(PHASE_ANALYZE, dir, {
53+
silent: false,
54+
reactProductionProfiling,
55+
})
56+
57+
process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || ''
58+
59+
const distDir = path.join(dir, '.next')
60+
const telemetry = new Telemetry({ distDir })
61+
setGlobal('phase', PHASE_ANALYZE)
62+
setGlobal('distDir', distDir)
63+
setGlobal('telemetry', telemetry)
64+
65+
Log.info('Analyzing a production build...')
66+
67+
const analyzeContext: AnalyzeContext = {
68+
config,
69+
dir,
70+
distDir,
71+
noMangling,
72+
appDirOnly,
73+
}
74+
75+
const { duration: analyzeDuration, shutdownPromise } =
76+
await turbopackAnalyze(analyzeContext)
77+
78+
const durationString = durationToString(analyzeDuration)
79+
Log.event(`Compiled successfully in ${durationString}`)
80+
81+
await shutdownPromise
82+
83+
await cp(
84+
path.join(__dirname, '../../bundle-analyzer'),
85+
path.join(dir, ANALYZE_PATH),
86+
{ recursive: true }
87+
)
88+
89+
// Collect and write routes for the bundle analyzer
90+
const routes = await collectRoutesForAnalyze(dir, config, appDirOnly)
91+
92+
await mkdir(path.join(dir, ANALYZE_PATH, 'data'), { recursive: true })
93+
await writeFile(
94+
path.join(dir, ANALYZE_PATH, 'data', 'routes.json'),
95+
JSON.stringify(routes, null, 2)
96+
)
97+
98+
telemetry.record(
99+
eventAnalyzeCompleted({
100+
success: true,
101+
durationInSeconds: Math.round(analyzeDuration),
102+
totalPageCount: routes.length,
103+
})
104+
)
105+
106+
if (serve) {
107+
await startServer(path.join(dir, ANALYZE_PATH), port)
108+
}
109+
} catch (e) {
110+
const telemetry = traceGlobals.get('telemetry') as Telemetry | undefined
111+
if (telemetry) {
112+
telemetry.record(
113+
eventAnalyzeCompleted({
114+
success: false,
115+
})
116+
)
117+
}
118+
119+
throw e
120+
}
121+
}
122+
123+
/**
124+
* Collects all routes from the project for the bundle analyzer.
125+
* Returns a list of route paths (both static and dynamic).
126+
*/
127+
async function collectRoutesForAnalyze(
128+
dir: string,
129+
config: NextConfigComplete,
130+
appDirOnly: boolean
131+
): Promise<string[]> {
132+
const { pagesDir, appDir } = findPagesDir(dir)
133+
const validFileMatcher = createValidFileMatcher(config.pageExtensions, appDir)
134+
135+
const { appPaths } = appDir
136+
? await collectAppFiles(appDir, validFileMatcher)
137+
: { appPaths: [] }
138+
const pagesPaths = pagesDir
139+
? await collectPagesFiles(pagesDir, validFileMatcher)
140+
: null
141+
142+
const appMapping = await createPagesMapping({
143+
pagePaths: appPaths,
144+
isDev: false,
145+
pagesType: PAGE_TYPES.APP,
146+
pageExtensions: config.pageExtensions,
147+
pagesDir,
148+
appDir,
149+
appDirOnly,
150+
})
151+
152+
const pagesMapping = pagesPaths
153+
? await createPagesMapping({
154+
pagePaths: pagesPaths,
155+
isDev: false,
156+
pagesType: PAGE_TYPES.PAGES,
157+
pageExtensions: config.pageExtensions,
158+
pagesDir,
159+
appDir,
160+
appDirOnly,
161+
})
162+
: null
163+
164+
const pageKeys = {
165+
pages: pagesMapping ? Object.keys(pagesMapping) : [],
166+
app: appMapping
167+
? Object.keys(appMapping).map((key) => normalizeAppPath(key))
168+
: undefined,
169+
}
170+
171+
// Load custom routes
172+
const { redirects, headers, rewrites } = await loadCustomRoutes(config)
173+
174+
// Compute restricted redirect paths
175+
const restrictedRedirectPaths = ['/_next'].map((pathPrefix) =>
176+
config.basePath ? `${config.basePath}${pathPrefix}` : pathPrefix
177+
)
178+
179+
const isAppPPREnabled = checkIsAppPPREnabled(config.experimental.ppr)
180+
181+
// Generate routes manifest
182+
const { routesManifest } = generateRoutesManifest({
183+
pageKeys,
184+
config,
185+
redirects,
186+
headers,
187+
rewrites,
188+
restrictedRedirectPaths,
189+
isAppPPREnabled,
190+
})
191+
192+
return routesManifest.dynamicRoutes
193+
.map((r) => r.page)
194+
.concat(routesManifest.staticRoutes.map((r) => r.page))
195+
}
196+
197+
function startServer(dir: string, port: number): Promise<void> {
198+
const server = http.createServer((req, res) => {
199+
return serveHandler(req, res, {
200+
public: dir,
201+
})
202+
})
203+
204+
return new Promise((resolve, reject) => {
205+
function onError(err: Error) {
206+
server.close(() => {
207+
reject(err)
208+
})
209+
}
210+
211+
server.on('error', onError)
212+
213+
server.listen(port, () => {
214+
const address = server.address()
215+
if (address == null) {
216+
reject(new Error('Unable to get server address'))
217+
return
218+
}
219+
220+
// No longer needed after startup
221+
server.removeListener('error', onError)
222+
223+
let addressString
224+
if (typeof address === 'string') {
225+
addressString = address
226+
} else {
227+
addressString = `${address.address === '::' ? 'localhost' : address.address}:${address.port}`
228+
}
229+
230+
Log.info(`Bundle analyzer available at http://${addressString}`)
231+
resolve()
232+
})
233+
})
234+
}

0 commit comments

Comments
 (0)