Skip to content

Commit 2f31831

Browse files
feedthejimclaude
andcommitted
perf(dev): bundle dev server with webpack and add bytecode caching
- Bundle start-server.ts and dependencies with webpack for faster loading - Add V8 bytecode caching to cache compiled code between runs - Bytecode cache includes JIT warmup for optimal performance - Cache stored in .next/cache/bytecode/ with automatic invalidation This further reduces warm start time by ~50-100ms. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent af8a686 commit 2f31831

File tree

10 files changed

+713
-22
lines changed

10 files changed

+713
-22
lines changed
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/**
2+
* Webpack/Rspack config for bundling dev server startup modules.
3+
*
4+
* This bundles start-server.ts, router-server.ts, and their dependencies
5+
* into a single file to reduce module loading overhead during dev server boot.
6+
*/
7+
8+
/* eslint-disable import/no-extraneous-dependencies */
9+
const rspack = require('@rspack/core')
10+
const path = require('path')
11+
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
12+
const DevToolsIgnorePlugin =
13+
require('./dist/build/webpack/plugins/devtools-ignore-list-plugin').default
14+
/* eslint-enable import/no-extraneous-dependencies */
15+
16+
// Shared externals with the server runtime bundle
17+
const sharedExternals = [
18+
'styled-jsx',
19+
'styled-jsx/style',
20+
'@opentelemetry/api',
21+
'next/dist/compiled/@ampproject/toolbox-optimizer',
22+
'next/dist/compiled/edge-runtime',
23+
'next/dist/compiled/@edge-runtime/ponyfill',
24+
'next/dist/compiled/undici',
25+
'next/dist/compiled/raw-body',
26+
'next/dist/server/capsize-font-metrics.json',
27+
'critters',
28+
'next/dist/compiled/node-html-parser',
29+
'next/dist/compiled/compression',
30+
'next/dist/compiled/jsonwebtoken',
31+
'next/dist/compiled/@opentelemetry/api',
32+
'next/dist/compiled/@mswjs/interceptors/ClientRequest',
33+
'next/dist/compiled/ws',
34+
'next/dist/compiled/tar',
35+
'next/dist/compiled/@modelcontextprotocol/sdk/server/mcp',
36+
]
37+
38+
// Dev server specific externals
39+
const devServerExternals = [
40+
// Native bindings - cannot be bundled
41+
'@next/swc',
42+
// User-facing packages
43+
'@next/env',
44+
// Optional/native image dependencies
45+
'sharp',
46+
]
47+
48+
// Patterns to externalize
49+
const externalPatterns = [
50+
// Pre-compiled dependencies
51+
/^next\/dist\/compiled\//,
52+
// Telemetry - lazy loaded
53+
/telemetry\/storage/,
54+
// Hot reloaders - dynamically loaded based on bundler choice
55+
/hot-reloader-turbopack/,
56+
/hot-reloader-webpack/,
57+
/hot-reloader-rspack/,
58+
// MCP server - only needed when clients connect
59+
/\/mcp\//,
60+
// Turbopack internals - native bindings
61+
/turbopack/,
62+
// SWC bindings - native (matches ./swc, build/swc, etc.)
63+
/\/swc($|\/)/,
64+
// Download SWC - only needed for fallback, loads tar
65+
/download-swc/,
66+
// Sandbox - manipulates require.cache
67+
/web\/sandbox/,
68+
// Require hook - uses require.resolve with user-facing packages
69+
/require-hook/,
70+
// Config utils - uses require.resolve with dynamic webpack paths
71+
/config-utils/,
72+
// Config loading - uses dynamic import() for user config files
73+
/\/config$/,
74+
// Node environment extensions - sets up global handlers, avoid duplication
75+
/node-environment-extensions/,
76+
// TypeScript setup verification - uses dynamic require.resolve
77+
/verify-typescript-setup/,
78+
// Sharp image processing
79+
/@img\/sharp/,
80+
]
81+
82+
// Map relative paths to proper external paths
83+
// Note: Most mappings are now handled dynamically by toNextDistPath() in the externalHandler
84+
const externalsMap = {
85+
// render-server pulls in next-server which has heavy dependencies (react-dom/server)
86+
// It's lazy-loaded at runtime when handling requests, not at startup
87+
'./render-server': 'next/dist/server/lib/render-server',
88+
'../render-server': 'next/dist/server/lib/render-server',
89+
// next/dist/server/next (the main Next.js server) - heavy, only needed for request handling
90+
'../next': 'next/dist/server/next',
91+
}
92+
93+
// Regex-based externals mapping
94+
const externalsRegexMap = {
95+
'(.*)trace/tracer$': 'next/dist/server/lib/trace/tracer',
96+
}
97+
98+
/**
99+
* Convert an absolute path within dist/ to a next/dist/... style import
100+
* @param {string} absolutePath
101+
* @returns {string|null}
102+
*/
103+
function toNextDistPath(absolutePath) {
104+
const normalizedPath = absolutePath.replace(/\\/g, '/')
105+
const distIndex = normalizedPath.indexOf('/dist/')
106+
if (distIndex === -1) return null
107+
108+
// Get the path after /dist/ and convert to next/dist/...
109+
const relativePath = normalizedPath.substring(distIndex + 1) // removes leading /
110+
return `next/${relativePath.replace(/\.js$/, '')}`
111+
}
112+
113+
/**
114+
* @param {Object} options
115+
* @param {boolean} options.dev - Development mode
116+
* @returns {import('@rspack/core').Configuration}
117+
*/
118+
module.exports = ({ dev }) => {
119+
const externalHandler = ({ context, request, getResolve }, callback) => {
120+
;(async () => {
121+
// Handle compiled dependencies with complex paths
122+
if (
123+
request.match(
124+
/next[/\\]dist[/\\]compiled[/\\](babel|webpack|source-map|semver|jest-worker|stacktrace-parser|@ampproject\/toolbox-optimizer)/
125+
)
126+
) {
127+
callback(null, 'commonjs ' + request)
128+
return
129+
}
130+
131+
// Handle image optimizer and test mode
132+
if (request.match(/(server\/image-optimizer|experimental\/testmode)/)) {
133+
callback(null, 'commonjs ' + request)
134+
return
135+
}
136+
137+
// Handle .external.js files
138+
if (request.match(/\.external(\.js)?$/)) {
139+
try {
140+
const resolve = getResolve()
141+
const resolved = await resolve(context, request)
142+
const nextDistPath = toNextDistPath(resolved)
143+
if (nextDistPath) {
144+
callback(null, `commonjs ${nextDistPath}`)
145+
return
146+
}
147+
} catch {
148+
// Resolution failed, use request as-is
149+
}
150+
callback(null, `commonjs ${request}`)
151+
return
152+
}
153+
154+
// Handle pattern-based externals - resolve and convert to next/dist/... paths
155+
for (const pattern of externalPatterns) {
156+
if (pattern.test(request)) {
157+
// Try to resolve and get proper next/dist/... path
158+
try {
159+
const resolve = getResolve()
160+
const resolved = await resolve(context, request)
161+
const nextDistPath = toNextDistPath(resolved)
162+
if (nextDistPath) {
163+
callback(null, `commonjs ${nextDistPath}`)
164+
return
165+
}
166+
} catch {
167+
// Resolution failed, use request as-is
168+
}
169+
callback(null, 'commonjs ' + request)
170+
return
171+
}
172+
}
173+
174+
// Handle regex map externals
175+
const regexMatch = Object.keys(externalsRegexMap).find((regex) =>
176+
new RegExp(regex).test(request)
177+
)
178+
if (regexMatch) {
179+
callback(null, 'commonjs ' + externalsRegexMap[regexMatch])
180+
return
181+
}
182+
183+
callback()
184+
})()
185+
}
186+
187+
return {
188+
entry: {
189+
'start-server': path.join(__dirname, 'dist/server/lib/start-server.js'),
190+
},
191+
target: 'node',
192+
mode: dev ? 'development' : 'production',
193+
output: {
194+
path: path.join(__dirname, 'dist/compiled/dev-server'),
195+
filename: '[name].js',
196+
libraryTarget: 'commonjs2',
197+
},
198+
devtool: 'source-map',
199+
optimization: {
200+
moduleIds: 'named',
201+
minimize: !dev,
202+
...(dev
203+
? {}
204+
: {
205+
minimizer: [
206+
new rspack.SwcJsMinimizerRspackPlugin({
207+
minimizerOptions: {
208+
mangle: false, // Keep readable for debugging
209+
},
210+
}),
211+
],
212+
}),
213+
},
214+
plugins: [
215+
new rspack.DefinePlugin({
216+
'typeof window': JSON.stringify('undefined'),
217+
// Replace process.env.NODE_ENV with 'development' since this bundle
218+
// is only used by the dev server. This ensures loadEnvConfig gets
219+
// the correct value for loading .env.development files.
220+
'process.env.NODE_ENV': JSON.stringify('development'),
221+
}),
222+
new rspack.BannerPlugin({
223+
banner: '/* Bundled dev server - reduces module loading overhead */',
224+
raw: true,
225+
}),
226+
process.env.ANALYZE &&
227+
new BundleAnalyzerPlugin({
228+
analyzerMode: 'static',
229+
reportFilename: path.join(
230+
__dirname,
231+
'dist/compiled/dev-server/bundle-report.html'
232+
),
233+
openAnalyzer: false,
234+
}),
235+
// Add ignoreList to source maps so internal frames are hidden in error stacks
236+
new DevToolsIgnorePlugin({
237+
shouldIgnorePath: () => true, // All sources in this bundle are internal
238+
}),
239+
].filter(Boolean),
240+
resolve: {
241+
extensions: ['.js', '.json'],
242+
},
243+
externals: [
244+
...sharedExternals,
245+
...devServerExternals,
246+
externalsMap,
247+
externalHandler,
248+
],
249+
externalsPresets: {
250+
node: true,
251+
},
252+
stats: process.env.ANALYZE_REASONS
253+
? {
254+
preset: 'verbose',
255+
reasons: true,
256+
modulesSpace: Infinity,
257+
}
258+
: {
259+
preset: 'errors-warnings',
260+
assets: true,
261+
},
262+
}
263+
}

