Skip to content

Commit af8a686

Browse files
committed
fix: improve source map path normalization and monorepo support
- Add normalizeSourceUrl function to handle file:/ URL concatenation - Fix bug where file:/ URLs at position 0 weren't handled (>= 0 instead of > 0) - Add packages/next/dist to shouldIgnorePath for monorepo development - Export shouldIgnorePath for reuse by webpack source map implementation - Add unit tests for normalizeSourceUrl
1 parent 1f6179b commit af8a686

File tree

10 files changed

+553
-247
lines changed

10 files changed

+553
-247
lines changed

packages/next/src/build/webpack/config/blocks/base.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import DevToolsIgnorePlugin from '../../plugins/devtools-ignore-list-plugin'
66
import EvalSourceMapDevToolPlugin from '../../plugins/eval-source-map-dev-tool-plugin'
77
import { getRspackCore } from '../../../../shared/lib/get-rspack'
88

9-
function shouldIgnorePath(modulePath: string): boolean {
9+
export function shouldIgnorePath(modulePath: string): boolean {
1010
return (
1111
modulePath.includes('node_modules') ||
1212
modulePath.endsWith('__nextjs-internal-proxy.cjs') ||
1313
modulePath.endsWith('__nextjs-internal-proxy.mjs') ||
1414
// Only relevant for when Next.js is symlinked e.g. in the Next.js monorepo
15-
modulePath.includes('next/dist')
15+
modulePath.includes('next/dist') ||
16+
modulePath.includes('packages/next/dist')
1617
)
1718
}
1819

packages/next/src/server/dev/node-stack-frames.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@ import {
44
decorateServerError,
55
type ErrorSourceType,
66
} from '../../shared/lib/error-source'
7+
import { normalizeSourceUrl } from '../lib/source-map-utils'
78

89
function getFilesystemFrame(frame: StackFrame): StackFrame {
910
const f: StackFrame = { ...frame }
1011

1112
if (typeof f.file === 'string') {
13+
// Normalize paths that may have been malformed by source-map library
14+
// (e.g., duplicate path segments from Turbopack)
15+
f.file = normalizeSourceUrl(f.file)
16+
1217
if (
1318
// Posix:
1419
f.file.startsWith('/') ||
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Pure string utilities for source map path normalization.
3+
* No Node.js dependencies - safe to import in browser bundles.
4+
*/
5+
6+
/**
7+
* Normalize source URLs that were incorrectly formed by source-map resolution.
8+
*
9+
* Fixes two issues:
10+
*
11+
* 1. **file:/ URL concatenation bug in source-map library**
12+
* The source-map library (v0.6.1) doesn't recognize `file:/` (single slash) as an
13+
* absolute URL, so when a source map has `sourceRoot: "../../../"` and
14+
* `sources: ["file:/Users/foo/app/page.js"]`, the library incorrectly concatenates
15+
* them into `../../../file:/Users/foo/app/page.js`.
16+
* We extract the last `file:/` occurrence and normalize it to `file:///`.
17+
* See: https://github.com/nicolo-ribaudo/source-map/issues/1
18+
*
19+
* 2. **Duplicate path segments**
20+
* Some bundler/compiler combinations can produce paths with duplicate directory
21+
* sequences like `app/components/app/components/Button.js`. We detect and remove
22+
* the first occurrence of repeated segments.
23+
*/
24+
export function normalizeSourceUrl(source: string): string {
25+
let result = source
26+
27+
// Fix 1: Handle file:/ URL that was incorrectly concatenated with sourceRoot.
28+
// Find the last occurrence of file:/ since that's the actual source path.
29+
const lastFileUrlIndex = result.lastIndexOf('file:/')
30+
if (lastFileUrlIndex > 0) {
31+
// Only extract if file:/ is NOT at the beginning (meaning it was concatenated)
32+
let fileUrl = result.slice(lastFileUrlIndex)
33+
// Normalize file:/ (single slash) to file:/// (canonical form with empty host)
34+
if (!fileUrl.startsWith('file://')) {
35+
fileUrl = 'file://' + fileUrl.slice(5) // 'file:/' is 5 chars, replace with 'file://'
36+
}
37+
result = fileUrl
38+
} else if (lastFileUrlIndex === 0 && !result.startsWith('file://')) {
39+
// Handle file:/ at the beginning that needs normalization to file://
40+
result = 'file://' + result.slice(5)
41+
}
42+
43+
// Fix 2: Handle duplicate path segments (e.g., test/foo/test/foo/file.js -> test/foo/file.js)
44+
// This also applies to file:// URLs with duplicate paths
45+
// Algorithm: Find the shortest repeated sequence of path segments and remove the duplicate.
46+
const parts = result.split('/')
47+
for (let len = 1; len <= parts.length / 2; len++) {
48+
for (let i = 0; i <= parts.length - len * 2; i++) {
49+
// Check if parts[i..i+len] equals parts[i+len..i+len*2]
50+
let match = true
51+
for (let j = 0; j < len; j++) {
52+
if (parts[i + j] !== parts[i + len + j]) {
53+
match = false
54+
break
55+
}
56+
}
57+
// Don't treat '..' or '.' or empty string as duplicates (e.g., ../../foo is valid)
58+
// Empty string can appear in file:// URLs (file:///Users -> ['file:', '', '', 'Users'])
59+
if (match && parts[i] !== '..' && parts[i] !== '.' && parts[i] !== '') {
60+
// Remove the duplicate segment by keeping [0..i+len] and [i+len*2..end]
61+
const newParts = [
62+
...parts.slice(0, i + len),
63+
...parts.slice(i + len * 2),
64+
]
65+
return newParts.join('/')
66+
}
67+
}
68+
}
69+
70+
return result
71+
}

packages/next/src/server/lib/source-maps.test.ts

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { ignoreListAnonymousStackFramesIfSandwiched } from './source-maps'
1+
import {
2+
ignoreListAnonymousStackFramesIfSandwiched,
3+
normalizeSourceUrl,
4+
} from './source-maps'
25

36
type StackFrame = null | {
47
file: string
@@ -226,3 +229,119 @@ test('does not ignore list anonymous frames that are not likely JS native method
226229
{ ignored: true, file: 'file2.js', methodName: 'render' },
227230
])
228231
})
232+
233+
describe('normalizeSourceUrl', () => {
234+
// Import the source-map library to demonstrate its actual behavior
235+
const { SourceMapConsumer } = (require('next/dist/compiled/source-map') as typeof import('next/dist/compiled/source-map'))
236+
237+
describe('file URL concatenation (source-map library bug)', () => {
238+
// The source-map library doesn't recognize file:/ (single slash) as an absolute URL,
239+
// so it concatenates sourceRoot with the file:/ source, producing malformed paths.
240+
// This test demonstrates the actual library behavior and verifies our fix.
241+
242+
it('fixes malformed paths produced by source-map library with sourceRoot', () => {
243+
// Create a source map that triggers the bug:
244+
// - sourceRoot is a relative path
245+
// - source is a file:/ URL (single slash, which is technically valid but uncommon)
246+
const rawSourceMap = {
247+
version: 3,
248+
file: 'output.js',
249+
sourceRoot: '../../../',
250+
sources: ['file:/Users/foo/app/page.js'],
251+
names: [],
252+
mappings: 'AAAA', // Maps position (0,0) -> (0,0)
253+
}
254+
255+
const consumer = new SourceMapConsumer(rawSourceMap)
256+
257+
// Get the source URL as the library reports it
258+
const malformedSource = consumer.sources[0]
259+
260+
// The library incorrectly concatenates sourceRoot with the file:/ URL
261+
expect(malformedSource).toBe('../../../file:/Users/foo/app/page.js')
262+
263+
// Our normalizeSourceUrl fixes this malformed path
264+
expect(normalizeSourceUrl(malformedSource)).toBe(
265+
'file:///Users/foo/app/page.js'
266+
)
267+
})
268+
269+
it('normalizes file:/ to file:// even without sourceRoot', () => {
270+
const rawSourceMap = {
271+
version: 3,
272+
file: 'output.js',
273+
sources: ['file:/Users/foo/app/page.js'],
274+
names: [],
275+
mappings: 'AAAA',
276+
}
277+
278+
const consumer = new SourceMapConsumer(rawSourceMap)
279+
const source = consumer.sources[0]
280+
281+
// Without sourceRoot, file:/ comes through as-is (still malformed)
282+
expect(source).toBe('file:/Users/foo/app/page.js')
283+
284+
// Our fix normalizes it to the canonical file:// format
285+
expect(normalizeSourceUrl(source)).toBe('file:///Users/foo/app/page.js')
286+
})
287+
288+
it('handles file:/// URLs correctly (no change needed)', () => {
289+
expect(normalizeSourceUrl('file:///Users/foo/app/page.js')).toBe(
290+
'file:///Users/foo/app/page.js'
291+
)
292+
})
293+
294+
it('extracts last file:/ when multiple occurrences exist', () => {
295+
// Edge case: nested source maps could produce multiple file: occurrences
296+
expect(
297+
normalizeSourceUrl('file:/wrong/path/file:/correct/path/page.js')
298+
).toBe('file:///correct/path/page.js')
299+
})
300+
})
301+
302+
describe('duplicate path segments', () => {
303+
it('removes single duplicate segment', () => {
304+
expect(normalizeSourceUrl('app/app/page.tsx')).toBe('app/page.tsx')
305+
})
306+
307+
it('removes multi-directory duplicate segment', () => {
308+
expect(normalizeSourceUrl('test/fixtures/test/fixtures/app.js')).toBe(
309+
'test/fixtures/app.js'
310+
)
311+
})
312+
313+
it('removes duplicate with relative prefix preserved', () => {
314+
expect(
315+
normalizeSourceUrl('../../../app/components/app/components/Button.js')
316+
).toBe('../../../app/components/Button.js')
317+
})
318+
})
319+
320+
describe('paths that should not be modified', () => {
321+
it('does not modify consecutive .. segments', () => {
322+
expect(normalizeSourceUrl('../../page.js')).toBe('../../page.js')
323+
})
324+
325+
it('does not modify paths with . in them', () => {
326+
expect(normalizeSourceUrl('./src/./page.js')).toBe('./src/./page.js')
327+
})
328+
329+
it('does not modify normal relative paths', () => {
330+
expect(normalizeSourceUrl('src/components/page.js')).toBe(
331+
'src/components/page.js'
332+
)
333+
})
334+
335+
it('does not modify webpack:// URLs', () => {
336+
expect(normalizeSourceUrl('webpack://app/./src/page.js')).toBe(
337+
'webpack://app/./src/page.js'
338+
)
339+
})
340+
341+
it('does not modify turbopack:// URLs', () => {
342+
expect(normalizeSourceUrl('turbopack://[project]/src/page.js')).toBe(
343+
'turbopack://[project]/src/page.js'
344+
)
345+
})
346+
})
347+
})

packages/next/src/server/lib/source-maps.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { SourceMap } from 'module'
22
import { LRUCache } from './lru-cache'
3+
export { normalizeSourceUrl } from './source-map-utils'
34

45
function noSourceMap(): SourceMap | undefined {
56
return undefined

packages/next/src/server/patch-error-inspect.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
findApplicableSourceMapPayload,
99
ignoreListAnonymousStackFramesIfSandwiched as ignoreListAnonymousStackFramesIfSandwichedGeneric,
1010
sourceMapIgnoreListsEverything,
11+
normalizeSourceUrl,
1112
} from './lib/source-maps'
1213
import { parseStack, type StackFrame } from './lib/parse-stack'
1314
import { getOriginalCodeFrame } from '../next-devtools/server/shared'
@@ -59,13 +60,30 @@ function frameToString(
5960
// In a multi-app repo, this leads to potentially larger file names but will make clicking snappy.
6061
// There's no tradeoff for the cases where `dir` in `next dev [dir]` is omitted
6162
// since relative to cwd is both the shortest and snappiest.
62-
fileLocation = path.relative(process.cwd(), url.fileURLToPath(sourceURL))
63+
try {
64+
fileLocation = path.relative(process.cwd(), url.fileURLToPath(sourceURL))
65+
} catch {
66+
// fileURLToPath throws for file URLs with non-localhost hosts (e.g., file://remote/path)
67+
// This can happen with malformed source map URLs. Fall back to using the URL as-is.
68+
fileLocation = sourceURL
69+
}
6370
} else if (sourceURL !== null && sourceURL.startsWith('/')) {
6471
fileLocation = path.relative(process.cwd(), sourceURL)
6572
} else {
6673
fileLocation = sourceURL
6774
}
6875

76+
// Decode URL-encoded characters in the path.
77+
// Dynamic routes like [slug] get encoded as %5Bslug%5D in source map URLs.
78+
// Decode them back so stack traces show readable paths: pages/blog/[slug].js
79+
if (fileLocation !== null) {
80+
try {
81+
fileLocation = decodeURIComponent(fileLocation)
82+
} catch {
83+
// Keep original if decoding fails (e.g., malformed percent encoding)
84+
}
85+
}
86+
6987
return methodName
7088
? ` at ${methodName} (${fileLocation}${sourceLocation})`
7189
: ` at ${fileLocation}${sourceLocation}`
@@ -112,14 +130,22 @@ interface SourceMappedFrame {
112130
function createUnsourcemappedFrame(
113131
frame: SourcemappableStackFrame
114132
): SourceMappedFrame {
133+
// Normalize the file path even for unsourcemapped frames.
134+
// This handles cases where V8's built-in source map support already resolved the path
135+
// but introduced duplicate path segments (e.g., from Turbopack's source maps).
136+
const normalizedFile = normalizeSourceUrl(frame.file)
137+
115138
return {
116139
stack: {
117-
file: frame.file,
140+
file: normalizedFile,
118141
line1: frame.line1,
119142
column1: frame.column1,
120-
methodName: frame.methodName,
143+
// Webpack's eval devtool wraps code in eval() which produces ugly method names like:
144+
// "Timeout.eval [as _onTimeout]" instead of "Timeout._onTimeout"
145+
// This regex extracts the actual method name from the "[as X]" wrapper.
146+
methodName: frame.methodName?.replace(/\.eval \[as ([^\]]+)\]/, '.$1'),
121147
arguments: frame.arguments,
122-
ignored: shouldIgnoreListGeneratedFrame(frame.file),
148+
ignored: shouldIgnoreListGeneratedFrame(normalizedFile),
123149
},
124150
code: null,
125151
}
@@ -250,14 +276,16 @@ function getSourcemappedFrameIfPossible(
250276
applicableSourceMap !== undefined &&
251277
sourceMapIgnoreListsEverything(applicableSourceMap)
252278
if (sourcePosition.source === null) {
279+
// Normalize the file path in case V8's source maps already resolved it with duplicates
280+
const normalizedFile = normalizeSourceUrl(frame.file)
253281
return {
254282
stack: {
255283
arguments: frame.arguments,
256-
file: frame.file,
284+
file: normalizedFile,
257285
line1: frame.line1,
258286
column1: frame.column1,
259287
methodName: frame.methodName,
260-
ignored: ignored || shouldIgnoreListGeneratedFrame(frame.file),
288+
ignored: ignored || shouldIgnoreListGeneratedFrame(normalizedFile),
261289
},
262290
code: null,
263291
}
@@ -282,15 +310,23 @@ function getSourcemappedFrameIfPossible(
282310
ignored = applicableSourceMap.ignoreList?.includes(sourceIndex) ?? false
283311
}
284312

313+
// Normalize the source URL to fix issues from source-map library:
314+
// 1. Duplicate path segments from Turbopack (test/foo/test/foo/file.js)
315+
// 2. Malformed file:/ URLs from sourceRoot concatenation
316+
const normalizedSource = normalizeSourceUrl(sourcePosition.source)
317+
285318
const originalFrame: IgnorableStackFrame = {
286319
// We ignore the sourcemapped name since it won't be the correct name.
287320
// The callsite will point to the column of the variable name instead of the
288321
// name of the enclosing function.
289322
// TODO(NDX-531): Spy on prepareStackTrace to get the enclosing line number for method name mapping.
290323
methodName: frame.methodName
291324
?.replace('__WEBPACK_DEFAULT_EXPORT__', 'default')
292-
?.replace('__webpack_exports__.', ''),
293-
file: sourcePosition.source,
325+
?.replace('__webpack_exports__.', '')
326+
// Webpack's eval devtool wraps code in eval() which produces ugly method names.
327+
// Extract actual method name: "Timeout.eval [as _onTimeout]" -> "Timeout._onTimeout"
328+
?.replace(/\.eval \[as ([^\]]+)\]/, '.$1'),
329+
file: normalizedSource,
294330
line1: sourcePosition.line,
295331
column1: sourcePosition.column + 1,
296332
// TODO: c&p from async createOriginalStackFrame but why not frame.arguments?

0 commit comments

Comments
 (0)