Skip to content
Closed
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
265 changes: 265 additions & 0 deletions packages/next/next-dev-server.webpack-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
/**
* Webpack/Rspack config for bundling dev server startup modules.
*
* This bundles start-server.ts, router-server.ts, and their dependencies
* into a single file to reduce module loading overhead during dev server boot.
*/

/* eslint-disable import/no-extraneous-dependencies */
const rspack = require('@rspack/core')
const path = require('path')
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
const DevToolsIgnorePlugin =
require('./dist/build/webpack/plugins/devtools-ignore-list-plugin').default
/* eslint-enable import/no-extraneous-dependencies */

// Shared externals with the server runtime bundle
const sharedExternals = [
'styled-jsx',
'styled-jsx/style',
'@opentelemetry/api',
'next/dist/compiled/@ampproject/toolbox-optimizer',
'next/dist/compiled/edge-runtime',
'next/dist/compiled/@edge-runtime/ponyfill',
'next/dist/compiled/undici',
'next/dist/compiled/raw-body',
'next/dist/server/capsize-font-metrics.json',
'critters',
'next/dist/compiled/node-html-parser',
'next/dist/compiled/compression',
'next/dist/compiled/jsonwebtoken',
'next/dist/compiled/@opentelemetry/api',
'next/dist/compiled/@mswjs/interceptors/ClientRequest',
'next/dist/compiled/ws',
'next/dist/compiled/tar',
'next/dist/compiled/@modelcontextprotocol/sdk/server/mcp',
]

// Dev server specific externals
const devServerExternals = [
// Native bindings - cannot be bundled
'@next/swc',
// User-facing packages
'@next/env',
// Optional/native image dependencies
'sharp',
]

// Patterns to externalize
const externalPatterns = [
// Pre-compiled dependencies
/^next\/dist\/compiled\//,
// Telemetry - lazy loaded
/telemetry\/storage/,
// Hot reloaders - dynamically loaded based on bundler choice
/hot-reloader-turbopack/,
/hot-reloader-webpack/,
/hot-reloader-rspack/,
// MCP server - only needed when clients connect
/\/mcp\//,
// Turbopack internals - native bindings
/turbopack/,
// SWC bindings - native (matches ./swc, build/swc, etc.)
/\/swc($|\/)/,
// Download SWC - only needed for fallback, loads tar
/download-swc/,
// Sandbox - manipulates require.cache
/web\/sandbox/,
// Require hook - uses require.resolve with user-facing packages
/require-hook/,
// Config utils - uses require.resolve with dynamic webpack paths
/config-utils/,
// Config loading - uses dynamic import() for user config files
/\/config$/,
// Node environment extensions - sets up global handlers, avoid duplication
/node-environment-extensions/,
// TypeScript setup verification - uses dynamic require.resolve
/verify-typescript-setup/,
// Sharp image processing
/@img\/sharp/,
]

// Map relative paths to proper external paths
// Note: Most mappings are now handled dynamically by toNextDistPath() in the externalHandler
const externalsMap = {
// render-server pulls in next-server which has heavy dependencies (react-dom/server)
// It's lazy-loaded at runtime when handling requests, not at startup
'./render-server': 'next/dist/server/lib/render-server',
'../render-server': 'next/dist/server/lib/render-server',
// next/dist/server/next (the main Next.js server) - heavy, only needed for request handling
'../next': 'next/dist/server/next',
}

// Regex-based externals mapping
const externalsRegexMap = {
'(.*)trace/tracer$': 'next/dist/server/lib/trace/tracer',
}

/**
* Convert an absolute path within dist/ to a next/dist/... style import
* @param {string} absolutePath
* @returns {string|null}
*/
function toNextDistPath(absolutePath) {
const normalizedPath = absolutePath.replace(/\\/g, '/')
const distIndex = normalizedPath.indexOf('/dist/')
if (distIndex === -1) return null

// Get the path after /dist/ and convert to next/dist/...
const relativePath = normalizedPath.substring(distIndex + 1) // removes leading /
return `next/${relativePath.replace(/\.js$/, '')}`
}

