diff --git a/packages/next/next-dev-server.webpack-config.js b/packages/next/next-dev-server.webpack-config.js new file mode 100644 index 000000000000..2d3572dc1bf9 --- /dev/null +++ b/packages/next/next-dev-server.webpack-config.js @@ -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, + }, + } +} diff --git a/packages/next/next-runtime.webpack-config.js b/packages/next/next-runtime.webpack-config.js index 1d5fd5121498..121ca0953fd3 100644 --- a/packages/next/next-runtime.webpack-config.js +++ b/packages/next/next-runtime.webpack-config.js @@ -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() })() } diff --git a/packages/next/src/cli/next-dev.ts b/packages/next/src/cli/next-dev.ts index d8880b788eeb..bc1c533e90e3 100644 --- a/packages/next/src/cli/next-dev.ts +++ b/packages/next/src/cli/next-dev.ts @@ -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((resolve) => { diff --git a/packages/next/src/server/dev/middleware-turbopack.ts b/packages/next/src/server/dev/middleware-turbopack.ts index 96bdfa02fd21..2a1c4cb2667e 100644 --- a/packages/next/src/server/dev/middleware-turbopack.ts +++ b/packages/next/src/server/dev/middleware-turbopack.ts @@ -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' @@ -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/') || modulePath.startsWith('node:') ) } @@ -245,9 +253,19 @@ 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) + } ignored = applicableSourceMap.ignoreList?.includes(sourceIndex) ?? // When sourcemap is not available, fallback to checking `frame.file`. @@ -255,6 +273,9 @@ async function nativeTraceSource( 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. @@ -264,7 +285,7 @@ async function nativeTraceSource( frame.methodName ?.replace('__WEBPACK_DEFAULT_EXPORT__', 'default') ?.replace('__webpack_exports__.', '') || '', - file: originalPosition.source, + file: normalizedSource, line1: originalPosition.line, column1: originalPosition.column === null ? null : originalPosition.column + 1, @@ -306,6 +327,15 @@ async function createOriginalStackFrame( projectPath, fileURLToPath(normalizedStackFrameLocation) ) + } else if ( + normalizedStackFrameLocation !== null && + !path.isAbsolute(normalizedStackFrameLocation) + ) { + // Resolve relative paths from CWD and make relative to projectPath + const resolvedPath = path.resolve(normalizedStackFrameLocation) + if (resolvedPath.startsWith(projectPath)) { + normalizedStackFrameLocation = path.relative(projectPath, resolvedPath) + } } return { diff --git a/packages/next/src/server/dev/node-stack-frames.ts b/packages/next/src/server/dev/node-stack-frames.ts index 1600c88d651d..c46c6ef05b81 100644 --- a/packages/next/src/server/dev/node-stack-frames.ts +++ b/packages/next/src/server/dev/node-stack-frames.ts @@ -1,5 +1,7 @@ import { parse } from 'next/dist/compiled/stacktrace-parser' import type { StackFrame } from 'next/dist/compiled/stacktrace-parser' +import path from 'path' +import url from 'url' import { decorateServerError, type ErrorSourceType, @@ -29,6 +31,25 @@ function getFilesystemFrame(frame: StackFrame): StackFrame { return f } +/** + * Convert a file path to a path relative to the current working directory. + */ +function toRelativePath(filePath: string): string { + if (filePath.startsWith('file://')) { + try { + filePath = url.fileURLToPath(filePath) + } catch { + // Invalid file URL, use as-is + } + } + + if (path.isAbsolute(filePath)) { + return path.relative(process.cwd(), filePath) + } + + return filePath +} + export function getServerError(error: Error, type: ErrorSourceType): Error { if (error.name === 'TurbopackInternalError') { // If this is an internal Turbopack error we shouldn't show internal details @@ -54,7 +75,8 @@ export function getServerError(error: Error, type: ErrorSourceType): Error { .map((f) => { let str = ` at ${f.methodName}` if (f.file) { - let loc = f.file + // Convert to relative path for cleaner output + let loc = toRelativePath(f.file) if (f.lineNumber) { loc += `:${f.lineNumber}` if (f.column) { diff --git a/packages/next/src/server/lib/start-server-with-cache.ts b/packages/next/src/server/lib/start-server-with-cache.ts new file mode 100644 index 000000000000..dad438622c04 --- /dev/null +++ b/packages/next/src/server/lib/start-server-with-cache.ts @@ -0,0 +1,24 @@ +/** + * Start Server Entry Point for Bundled Dev Server + * + * This module provides a unified entry point that can load either the bundled + * or unbundled dev server based on environment configuration. + * + * Environment variables: + * NEXT_USE_UNBUNDLED_SERVER=1 - Use unbundled server instead (for development) + */ + +import path from 'path' + +// Determine which server to load +const useBundled = process.env.NEXT_USE_UNBUNDLED_SERVER !== '1' + +const serverPath = useBundled + ? path.join(__dirname, '../../compiled/dev-server/start-server.js') + : path.join(__dirname, './start-server.js') + +// Load the server module +const serverModule: typeof import('./start-server') = require(serverPath) + +// Re-export everything from the server module +export const { startServer, getRequestHandlers } = serverModule diff --git a/packages/next/src/server/next.ts b/packages/next/src/server/next.ts index 9f84f7410d87..87f22f53f273 100644 --- a/packages/next/src/server/next.ts +++ b/packages/next/src/server/next.ts @@ -403,7 +403,7 @@ class NextCustomServer implements NextWrapperServer { async prepare() { const { getRequestHandlers } = - require('./lib/start-server') as typeof import('./lib/start-server') + require('./lib/start-server-with-cache') as typeof import('./lib/start-server-with-cache') let onDevServerCleanup: AsyncCallbackSet['add'] | undefined if (this.options.dev) { diff --git a/packages/next/src/server/patch-error-inspect.ts b/packages/next/src/server/patch-error-inspect.ts index 036a5f9ba21b..63628b6d529b 100644 --- a/packages/next/src/server/patch-error-inspect.ts +++ b/packages/next/src/server/patch-error-inspect.ts @@ -1,4 +1,5 @@ import { findSourceMap as nativeFindSourceMap } from 'module' +import * as fs from 'fs' import * as path from 'path' import * as url from 'url' import type * as util from 'util' @@ -7,8 +8,8 @@ import { type ModernSourceMapPayload, findApplicableSourceMapPayload, ignoreListAnonymousStackFramesIfSandwiched as ignoreListAnonymousStackFramesIfSandwichedGeneric, - sourceMapIgnoreListsEverything, normalizeSourceUrl, + sourceMapIgnoreListsEverything, } from './lib/source-maps' import { parseStack, type StackFrame } from './lib/parse-stack' import { getOriginalCodeFrame } from '../next-devtools/server/shared' @@ -30,6 +31,39 @@ export function setBundlerFindSourceMapImplementation( bundlerFindSourceMapPayload = findSourceMapImplementation } +// Cache for source maps read from disk to avoid repeated file I/O +const diskSourceMapCache = new Map() + +/** + * Try to read a source map from disk as a fallback when Node.js's findSourceMap fails. + * This handles cases where source maps aren't registered in Node.js's source map registry + * (e.g., when --enable-source-maps isn't set or files are loaded in certain ways). + */ +function readSourceMapFromDisk( + filePath: string +): ModernSourceMapPayload | undefined { + // Check cache first + const cached = diskSourceMapCache.get(filePath) + if (cached !== undefined) { + return cached ?? undefined + } + + const mapPath = filePath + '.map' + try { + if (fs.existsSync(mapPath)) { + const content = fs.readFileSync(mapPath, 'utf8') + const payload = JSON.parse(content) as ModernSourceMapPayload + diskSourceMapCache.set(filePath, payload) + return payload + } + } catch { + // Ignore read errors + } + + diskSourceMapCache.set(filePath, null) + return undefined +} + interface IgnorableStackFrame extends StackFrame { ignored: boolean } @@ -195,6 +229,13 @@ function getSourcemappedFrameIfPossible( // "" or "node:internal/process/task_queues" here if (path.isAbsolute(frame.file)) { sourceURL = url.pathToFileURL(frame.file).toString() + } else if ( + !frame.file.startsWith('webpack-internal://') && + !frame.file.startsWith('<') && + !frame.file.startsWith('node:') + ) { + // Relative paths need to be resolved to absolute paths for findSourceMap to work + sourceURL = url.pathToFileURL(path.resolve(frame.file)).toString() } let maybeSourceMapPayload: ModernSourceMapPayload | undefined try { @@ -220,6 +261,10 @@ function getSourcemappedFrameIfPossible( if (maybeSourceMapPayload === undefined) { maybeSourceMapPayload = bundlerFindSourceMapPayload(sourceURL) } + // Fall back to reading source map from disk if Node.js and bundler both fail + if (maybeSourceMapPayload === undefined && path.isAbsolute(frame.file)) { + maybeSourceMapPayload = readSourceMapFromDisk(frame.file) + } if (maybeSourceMapPayload === undefined) { return createUnsourcemappedFrame(frame) @@ -304,9 +349,17 @@ function getSourcemappedFrameIfPossible( ignored = true } else if (!ignored) { // TODO: O(n^2). Consider moving `ignoreList` into a Set - const sourceIndex = applicableSourceMap.sources.indexOf( - sourcePosition.source - ) + // 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(sourcePosition.source) + if (sourceIndex === -1) { + // Try with "./" prefix for webpack:// URLs which often have this format + const sourceWithPrefix = sourcePosition.source.replace( + /^(webpack:\/\/[^/]+\/)/, + '$1./' + ) + sourceIndex = applicableSourceMap.sources.indexOf(sourceWithPrefix) + } ignored = applicableSourceMap.ignoreList?.includes(sourceIndex) ?? false } diff --git a/packages/next/taskfile.js b/packages/next/taskfile.js index 225fb3b1f69a..ba1eacb83857 100644 --- a/packages/next/taskfile.js +++ b/packages/next/taskfile.js @@ -3007,6 +3007,16 @@ export async function next_bundle_devtools(task, opts) { }) } +export async function next_bundle_dev_server(task, opts) { + await task.source('dist').webpack({ + watch: opts.dev, + config: require('./next-dev-server.webpack-config')({ + dev: opts.dev, + }), + name: 'next-bundle-dev-server', + }) +} + export async function next_bundle(task, opts) { await task.parallel( [ @@ -3029,6 +3039,8 @@ export async function next_bundle(task, opts) { 'next_bundle_server', // devtools 'next_bundle_devtools', + // dev server startup bundle (reduces module loading overhead) + 'next_bundle_dev_server', ], opts ) diff --git a/test/integration/server-side-dev-errors/test/index.test.ts b/test/integration/server-side-dev-errors/test/index.test.ts index 9ffe57058592..4e1aa6ac1496 100644 --- a/test/integration/server-side-dev-errors/test/index.test.ts +++ b/test/integration/server-side-dev-errors/test/index.test.ts @@ -230,6 +230,7 @@ describe('server-side dev errors', () => { }) const stderrOutput = stripAnsi(stderr.slice(stderrIdx)).trim() + // Both Turbopack and Webpack now have internal frames properly filtered expect(stderrOutput).toStartWith( '⨯ ReferenceError: missingVar is not defined' + '\n at handler (../../test/integration/server-side-dev-errors/pages/api/hello.js:2:3)' + @@ -283,6 +284,7 @@ describe('server-side dev errors', () => { }) const stderrOutput = stripAnsi(stderr.slice(stderrIdx)).trim() + // Both Turbopack and Webpack now have internal frames properly filtered expect(stderrOutput).toStartWith( '⨯ ReferenceError: missingVar is not defined' + '\n at handler (../../test/integration/server-side-dev-errors/pages/api/blog/[slug].js:2:3)' +