packages/next/next-runtime.webpack-config.js

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -140,22 +140,27 @@ module.exports = ({ dev, turbo, bundleType, experimental, ...rest }) => {
140140
}
141141

142142
if (request.match(/\.external(\.js)?$/)) {
143-
const resolve = getResolve()
144-
const resolved = await resolve(context, request)
145-
const relative = path.relative(
146-
path.join(__dirname, '..'),
147-
resolved.replace('esm' + path.sep, '')
148-
)
149-
callback(null, `commonjs ${relative}`)
150-
} else {
151-
const regexMatch = Object.keys(externalsRegexMap).find((regex) =>
152-
new RegExp(regex).test(request)
153-
)
154-
if (regexMatch) {
155-
return callback(null, 'commonjs ' + externalsRegexMap[regexMatch])
143+
try {
144+
const resolve = getResolve()
145+
const resolved = await resolve(context, request)
146+
const relative = path.relative(
147+
path.join(__dirname, '..'),
148+
resolved.replace('esm' + path.sep, '')
149+
)
150+
callback(null, `commonjs ${relative}`)
151+
return
152+
} catch {
153+
// Resolution failed, fall through to next block
156154
}
157-
callback()
158155
}
156+
157+
const regexMatch = Object.keys(externalsRegexMap).find((regex) =>
158+
new RegExp(regex).test(request)
159+
)
160+
if (regexMatch) {
161+
return callback(null, 'commonjs ' + externalsRegexMap[regexMatch])
162+
}
163+
callback()
159164
})()
160165
}
161166

