Skip to content

Commit e226233

Browse files
authored
[turbopack] Implement improved deobfuscation for free calls and module identifiers. (#85060)
Improve deobfuscation of 'imported module identifier' and attempt to deobfuscate error messages logged by the server. Before ``` ⨯ TypeError: (0 , __TURBOPACK__imported__module__$5b$project$5d2f$examples$2f$with$2d$turbopack$2f$app$2f$foo$2e$ts__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__.foo) is not a function at Page (app/page.tsx:5:6) 3 | export default function Page() { 4 | /** @ts-ignore */ > 5 | foo(); | ^ 6 | return <h1>Hello, Next.js!</h1>; 7 | } 8 | { ``` after ``` ⨯ TypeError: {imported module ./examples/with-turbopack/app/foo.ts}.foo is not a function at Page (app/page.tsx:5:6) 3 | export default function Page() { 4 | /** @ts-ignore */ > 5 | foo(); | ^ 6 | return <h1>Hello, Next.js!</h1>; 7 | } 8 | { ``` Similarly in devtools before: ![image.png](https://app.graphite.dev/user-attachments/assets/fd50d94a-d34a-4026-862e-0ca8ee6658ba.png) after ![image.png](https://app.graphite.dev/user-attachments/assets/d9080ed7-29c8-428a-831a-9bbcd818cc3e.png) This is definitely an improvement but i am not sure i put the deobfuscation logic in the best place for the console case. Happy for feedback
1 parent f50c769 commit e226233

File tree

10 files changed

+373
-61
lines changed

10 files changed

+373
-61
lines changed

examples/basic-css/tsconfig.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
"strictNullChecks": true,
2222
"target": "ES2017"
2323
},
24-
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
24+
"include": [
25+
"next-env.d.ts",
26+
".next/types/**/*.ts",
27+
"**/*.ts",
28+
"**/*.tsx",
29+
".next/dev/types/**/*.ts"
30+
],
2531
"exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"]
2632
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { foo } from "./foo";
12
export default function Page() {
3+
/** @ts-ignore */
4+
foo();
25
return <h1>Hello, Next.js!</h1>;
36
}

examples/with-turbopack/tsconfig.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
}
2121
]
2222
},
23-
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
23+
"include": [
24+
"next-env.d.ts",
25+
"**/*.ts",
26+
"**/*.tsx",
27+
".next/types/**/*.ts",
28+
".next/dev/types/**/*.ts"
29+
],
2430
"exclude": ["node_modules"]
2531
}

packages/next/src/next-devtools/dev-overlay/components/hot-linked-text/index.tsx

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,59 @@
11
import React from 'react'
2-
import {
3-
decodeMagicIdentifier,
4-
MAGIC_IDENTIFIER_REGEX,
5-
} from '../../../../shared/lib/magic-identifier'
2+
import { deobfuscateTextParts } from '../../../../shared/lib/magic-identifier'
63