/**
* @param {Object} options
* @param {boolean} options.dev - Development mode
* @returns {import('@rspack/core').Configuration}
*/
module.exports = ({ dev }) => {
const externalHandler = ({ context, request, getResolve }, callback) => {
;(async () => {
// Handle compiled dependencies with complex paths
if (
request.match(
/next[/\\]dist[/\\]compiled[/\\](babel|webpack|source-map|semver|jest-worker|stacktrace-parser|@ampproject\/toolbox-optimizer)/
)
) {
callback(null, 'commonjs ' + request)
return
}

// Handle image optimizer and test mode
if (request.match(/(server\/image-optimizer|experimental\/testmode)/)) {
callback(null, 'commonjs ' + request)
return
}

// Handle .external.js files
if (request.match(/\.external(\.js)?$/)) {
try {
const resolve = getResolve()
const resolved = await resolve(context, request)
const nextDistPath = toNextDistPath(resolved)
if (nextDistPath) {
callback(null, `commonjs ${nextDistPath}`)
return
}
} catch {
// Resolution failed, use request as-is
}
callback(null, `commonjs ${request}`)
return
}

// Handle pattern-based externals - resolve and convert to next/dist/... paths
for (const pattern of externalPatterns) {
if (pattern.test(request)) {
// Try to resolve and get proper next/dist/... path
try {
const resolve = getResolve()
const resolved = await resolve(context, request)
const nextDistPath = toNextDistPath(resolved)
if (nextDistPath) {
callback(null, `commonjs ${nextDistPath}`)
return
}
} catch {
// Resolution failed, use request as-is
}
callback(null, 'commonjs ' + request)
return
}
}

// Handle regex map externals
const regexMatch = Object.keys(externalsRegexMap).find((regex) =>
new RegExp(regex).test(request)
)
if (regexMatch) {
callback(null, 'commonjs ' + externalsRegexMap[regexMatch])
return
}

callback()
})()
}

return {
entry: {
'start-server': path.join(__dirname, 'dist/server/lib/start-server.js'),
},
target: 'node',
mode: dev ? 'development' : 'production',
output: {
path: path.join(__dirname, 'dist/compiled/dev-server'),
filename: '[name].js',
libraryTarget: 'commonjs2',
// Use absolute paths in source maps so error formatting can compute correct relative paths
devtoolModuleFilenameTemplate: '[absolute-resource-path]',
},
devtool: 'source-map',
optimization: {
moduleIds: 'named',
minimize: !dev,
...(dev
? {}
: {
minimizer: [
new rspack.SwcJsMinimizerRspackPlugin({
minimizerOptions: {
mangle: false, // Keep readable for debugging
},
}),
],
}),
},
plugins: [
new rspack.DefinePlugin({
'typeof window': JSON.stringify('undefined'),
// Replace process.env.NODE_ENV with 'development' since this bundle
// is only used by the dev server. This ensures loadEnvConfig gets
// the correct value for loading .env.development files.
'process.env.NODE_ENV': JSON.stringify('development'),
}),
new rspack.BannerPlugin({
banner: '/* Bundled dev server - reduces module loading overhead */',
raw: true,
}),
process.env.ANALYZE &&
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: path.join(
__dirname,
'dist/compiled/dev-server/bundle-report.html'
),
openAnalyzer: false,
}),
// Add ignoreList to source maps so internal frames are hidden in error stacks
new DevToolsIgnorePlugin({
shouldIgnorePath: () => true, // All sources in this bundle are internal
}),
].filter(Boolean),
resolve: {
extensions: ['.js', '.json'],
},
externals: [
...sharedExternals,
...devServerExternals,
externalsMap,
externalHandler,
],
externalsPresets: {
node: true,
},
stats: process.env.ANALYZE_REASONS
? {
preset: 'verbose',
reasons: true,
modulesSpace: Infinity,
}
: {
preset: 'errors-warnings',
assets: true,
},
}
}
33 changes: 19 additions & 14 deletions packages/next/next-runtime.webpack-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,22 +140,27 @@ module.exports = ({ dev, turbo, bundleType, experimental, ...rest }) => {
}