packages/next/src/cli/next-dev.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,9 @@ const nextDev = async (
268268
hostname: host,
269269
}
270270

271-
const startServerPath = require.resolve('../server/lib/start-server')
271+
const startServerPath = require.resolve(
272+
'../server/lib/start-server-with-cache'
273+
)
272274

273275
async function startServer(startServerOptions: StartServerOptions) {
274276
return new Promise<void>((resolve) => {

packages/next/src/server/dev/middleware-turbopack.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
type ModernSourceMapPayload,
2121
devirtualizeReactServerURL,
2222
findApplicableSourceMapPayload,
23+
normalizeSourceUrl,
2324
} from '../lib/source-maps'
2425
import { findSourceMap, type SourceMap } from 'node:module'
2526
import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -30,6 +31,13 @@ function shouldIgnorePath(modulePath: string): boolean {
3031
modulePath.includes('node_modules') ||
3132
// Only relevant for when Next.js is symlinked e.g. in the Next.js monorepo
3233
modulePath.includes('next/dist') ||
34+
modulePath.includes('next/src') ||
35+
// Relative paths to Next.js source files (from tsc-generated source maps)
36+
// These appear when the source map traces back to TS source but the ignoreList
37+
// can't be checked due to path format mismatches
38+
modulePath.startsWith('src/server/') ||
39+
modulePath.startsWith('src/build/') ||
40+
modulePath.startsWith('src/lib/') ||
3341
modulePath.startsWith('node:')
3442
)
3543
}
@@ -245,16 +253,29 @@ async function nativeTraceSource(
245253
)
246254
} else {
247255
// TODO: O(n^2). Consider moving `ignoreList` into a Set
248-
const sourceIndex = applicableSourceMap.sources.indexOf(
256+
// The source-map library may strip leading "./" when resolving paths,
257+
// so we need to handle both formats when looking up in sources.
258+
let sourceIndex = applicableSourceMap.sources.indexOf(
249259
originalPosition.source!
250260
)
261+
if (sourceIndex === -1) {
262+
// Try with "./" prefix for webpack:// URLs which often have this format
263+
const sourceWithPrefix = originalPosition.source!.replace(
264+
/^(webpack:\/\/[^/]+\/)/,
265+
'$1./'
266+
)
267+
sourceIndex = applicableSourceMap.sources.indexOf(sourceWithPrefix)
268+
}
251269
ignored =
252270
applicableSourceMap.ignoreList?.includes(sourceIndex) ??
253271
// When sourcemap is not available, fallback to checking `frame.file`.
254272
// e.g. In pages router, nextjs server code is not bundled into the page.
255273
shouldIgnorePath(frame.file)
256274
}
257275

276+
// Normalize source URLs that may have been incorrectly concatenated
277+
const normalizedSource = normalizeSourceUrl(originalPosition.source!)
278+
258279
const originalStackFrame: IgnorableStackFrame = {
259280
methodName:
260281
// We ignore the sourcemapped name since it won't be the correct name.
@@ -264,7 +285,7 @@ async function nativeTraceSource(
264285
frame.methodName
265286
?.replace('__WEBPACK_DEFAULT_EXPORT__', 'default')
266287
?.replace('__webpack_exports__.', '') || '<unknown>',
267-
file: originalPosition.source,
288+
file: normalizedSource,
268289
line1: originalPosition.line,
269290
column1:
270291
originalPosition.column === null ? null : originalPosition.column + 1,
@@ -306,6 +327,15 @@ async function createOriginalStackFrame(
306327
projectPath,
307328
fileURLToPath(normalizedStackFrameLocation)
308329
)
330+
} else if (
331+
normalizedStackFrameLocation !== null &&
332+
!path.isAbsolute(normalizedStackFrameLocation)
333+
) {
334+
// Resolve relative paths from CWD and make relative to projectPath
335+
const resolvedPath = path.resolve(normalizedStackFrameLocation)
336+
if (resolvedPath.startsWith(projectPath)) {
337+
normalizedStackFrameLocation = path.relative(projectPath, resolvedPath)
338+
}
309339
}
310340

311341
return {

0 commit comments

Comments
 (0)