Skip to content

Commit 5663802

Browse files
committed
Introduce next analyze: a built-in bundle analyzer for Turbopack
1 parent 52469f6 commit 5663802

File tree

16 files changed

+757
-186
lines changed

16 files changed

+757
-186
lines changed

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

0 commit comments

Comments
 (0)