if (request.match(/\.external(\.js)?$/)) {
const resolve = getResolve()
const resolved = await resolve(context, request)
const relative = path.relative(
path.join(__dirname, '..'),
resolved.replace('esm' + path.sep, '')
)
callback(null, `commonjs ${relative}`)
} else {
const regexMatch = Object.keys(externalsRegexMap).find((regex) =>
new RegExp(regex).test(request)
)
if (regexMatch) {
return callback(null, 'commonjs ' + externalsRegexMap[regexMatch])
try {
const resolve = getResolve()
const resolved = await resolve(context, request)
const relative = path.relative(
path.join(__dirname, '..'),
resolved.replace('esm' + path.sep, '')
)
callback(null, `commonjs ${relative}`)
return
} catch {
// Resolution failed, fall through to next block
}
callback()
}

const regexMatch = Object.keys(externalsRegexMap).find((regex) =>
new RegExp(regex).test(request)
)
if (regexMatch) {
return callback(null, 'commonjs ' + externalsRegexMap[regexMatch])
}
callback()
})()
}

Expand Down
4 changes: 3 additions & 1 deletion packages/next/src/cli/next-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,9 @@ const nextDev = async (
hostname: host,
}

const startServerPath = require.resolve('../server/lib/start-server')
const startServerPath = require.resolve(
'../server/lib/start-server-with-cache'
)