74
const linkRegex = /https?:\/\/[^\s/$.?#].[^\s)'"]*/i
85

9-
const splitRegexp = new RegExp(`(${MAGIC_IDENTIFIER_REGEX.source}|\\s+)`)
10-
116
export const HotlinkedText: React.FC<{
127
text: string
138
matcher?: (text: string) => boolean
149
}> = function HotlinkedText(props) {
1510
const { text, matcher } = props
1611

17-
const wordsAndWhitespaces = text.split(splitRegexp)
12+
// Deobfuscate the entire text first
13+
const deobfuscatedParts = deobfuscateTextParts(text)
1814

1915
return (
2016
<>
21-
{wordsAndWhitespaces.map((word, index) => {
22-
if (linkRegex.test(word)) {
23-
const link = linkRegex.exec(word)!
24-
const href = link[0]
25-
// If link matcher is present but the link doesn't match, don't turn it into a link
26-
if (typeof matcher === 'function' && !matcher(href)) {
27-
return word
28-
}
29-
return (
30-
<React.Fragment key={`link-${index}`}>
31-
<a href={href} target="_blank" rel="noreferrer noopener">
32-
{word}
33-
</a>
34-
</React.Fragment>
35-
)
36-
}
37-
try {
38-
const decodedWord = decodeMagicIdentifier(word)
39-
if (decodedWord !== word) {
40-
return (
41-
<i key={`ident-${index}`}>
42-
{'{'}
43-
{decodedWord}
44-
{'}'}
45-
</i>
46-
)
47-
}
48-
} catch (e) {
17+
{deobfuscatedParts.map(([type, part], outerIndex) => {
18+
if (type === 'raw') {
4919
return (
50-
<i key={`ident-${index}`}>
51-
{'{'}
52-
{word} (decoding failed: {'' + e}){'}'}
53-
</i>
20+
part
21+
// Split on whitespace and links
22+
.split(/(\s+|https?:\/\/[^\s/$.?#].[^\s)'"]*)/)
23+
.map((rawPart, index) => {
24+
if (linkRegex.test(rawPart)) {
25+
const link = linkRegex.exec(rawPart)!
26+
const href = link[0]
27+
// If link matcher is present but the link doesn't match, don't turn it into a link
28+
if (typeof matcher === 'function' && !matcher(href)) {
29+
return (
30+
<React.Fragment key={`link-${outerIndex}-${index}`}>
31+
{rawPart}
32+
</React.Fragment>
33+
)
34+
}
35+
return (
36+
<React.Fragment key={`link-${outerIndex}-${index}`}>
37+
<a href={href} target="_blank" rel="noreferrer noopener">
38+
{rawPart}
39+
</a>
40+
</React.Fragment>
41+
)
42+
} else {
43+
return (
44+
<React.Fragment key={`text-${outerIndex}-${index}`}>
45+
{rawPart}
46+
</React.Fragment>
47+
)
48+
}
49+
})
5450
)
51+
} else if (type === 'deobfuscated') {
52+
// italicize the deobfuscated part
53+
return <i key={`ident-${outerIndex}`}>{part}</i>
54+
} else {
55+
throw new Error(`Unknown text part type: ${type}`)
5556
}
56-
return <React.Fragment key={`text-${index}`}>{word}</React.Fragment>
5757
})}
5858
</>
5959
)

packages/next/src/server/lib/router-utils/setup-dev-bundler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
import { isParallelRouteSegment } from '../../../shared/lib/segment'
8989
import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash'
9090
import { Lockfile } from '../../../build/lockfile'
91+
import { deobfuscateText } from '../../../shared/lib/magic-identifier'
9192

9293
export type SetupOpts = {
9394
renderServer: LazyRenderServerInstance
@@ -1224,6 +1225,9 @@ async function startWatcher(
12241225
err: unknown,
12251226
type?: 'unhandledRejection' | 'uncaughtException' | 'warning' | 'app-dir'
12261227
) {
1228+
if (err instanceof Error) {
1229+
err.message = deobfuscateText(err.message)
1230+
}
12271231
if (err instanceof ModuleBuildError) {
12281232
// Errors that may come from issues from the user's code
12291233
Log.error(err.message)
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import {
2+
decodeMagicIdentifier,
3+
MAGIC_IDENTIFIER_REGEX,
4+
deobfuscateModuleId,
5+
removeFreeCallWrapper,
6+
deobfuscateText,
7+
deobfuscateTextParts,
8+
} from './magic-identifier'
9+
10+
describe('decodeMagicIdentifier', () => {
11+
// Basic decoding tests (ported from Rust)
12+
test('decodes module evaluation', () => {
13+
expect(decodeMagicIdentifier('__TURBOPACK__module__evaluation__')).toBe(
14+
'module evaluation'
15+
)
16+
})
17+
18+
test('decodes path with slashes', () => {
19+
expect(decodeMagicIdentifier('__TURBOPACK__Hello$2f$World__')).toBe(
20+
'Hello/World'
21+
)
22+
})
23+
24+
test('decodes emoji', () => {
25+
expect(decodeMagicIdentifier('__TURBOPACK__Hello$_1f600$World__')).toBe(
26+
'Hello😀World'
27+
)
28+
})
29+
30+
test('returns unchanged if not a magic identifier', () => {
31+
expect(decodeMagicIdentifier('regular_identifier')).toBe(
32+
'regular_identifier'
33+
)
34+
})
35+
})
36+
37+
describe('MAGIC_IDENTIFIER_REGEX', () => {
38+
test('matches magic identifiers globally', () => {
39+
const text =
40+
'Hello __TURBOPACK__Hello__World__ and __TURBOPACK__foo$2f$bar__'
41+
const matches = text.match(MAGIC_IDENTIFIER_REGEX)
42+
expect(matches).toHaveLength(2)
43+
})
44+
})
45+
46+
describe('deobfuscateModuleId', () => {
47+
test('replaces [project] with .', () => {
48+
expect(
49+
deobfuscateModuleId('[project]/examples/with-turbopack/app/foo.ts')
50+
).toBe('./examples/with-turbopack/app/foo.ts')
51+
})
52+
53+
test('removes content in square brackets', () => {
54+
expect(
55+
deobfuscateModuleId('./examples/with-turbopack/app/foo.ts [app-rsc]')
56+
).toBe('./examples/with-turbopack/app/foo.ts')
57+
})
58+
59+
test('removes content in parentheses', () => {
60+
expect(
61+
deobfuscateModuleId('./examples/with-turbopack/app/foo.ts (ecmascript)')
62+
).toBe('./examples/with-turbopack/app/foo.ts')
63+
})
64+
65+
test('removes content in angle brackets', () => {
66+
expect(
67+
deobfuscateModuleId('./examples/with-turbopack/app/foo.ts <locals>')
68+
).toBe('./examples/with-turbopack/app/foo.ts')
69+
})
70+
71+
test('handles combined cleanup', () => {
72+
expect(
73+
deobfuscateModuleId(
74+
'[project]/examples/with-turbopack/app/foo.ts [app-rsc] (ecmascript)'
75+
)
76+
).toBe('./examples/with-turbopack/app/foo.ts')
77+
})
78+
79+
test('handles parenthesis in path', () => {
80+
expect(
81+
deobfuscateModuleId(
82+
'[project]/examples/(group)/with-turbopack/app/foo.ts [app-rsc] (ecmascript)'
83+
)
84+
).toBe('./examples/(group)/with-turbopack/app/foo.ts')
85+
})
86+
})
87+
88+
describe('removeFreeCallWrapper', () => {
89+
test('removes (0, ) wrapper', () => {
90+
expect(removeFreeCallWrapper('(0, __TURBOPACK__foo__.bar)')).toBe(
91+
'__TURBOPACK__foo__.bar'
92+
)
93+
})
94+
95+
test('removes (0 , ) wrapper with spaces', () => {
96+
expect(removeFreeCallWrapper('(0 , __TURBOPACK__foo__.bar)')).toBe(
97+
'__TURBOPACK__foo__.bar'
98+
)
99+
})
100+
101+
test('leaves non-free-call expressions unchanged', () => {
102+
expect(removeFreeCallWrapper('(foo, bar)')).toBe('(foo, bar)')
103+
expect(removeFreeCallWrapper('foo()')).toBe('foo()')
104+
})
105+
})
106+
107+
describe('deobfuscateText', () => {
108+
test('deobfuscates complete error message with imported module', () => {
109+
const input =
110+
'(0 , __TURBOPACK__imported__module__$5b$project$5d2f$examples$2f$with$2d$turbopack$2f$app$2f$foo$2e$ts__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__.foo) is not a function'
111+
const output = deobfuscateText(input)
112+
expect(output).toBe(
113+
'{imported module ./examples/with-turbopack/app/foo.ts}.foo is not a function'
114+
)
115+
})
116+
117+
test('handles multiple magic identifiers', () => {
118+
const input =
119+
'__TURBOPACK__module__evaluation__ called __TURBOPACK__foo$2f$bar__'
120+
const output = deobfuscateText(input)
121+
expect(output).toBe('{module evaluation} called {foo/bar}')
122+
})
123+
124+
test('leaves regular text unchanged', () => {
125+
const input = 'This is a regular error message'
126+
expect(deobfuscateText(input)).toBe(input)
127+
})
128+
})
129+
130+
describe('deobfuscateTextParts', () => {
131+
test('returns discriminated parts with raw and deobfuscated text', () => {
132+
const input = 'Error in __TURBOPACK__module__evaluation__ at line 10'
133+
const output = deobfuscateTextParts(input)
134+
expect(output).toEqual([
135+
['raw', 'Error in '],
136+
['deobfuscated', '{module evaluation}'],
137+
['raw', ' at line 10'],
138+
])
139+
})
140+
141+
test('handles multiple magic identifiers with interleaved raw text', () => {
142+
const input =
143+
'__TURBOPACK__module__evaluation__ called __TURBOPACK__foo$2f$bar__'
144+
const output = deobfuscateTextParts(input)
145+
expect(output).toEqual([
146+
['deobfuscated', '{module evaluation}'],
147+
['raw', ' called '],
148+
['deobfuscated', '{foo/bar}'],
149+
])
150+
})
151+
152+
test('returns single raw part for text without magic identifiers', () => {
153+
const input = 'This is a regular error message'
154+
const output = deobfuscateTextParts(input)
155+
expect(output).toEqual([['raw', 'This is a regular error message']])
156+
})
157+
158+
test('handles imported module with free call wrapper', () => {
159+
const input =
160+
'(0 , __TURBOPACK__imported__module__$5b$project$5d2f$examples$2f$with$2d$turbopack$2f$app$2f$foo$2e$ts__$5b$app$2d$rsc$5d$__$28$ecmascript$29$__.foo) is not a function'
161+
const output = deobfuscateTextParts(input)
162+
expect(output).toEqual([
163+
[
164+
'deobfuscated',
165+
'{imported module ./examples/with-turbopack/app/foo.ts}',
166+
],
167+
['raw', '.foo is not a function'],
168+
])
169+
})
170+
171+
test('produces same result as deobfuscateText when joined', () => {
172+
const input =
173+
'Error in __TURBOPACK__module__evaluation__ at __TURBOPACK__foo$2f$bar__'
174+
const parts = deobfuscateTextParts(input)
175+
const joined = parts.map((part) => part[1]).join('')
176+
expect(joined).toBe(deobfuscateText(input))
177+
})
178+
})

0 commit comments

Comments
 (0)