async function startServer(startServerOptions: StartServerOptions) {
return new Promise<void>((resolve) => {
Expand Down
34 changes: 32 additions & 2 deletions packages/next/src/server/dev/middleware-turbopack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type ModernSourceMapPayload,
devirtualizeReactServerURL,
findApplicableSourceMapPayload,
normalizeSourceUrl,
} from '../lib/source-maps'
import { findSourceMap, type SourceMap } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
Expand All @@ -30,6 +31,13 @@ function shouldIgnorePath(modulePath: string): boolean {
modulePath.includes('node_modules') ||
// Only relevant for when Next.js is symlinked e.g. in the Next.js monorepo
modulePath.includes('next/dist') ||
modulePath.includes('next/src') ||
// Relative paths to Next.js source files (from tsc-generated source maps)
// These appear when the source map traces back to TS source but the ignoreList
// can't be checked due to path format mismatches
modulePath.startsWith('src/server/') ||
modulePath.startsWith('src/build/') ||
modulePath.startsWith('src/lib/') ||
Comment on lines +35 to +40

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's not guaranteed that these are Next.js module paths. This should be handled via ignore-list.

modulePath.startsWith('node:')
)
}
Expand Down Expand Up @@ -245,16 +253,29 @@ async function nativeTraceSource(
)
} else {
// TODO: O(n^2). Consider moving `ignoreList` into a Set
const sourceIndex = applicableSourceMap.sources.indexOf(
// The source-map library may strip leading "./" when resolving paths,
// so we need to handle both formats when looking up in sources.
let sourceIndex = applicableSourceMap.sources.indexOf(
originalPosition.source!
)
if (sourceIndex === -1) {
// Try with "./" prefix for webpack:// URLs which often have this format
const sourceWithPrefix = originalPosition.source!.replace(
/^(webpack:\/\/[^/]+\/)/,
'$1./'
)
sourceIndex = applicableSourceMap.sources.indexOf(sourceWithPrefix)
}
Comment on lines +256 to +268

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we split this out? This seems sketchy e.g. why and when specifically does source-map strip leading ./?

ignored =
applicableSourceMap.ignoreList?.includes(sourceIndex) ??
// When sourcemap is not available, fallback to checking `frame.file`.
// e.g. In pages router, nextjs server code is not bundled into the page.
shouldIgnorePath(frame.file)
}

// Normalize source URLs that may have been incorrectly concatenated
const normalizedSource = normalizeSourceUrl(originalPosition.source!)

const originalStackFrame: IgnorableStackFrame = {
methodName:
// We ignore the sourcemapped name since it won't be the correct name.
Expand All @@ -264,7 +285,7 @@ async function nativeTraceSource(
frame.methodName
?.replace('__WEBPACK_DEFAULT_EXPORT__', 'default')
?.replace('__webpack_exports__.', '') || '<unknown>',
file: originalPosition.source,
file: normalizedSource,
line1: originalPosition.line,
column1:
originalPosition.column === null ? null : originalPosition.column + 1,
Expand Down Expand Up @@ -306,6 +327,15 @@ async function createOriginalStackFrame(
projectPath,
fileURLToPath(normalizedStackFrameLocation)
)
} else if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fileURLToPath call on line 328 lacks a try-catch block for error handling. According to the PR description, this was supposed to have "try-catch added to url.fileURLToPath() for malformed URL handling," but the error handling was only added to patch-error-inspect.ts, not here.

View Details
📝 Patch Details
diff --git a/packages/next/src/server/dev/middleware-turbopack.ts b/packages/next/src/server/dev/middleware-turbopack.ts
index 2a1c4cb266..a42e3253b4 100644
--- a/packages/next/src/server/dev/middleware-turbopack.ts
+++ b/packages/next/src/server/dev/middleware-turbopack.ts
@@ -323,10 +323,15 @@ async function createOriginalStackFrame(
     normalizedStackFrameLocation !== null &&
     normalizedStackFrameLocation.startsWith('file://')
   ) {
-    normalizedStackFrameLocation = path.relative(
-      projectPath,
-      fileURLToPath(normalizedStackFrameLocation)
-    )
+    try {
+      normalizedStackFrameLocation = path.relative(
+        projectPath,
+        fileURLToPath(normalizedStackFrameLocation)
+      )
+    } catch {
+      // fileURLToPath can fail for file URLs with non-localhost hosts
+      // Fall back to using the URL as-is
+    }
   } else if (
     normalizedStackFrameLocation !== null &&
     !path.isAbsolute(normalizedStackFrameLocation)

Analysis

Missing error handling for fileURLToPath in middleware-turbopack.ts

What fails: createOriginalStackFrame() in packages/next/src/server/dev/middleware-turbopack.ts (lines 326-329) calls fileURLToPath(normalizedStackFrameLocation) without error handling. When passed file URLs with non-localhost hosts (e.g., network paths or malformed URLs), fileURLToPath() throws an ERR_INVALID_FILE_URL_HOST error, crashing the error reporting mechanism instead of gracefully handling it.

How to reproduce:

const { fileURLToPath } = require('url');

// This will throw ERR_INVALID_FILE_URL_HOST
fileURLToPath('file://myhost/home/user/test.js');

// This will throw ERR_INVALID_URL
fileURLToPath('file://[invalid]');

Result: When createOriginalStackFrame() processes a stack frame with a malformed file URL, it throws an unhandled error instead of falling back gracefully. This crashes the middleware error reporting instead of displaying the error properly.

Expected: fileURLToPath() calls should be wrapped in try-catch blocks to gracefully handle errors from malformed or non-standard file URLs, falling back to using the URL as-is (matching the pattern already implemented in patch-error-inspect.ts lines 97-103).

Reference: According to Node.js documentation, fileURLToPath() throws ERR_INVALID_FILE_URL_HOST when given file URLs with non-localhost hosts, and ERR_INVALID_URL for malformed URLs. The same error handling pattern is already implemented in patch-error-inspect.ts but was missing from middleware-turbopack.ts.

normalizedStackFrameLocation !== null &&
!path.isAbsolute(normalizedStackFrameLocation)
) {
// Resolve relative paths from CWD and make relative to projectPath
const resolvedPath = path.resolve(normalizedStackFrameLocation)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Whatever is giving us relative paths to the cwd, should instead give us absolute paths. I'm not sure it's always correct to assume relative paths in stackframes are relative to the cwd.

if (resolvedPath.startsWith(projectPath)) {
normalizedStackFrameLocation = path.relative(projectPath, resolvedPath)
}
}

return {
Expand Down
Loading
Loading