react-spot 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -0
- package/package.json +42 -0
- package/src/core/chain-transformer.ts +89 -0
- package/src/core/fiber-utils.ts +684 -0
- package/src/core/source-location-resolver.test.ts +415 -0
- package/src/core/source-location-resolver.ts +801 -0
- package/src/core/types.ts +79 -0
- package/src/index.ts +26 -0
- package/src/react/ReactSpot.tsx +1058 -0
- package/src/react/components/ui/index.ts +1 -0
- package/src/react/components/ui/popover.tsx +24 -0
- package/src/transformers/formatted-message.ts +159 -0
- package/src/transformers/transformer-rule.ts +386 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
import { SourceMapConsumer } from '@jridgewell/source-map';
|
|
2
|
+
import * as convertSourceMap from 'convert-source-map';
|
|
3
|
+
|
|
4
|
+
export interface StackFrameInfo {
|
|
5
|
+
url: string;
|
|
6
|
+
line: number;
|
|
7
|
+
column: number;
|
|
8
|
+
functionName?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OriginalSourceInfo {
|
|
12
|
+
source: string;
|
|
13
|
+
line: number;
|
|
14
|
+
column: number;
|
|
15
|
+
name?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResolvedSourceInfo extends OriginalSourceInfo {
|
|
19
|
+
sourceContent?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CachedSourceMapData {
|
|
23
|
+
sourceContent: string;
|
|
24
|
+
sourceMapContent: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CachedResult {
|
|
28
|
+
originalSource: ResolvedSourceInfo;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MAX_RESULT_CACHE_SIZE = 500;
|
|
32
|
+
const MAX_SOURCE_MAP_CACHE_SIZE = 100;
|
|
33
|
+
|
|
34
|
+
/** Sets a value on a Map, evicting the oldest entry if the map exceeds `maxSize`. */
|
|
35
|
+
function boundedSet<K, V>(map: Map<K, V>, key: K, value: V, maxSize: number): void {
|
|
36
|
+
map.delete(key); // re-insert to refresh position (Map preserves insertion order)
|
|
37
|
+
map.set(key, value);
|
|
38
|
+
if (map.size > maxSize) {
|
|
39
|
+
// The first key is the oldest entry
|
|
40
|
+
const oldest = map.keys().next().value;
|
|
41
|
+
if (oldest !== undefined) {
|
|
42
|
+
map.delete(oldest);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sourceMapCache = new Map<string, CachedSourceMapData>();
|
|
48
|
+
const resultCache = new Map<string, CachedResult>();
|
|
49
|
+
|
|
50
|
+
// ─── Source root configuration ──────────────────────────────────────────────
|
|
51
|
+
// Allows converting URL-relative paths (e.g. /src/scenarios/DeepChain.tsx)
|
|
52
|
+
// into absolute filesystem paths that the editor can open.
|
|
53
|
+
//
|
|
54
|
+
// Set via:
|
|
55
|
+
// 1. configureSourceRoot('/absolute/path/to/project') — programmatic
|
|
56
|
+
// 2. window.__SHOW_COMPONENT_SOURCE_ROOT__ = '/abs/path' — global
|
|
57
|
+
// 3. <ReactSpot sourceRoot="/abs/path" /> — prop (calls #1)
|
|
58
|
+
//
|
|
59
|
+
// When unset, resolved paths are URL-relative (e.g. /src/scenarios/Foo.tsx).
|
|
60
|
+
|
|
61
|
+
let _sourceRoot: string | undefined;
|
|
62
|
+
|
|
63
|
+
export function configureSourceRoot(root: string | undefined): void {
|
|
64
|
+
_sourceRoot = root ? root.replace(/\/+$/, '') : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getSourceRoot(): string | undefined {
|
|
68
|
+
return (
|
|
69
|
+
_sourceRoot ??
|
|
70
|
+
(typeof window !== 'undefined'
|
|
71
|
+
? ((window as unknown as Record<string, unknown>).__SHOW_COMPONENT_SOURCE_ROOT__ as
|
|
72
|
+
| string
|
|
73
|
+
| undefined)
|
|
74
|
+
: undefined)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolves a potentially-relative source path from a source map against
|
|
80
|
+
* the URL of the file that contained the source map.
|
|
81
|
+
*
|
|
82
|
+
* Example:
|
|
83
|
+
* rawSource = "DeepChain.tsx"
|
|
84
|
+
* sourceUrl = "http://localhost:5200/src/scenarios/DeepChain.tsx"
|
|
85
|
+
* sourceRoot from source map = "" (empty)
|
|
86
|
+
* → resolved = "/src/scenarios/DeepChain.tsx"
|
|
87
|
+
*
|
|
88
|
+
* If a filesystem sourceRoot is configured:
|
|
89
|
+
* → "/Users/me/project/src/scenarios/DeepChain.tsx"
|
|
90
|
+
*/
|
|
91
|
+
/** @internal — exported for testing */
|
|
92
|
+
export function resolveSourcePath(
|
|
93
|
+
rawSource: string,
|
|
94
|
+
sourceMapSourceRoot: string | undefined,
|
|
95
|
+
sourceFileUrl: string
|
|
96
|
+
): string {
|
|
97
|
+
// Strip file:// protocol — Turbopack emits sources like
|
|
98
|
+
// "file:///Users/me/project/src/Foo.tsx" which are already absolute
|
|
99
|
+
// filesystem paths once the scheme is removed.
|
|
100
|
+
if (rawSource.startsWith('file://')) {
|
|
101
|
+
const stripped = rawSource.replace(/^file:\/\//, '');
|
|
102
|
+
// After removing "file://" we have "/Users/me/project/…" (absolute)
|
|
103
|
+
if (stripped.startsWith('/')) {
|
|
104
|
+
return stripped;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 处理以 / 开头的路径,分两种情况:
|
|
109
|
+
// 1. 真实的文件系统绝对路径(如 /Users/me/project/src/Foo.tsx)—— 直接返回
|
|
110
|
+
// 2. Turbopack 虚拟根相对路径(如 /src/Foo.tsx)—— 需要拼接 fsRoot
|
|
111
|
+
// 区分方法:若 fsRoot 已配置且路径已以 fsRoot 开头,说明是真实绝对路径;
|
|
112
|
+
// 否则一律拼接 fsRoot。不配置 fsRoot 时,含 /src/ 的路径假定已足够深而直接返回。
|
|
113
|
+
if (rawSource.startsWith('/') && !rawSource.startsWith('//')) {
|
|
114
|
+
const fsRoot = getSourceRoot();
|
|
115
|
+
if (fsRoot) {
|
|
116
|
+
// 路径已经以项目根开头,是真实绝对路径,避免重复拼接
|
|
117
|
+
if (rawSource.startsWith(fsRoot)) {
|
|
118
|
+
return rawSource;
|
|
119
|
+
}
|
|
120
|
+
// Turbopack 虚拟根相对路径,拼接项目根后才是真实路径
|
|
121
|
+
return fsRoot + rawSource;
|
|
122
|
+
}
|
|
123
|
+
// 未配置 fsRoot:含 /src/ 或 /node_modules/ 的路径通常已足够深,视为绝对路径直接返回
|
|
124
|
+
if (rawSource.includes('/src/') || rawSource.includes('/node_modules/')) {
|
|
125
|
+
return rawSource;
|
|
126
|
+
}
|
|
127
|
+
return rawSource;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Strip webpack:/// or similar protocol prefixes
|
|
131
|
+
let cleaned = rawSource.replace(/^webpack:\/\/\//, '').replace(/^\.\/?/, '');
|
|
132
|
+
|
|
133
|
+
// If the source map provided a sourceRoot, use it
|
|
134
|
+
if (sourceMapSourceRoot && sourceMapSourceRoot !== '/' && sourceMapSourceRoot !== '') {
|
|
135
|
+
const root = sourceMapSourceRoot.replace(/\/+$/, '');
|
|
136
|
+
cleaned = `${root}/${cleaned}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// If still relative, resolve against the source file URL
|
|
140
|
+
if (!cleaned.startsWith('/') && !cleaned.startsWith('http')) {
|
|
141
|
+
try {
|
|
142
|
+
const base = new URL(sourceFileUrl);
|
|
143
|
+
const dir = base.pathname.substring(0, base.pathname.lastIndexOf('/') + 1);
|
|
144
|
+
cleaned = dir + cleaned;
|
|
145
|
+
// Normalize /../ and /./ sequences
|
|
146
|
+
cleaned = new URL(cleaned, base.origin).pathname;
|
|
147
|
+
} catch {
|
|
148
|
+
// If URL parsing fails, fall through with what we have
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// If a filesystem source root is configured, convert URL path → absolute path
|
|
153
|
+
const fsRoot = getSourceRoot();
|
|
154
|
+
if (fsRoot && cleaned.startsWith('/')) {
|
|
155
|
+
return fsRoot + cleaned;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return cleaned;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Extracts URL, line, and column from a stack trace frame.
|
|
163
|
+
*
|
|
164
|
+
* Supported formats:
|
|
165
|
+
* - Chrome/V8: `at fn (url:line:col)` or `at url:line:col`
|
|
166
|
+
* - Firefox/Safari: `fn@url:line:col`
|
|
167
|
+
*/
|
|
168
|
+
export function extractStackFrameInfo(stackLine: string): StackFrameInfo | null {
|
|
169
|
+
const patterns = [
|
|
170
|
+
/at\s+([^(]+)\s*\((.+):(\d+):(\d+)\)/, // at fn (url:line:col)
|
|
171
|
+
/at\s+(.+):(\d+):(\d+)/, // at url:line:col
|
|
172
|
+
/([^@]+)@(.+):(\d+):(\d+)/, // fn@url:line:col
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
for (const pattern of patterns) {
|
|
176
|
+
const match = stackLine.match(pattern);
|
|
177
|
+
if (match) {
|
|
178
|
+
if (match.length === 5) {
|
|
179
|
+
return {
|
|
180
|
+
functionName: match[1].trim(),
|
|
181
|
+
url: match[2],
|
|
182
|
+
line: Number.parseInt(match[3], 10),
|
|
183
|
+
column: Number.parseInt(match[4], 10),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (match.length === 4) {
|
|
187
|
+
return {
|
|
188
|
+
url: match[1],
|
|
189
|
+
line: Number.parseInt(match[2], 10),
|
|
190
|
+
column: Number.parseInt(match[3], 10),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Matches React Server Component debug URLs.
|
|
201
|
+
*
|
|
202
|
+
* React / Next.js emits stack frames with special schemes that embed a
|
|
203
|
+
* `file:///` filesystem path. Known variants:
|
|
204
|
+
* - `rsc://React/Server/file:///…` (React Flight / older Turbopack)
|
|
205
|
+
* - `about://React/Server/file:///…` (newer Turbopack builds)
|
|
206
|
+
*
|
|
207
|
+
* This pattern captures everything after the `file://` portion.
|
|
208
|
+
*/
|
|
209
|
+
const REACT_SERVER_URL_RE = /^(?:rsc|about):\/\/React\/Server\/file:\/\/(.+?)(\?.*)?$/;
|
|
210
|
+
|
|
211
|
+
/** Returns `true` when the URL uses a known React Server Component debug scheme. */
|
|
212
|
+
function isReactServerUrl(url: string): boolean {
|
|
213
|
+
return url.startsWith('rsc://React/Server/') || url.startsWith('about://React/Server/');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Returns `true` when the URL is a Next.js-specific RSC URL.
|
|
218
|
+
*
|
|
219
|
+
* Next.js RSC URLs contain `/.next/` in the path (the build output directory),
|
|
220
|
+
* which distinguishes them from RSC URLs potentially produced by other frameworks.
|
|
221
|
+
*/
|
|
222
|
+
function isNextjsRscUrl(url: string): boolean {
|
|
223
|
+
return isReactServerUrl(url) && url.includes('/.next/');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Extracts the absolute filesystem path from an RSC debug URL.
|
|
228
|
+
* URL-decodes the path to handle percent-encoded characters (e.g. %5B → [).
|
|
229
|
+
*
|
|
230
|
+
* Example:
|
|
231
|
+
* about://React/Server/file:///Users/me/proj/.next/server/chunks/ssr/%5Broot%5D__abc._.js?20
|
|
232
|
+
* → /Users/me/proj/.next/server/chunks/ssr/[root]__abc._.js
|
|
233
|
+
*/
|
|
234
|
+
function extractFilePathFromRscUrl(rscUrl: string): string | null {
|
|
235
|
+
const match = rscUrl.match(REACT_SERVER_URL_RE);
|
|
236
|
+
if (!match) return null;
|
|
237
|
+
try {
|
|
238
|
+
return decodeURIComponent(match[1]);
|
|
239
|
+
} catch {
|
|
240
|
+
return match[1];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── Next.js Dev Server integration ─────────────────────────────────────────
|
|
245
|
+
// Next.js dev server exposes built-in endpoints for source-map resolution:
|
|
246
|
+
// POST /__nextjs_original-stack-frames — full server-side stack frame resolution
|
|
247
|
+
// GET /__nextjs_source-map?filename=… — raw source map for a given file
|
|
248
|
+
//
|
|
249
|
+
// These leverage the bundler's in-memory compilation state (Webpack / Turbopack)
|
|
250
|
+
// and are more reliable than fetching compiled JS to extract source maps client-side.
|
|
251
|
+
|
|
252
|
+
/** Next.js StackFrame shape expected by `__nextjs_original-stack-frames`. */
|
|
253
|
+
interface NextStackFrame {
|
|
254
|
+
file: string | null;
|
|
255
|
+
methodName: string;
|
|
256
|
+
arguments: string[];
|
|
257
|
+
line1: number | null;
|
|
258
|
+
column1: number | null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface NextOriginalStackFrameResponse {
|
|
262
|
+
originalStackFrame: (NextStackFrame & { ignored: boolean }) | null;
|
|
263
|
+
originalCodeFrame: string | null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
type NextOriginalStackFramesResponse = Array<
|
|
267
|
+
| { status: 'fulfilled'; value: NextOriginalStackFrameResponse }
|
|
268
|
+
| { status: 'rejected'; reason: string }
|
|
269
|
+
>;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Tracks whether the Next.js dev server's stack-frame endpoint is available.
|
|
273
|
+
* `undefined` = not yet probed, `true` = available, `false` = not available.
|
|
274
|
+
*/
|
|
275
|
+
let _nextDevServerAvailable: boolean | undefined;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Resolves a React Server Component stack frame via the Next.js dev server's
|
|
279
|
+
* built-in `POST /__nextjs_original-stack-frames` endpoint.
|
|
280
|
+
*
|
|
281
|
+
* This performs source map resolution server-side with full access to the
|
|
282
|
+
* bundler's compilation state, which is more reliable than client-side
|
|
283
|
+
* resolution for server-rendered files whose compiled JS isn't directly
|
|
284
|
+
* fetchable via HTTP.
|
|
285
|
+
*/
|
|
286
|
+
async function resolveViaNextDevServer(
|
|
287
|
+
frameInfo: StackFrameInfo,
|
|
288
|
+
debug?: boolean
|
|
289
|
+
): Promise<ResolvedSourceInfo | null> {
|
|
290
|
+
if (_nextDevServerAvailable === false) {
|
|
291
|
+
if (debug) console.log('Next.js dev server endpoint previously unavailable, skipping');
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const filePath = extractFilePathFromRscUrl(frameInfo.url);
|
|
296
|
+
if (!filePath) return null;
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const response = await fetch(`${window.location.origin}/__nextjs_original-stack-frames`, {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
headers: { 'Content-Type': 'application/json' },
|
|
302
|
+
body: JSON.stringify({
|
|
303
|
+
frames: [
|
|
304
|
+
{
|
|
305
|
+
file: filePath,
|
|
306
|
+
methodName: frameInfo.functionName || '<unknown>',
|
|
307
|
+
arguments: [],
|
|
308
|
+
line1: frameInfo.line,
|
|
309
|
+
column1: frameInfo.column,
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
isServer: true,
|
|
313
|
+
isEdgeServer: false,
|
|
314
|
+
isAppDirectory: true,
|
|
315
|
+
}),
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
if (response.status === 404) {
|
|
320
|
+
_nextDevServerAvailable = false;
|
|
321
|
+
if (debug) console.log('Next.js __nextjs_original-stack-frames not found (404), disabling');
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
_nextDevServerAvailable = true;
|
|
327
|
+
|
|
328
|
+
const results: NextOriginalStackFramesResponse = await response.json();
|
|
329
|
+
if (!results.length) return null;
|
|
330
|
+
|
|
331
|
+
const first = results[0];
|
|
332
|
+
if (first.status !== 'fulfilled' || !first.value.originalStackFrame) {
|
|
333
|
+
if (debug)
|
|
334
|
+
console.log(
|
|
335
|
+
'Next.js stack frame resolution rejected:',
|
|
336
|
+
first.status === 'rejected' ? first.reason : 'no original frame'
|
|
337
|
+
);
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const { originalStackFrame } = first.value;
|
|
342
|
+
let sourcePath = originalStackFrame.file || frameInfo.url;
|
|
343
|
+
|
|
344
|
+
// Next.js returns project-relative paths (e.g. "src/app/page.tsx").
|
|
345
|
+
// Convert to absolute using the configured source root so the editor
|
|
346
|
+
// can open the file directly.
|
|
347
|
+
if (
|
|
348
|
+
sourcePath &&
|
|
349
|
+
!sourcePath.startsWith('/') &&
|
|
350
|
+
!sourcePath.startsWith('file://') &&
|
|
351
|
+
!sourcePath.includes('://')
|
|
352
|
+
) {
|
|
353
|
+
const fsRoot = getSourceRoot();
|
|
354
|
+
if (debug) {
|
|
355
|
+
console.log('Source path is relative, resolving with sourceRoot:', {
|
|
356
|
+
sourcePath,
|
|
357
|
+
fsRoot,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
if (fsRoot) {
|
|
361
|
+
sourcePath = `${fsRoot}/${sourcePath}`;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (debug) console.log('Final resolved source path:', sourcePath);
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
source: sourcePath,
|
|
369
|
+
line: originalStackFrame.line1 ?? frameInfo.line,
|
|
370
|
+
column: originalStackFrame.column1 ?? frameInfo.column,
|
|
371
|
+
name: originalStackFrame.methodName || undefined,
|
|
372
|
+
};
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (debug) console.warn('Next.js dev server resolution failed:', error);
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Fetches a raw source map from the Next.js dev server's
|
|
381
|
+
* `GET /__nextjs_source-map?filename=…` endpoint.
|
|
382
|
+
*
|
|
383
|
+
* Returns the source map JSON string, or `null` if unavailable.
|
|
384
|
+
*/
|
|
385
|
+
async function fetchSourceMapFromNextDevServer(
|
|
386
|
+
filePath: string,
|
|
387
|
+
debug?: boolean
|
|
388
|
+
): Promise<string | null> {
|
|
389
|
+
try {
|
|
390
|
+
const url = `${window.location.origin}/__nextjs_source-map?filename=${encodeURIComponent(filePath)}`;
|
|
391
|
+
if (debug) console.log('Fetching source map from Next.js dev server:', url);
|
|
392
|
+
|
|
393
|
+
const response = await fetch(url);
|
|
394
|
+
if (!response.ok) {
|
|
395
|
+
if (debug) console.log('__nextjs_source-map returned:', response.status);
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return await response.text();
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (debug) console.warn('Failed to fetch source map from Next.js dev server:', error);
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Returns `true` when the URL uses a scheme that we cannot fetch
|
|
408
|
+
* (e.g. `chrome-extension://`, `blob:`, `data:`, `rsc://`, `about://`, etc.).
|
|
409
|
+
*
|
|
410
|
+
* Allows: `http(s)://` and relative URLs (no scheme).
|
|
411
|
+
*
|
|
412
|
+
* Note: React Server Component URLs (`rsc://`, `about://React/Server/…`) are
|
|
413
|
+
* handled by the Next.js dev server fast path in {@link resolveLocation} and
|
|
414
|
+
* are intentionally *not* fetchable through this path.
|
|
415
|
+
*/
|
|
416
|
+
function hasNonFetchableScheme(url: string): boolean {
|
|
417
|
+
// Relative URLs and http(s) are fine
|
|
418
|
+
if (url.startsWith('/') || url.startsWith('http://') || url.startsWith('https://')) return false;
|
|
419
|
+
// Anything else with a "scheme://" prefix is non-fetchable
|
|
420
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(url);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Fetches a source file over HTTP.
|
|
425
|
+
*
|
|
426
|
+
* Accepts absolute `http(s)://` URLs and root-relative paths (resolved against
|
|
427
|
+
* `window.location.origin`). Non-HTTP schemes (including RSC debug URLs) are
|
|
428
|
+
* rejected — RSC resolution is handled by the Next.js fast path in
|
|
429
|
+
* {@link resolveLocation}.
|
|
430
|
+
*/
|
|
431
|
+
export async function fetchSourceFile(
|
|
432
|
+
url: string
|
|
433
|
+
): Promise<{ content: string; effectiveUrl: string }> {
|
|
434
|
+
if (hasNonFetchableScheme(url)) {
|
|
435
|
+
throw new Error(`Non-fetchable URL scheme: ${url}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const fetchUrl = url.startsWith('http') ? url : `${window.location.origin}${url}`;
|
|
439
|
+
|
|
440
|
+
const response = await fetch(fetchUrl);
|
|
441
|
+
if (!response.ok) {
|
|
442
|
+
throw new Error(`Failed to fetch source file: ${response.status} ${response.statusText}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const content = await response.text();
|
|
446
|
+
return { content, effectiveUrl: fetchUrl };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Returns `"inline"` for data-URL source maps, the external URL string
|
|
451
|
+
* for `//# sourceMappingURL=…` comments, or `null` when absent.
|
|
452
|
+
*
|
|
453
|
+
* convert-source-map handles inline (data-URL) maps; for external maps
|
|
454
|
+
* we scan the last 10 lines manually because the library doesn't expose
|
|
455
|
+
* the raw URL.
|
|
456
|
+
*/
|
|
457
|
+
function extractSourceMapUrl(sourceContent: string): string | null {
|
|
458
|
+
// 1. Check for inline (data-URL) source maps first
|
|
459
|
+
const converter = convertSourceMap.fromSource(sourceContent);
|
|
460
|
+
if (converter) {
|
|
461
|
+
const sourceMapObj = converter.toObject();
|
|
462
|
+
if (sourceMapObj && Object.keys(sourceMapObj).length > 0) {
|
|
463
|
+
return 'inline';
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 2. Fall through to external //# sourceMappingURL=… scan.
|
|
468
|
+
// This is the common case for Turbopack / webpack / esbuild
|
|
469
|
+
// production builds that emit a separate .map file.
|
|
470
|
+
const lines = sourceContent.split('\n');
|
|
471
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 10); i--) {
|
|
472
|
+
const match = lines[i].trim().match(/^\/\/[@#]\s*sourceMappingURL=(.+)$/);
|
|
473
|
+
if (match) {
|
|
474
|
+
return match[1].trim();
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Resolves source map content — inline (data URL) or external (fetched). */
|
|
482
|
+
export async function resolveSourceMap(
|
|
483
|
+
sourceContent: string,
|
|
484
|
+
sourceUrl: string
|
|
485
|
+
): Promise<string | null> {
|
|
486
|
+
const sourceMapUrl = extractSourceMapUrl(sourceContent);
|
|
487
|
+
if (!sourceMapUrl) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (sourceMapUrl === 'inline') {
|
|
492
|
+
const converter = convertSourceMap.fromSource(sourceContent);
|
|
493
|
+
return converter ? converter.toJSON() : null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Data URL fallback for edge cases not handled by convert-source-map
|
|
497
|
+
if (sourceMapUrl.startsWith('data:')) {
|
|
498
|
+
const base64 = sourceMapUrl.match(/^data:application\/json;(?:charset=utf-8;)?base64,(.+)$/);
|
|
499
|
+
if (base64) {
|
|
500
|
+
return atob(base64[1]);
|
|
501
|
+
}
|
|
502
|
+
const plainJson = sourceMapUrl.match(/^data:application\/json;charset=utf-8,(.+)$/);
|
|
503
|
+
if (plainJson) {
|
|
504
|
+
return decodeURIComponent(plainJson[1]);
|
|
505
|
+
}
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// External source map — resolve to absolute URL
|
|
510
|
+
let absoluteSourceMapUrl: string;
|
|
511
|
+
if (sourceMapUrl.startsWith('http')) {
|
|
512
|
+
absoluteSourceMapUrl = sourceMapUrl;
|
|
513
|
+
} else if (sourceMapUrl.startsWith('/')) {
|
|
514
|
+
const baseUrl = new URL(sourceUrl);
|
|
515
|
+
absoluteSourceMapUrl = `${baseUrl.protocol}//${baseUrl.host}${sourceMapUrl}`;
|
|
516
|
+
} else {
|
|
517
|
+
const baseUrl = new URL(sourceUrl);
|
|
518
|
+
const basePath = baseUrl.pathname.substring(0, baseUrl.pathname.lastIndexOf('/') + 1);
|
|
519
|
+
absoluteSourceMapUrl = `${baseUrl.protocol}//${baseUrl.host}${basePath}${sourceMapUrl}`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
const response = await fetch(absoluteSourceMapUrl);
|
|
524
|
+
if (!response.ok) {
|
|
525
|
+
throw new Error(`Failed to fetch source map: ${response.status} ${response.statusText}`);
|
|
526
|
+
}
|
|
527
|
+
return await response.text();
|
|
528
|
+
} catch (error) {
|
|
529
|
+
console.warn(`Failed to fetch source map from ${absoluteSourceMapUrl}:`, error);
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Maps a generated position to the original source via source map.
|
|
536
|
+
* Returns the raw source path as-is — the caller resolves it (see `resolveSourcePath`).
|
|
537
|
+
*/
|
|
538
|
+
export async function mapToOriginalSource(
|
|
539
|
+
frameInfo: StackFrameInfo,
|
|
540
|
+
sourceMapContent: string
|
|
541
|
+
): Promise<{ info: OriginalSourceInfo; sourceRoot?: string } | null> {
|
|
542
|
+
try {
|
|
543
|
+
const sourceMap = JSON.parse(sourceMapContent);
|
|
544
|
+
|
|
545
|
+
const consumer = new SourceMapConsumer(sourceMap);
|
|
546
|
+
|
|
547
|
+
const originalPosition = consumer.originalPositionFor({
|
|
548
|
+
line: frameInfo.line,
|
|
549
|
+
column: frameInfo.column,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
if (
|
|
553
|
+
originalPosition.source &&
|
|
554
|
+
originalPosition.line !== null &&
|
|
555
|
+
originalPosition.column !== null
|
|
556
|
+
) {
|
|
557
|
+
return {
|
|
558
|
+
info: {
|
|
559
|
+
source: originalPosition.source,
|
|
560
|
+
line: originalPosition.line,
|
|
561
|
+
column: originalPosition.column,
|
|
562
|
+
name: originalPosition.name || undefined,
|
|
563
|
+
},
|
|
564
|
+
sourceRoot: sourceMap.sourceRoot,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return null;
|
|
569
|
+
} catch (error) {
|
|
570
|
+
console.error('Error parsing source map:', error);
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Retrieves the original source content embedded in the source map, if available. */
|
|
576
|
+
export async function getOriginalSourceContent(
|
|
577
|
+
originalInfo: OriginalSourceInfo,
|
|
578
|
+
sourceMapContent: string
|
|
579
|
+
): Promise<string | null> {
|
|
580
|
+
try {
|
|
581
|
+
const sourceMap = JSON.parse(sourceMapContent);
|
|
582
|
+
const consumer = new SourceMapConsumer(sourceMap);
|
|
583
|
+
|
|
584
|
+
const sourceContent = consumer.sourceContentFor(originalInfo.source);
|
|
585
|
+
|
|
586
|
+
return sourceContent;
|
|
587
|
+
} catch (error) {
|
|
588
|
+
console.error('Error getting source content:', error);
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Resolves a stack trace line to original source location using source maps.
|
|
595
|
+
* Two-level cache: L1 caches the final resolved result, L2 caches the parsed
|
|
596
|
+
* source map so multiple positions in the same file are fast.
|
|
597
|
+
*
|
|
598
|
+
* When `debug` is `true`, detailed logs are printed to the console showing
|
|
599
|
+
* each step of the resolution pipeline.
|
|
600
|
+
*/
|
|
601
|
+
export async function resolveLocation(
|
|
602
|
+
stackLine: string,
|
|
603
|
+
debug?: boolean
|
|
604
|
+
): Promise<ResolvedSourceInfo | null> {
|
|
605
|
+
try {
|
|
606
|
+
if (debug) console.group('[show-component] resolveLocation');
|
|
607
|
+
if (debug) console.log('Stack line:', stackLine);
|
|
608
|
+
|
|
609
|
+
const frameInfo = extractStackFrameInfo(stackLine);
|
|
610
|
+
if (!frameInfo) {
|
|
611
|
+
if (debug) {
|
|
612
|
+
console.warn('Could not extract frame info from stack line');
|
|
613
|
+
console.groupEnd();
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (debug) console.log('Extracted frame:', frameInfo);
|
|
619
|
+
|
|
620
|
+
const { url, line, column } = frameInfo;
|
|
621
|
+
const cacheKey = `${url}:${line}:${column}`;
|
|
622
|
+
|
|
623
|
+
// L1: exact result cache
|
|
624
|
+
const cachedResult = resultCache.get(cacheKey);
|
|
625
|
+
if (cachedResult) {
|
|
626
|
+
if (debug) {
|
|
627
|
+
console.log('L1 cache hit:', cachedResult.originalSource);
|
|
628
|
+
console.groupEnd();
|
|
629
|
+
}
|
|
630
|
+
return cachedResult.originalSource;
|
|
631
|
+
}
|
|
632
|
+
if (debug) console.log('L1 cache miss');
|
|
633
|
+
|
|
634
|
+
// ── Next.js RSC fast path ──────────────────────────────────────────────
|
|
635
|
+
// For React Server Component URLs in Next.js, delegate resolution to the
|
|
636
|
+
// built-in dev server endpoints which resolve source maps server-side
|
|
637
|
+
// with full access to the bundler's compilation state. This removes the
|
|
638
|
+
// need for a custom /api/dev/source-file/ handler.
|
|
639
|
+
if (isNextjsRscUrl(url)) {
|
|
640
|
+
if (debug) console.log('Next.js RSC URL detected, trying built-in dev server endpoints');
|
|
641
|
+
|
|
642
|
+
// Strategy 1: POST /__nextjs_original-stack-frames (full server-side resolution)
|
|
643
|
+
const nextResult = await resolveViaNextDevServer(frameInfo, debug);
|
|
644
|
+
if (nextResult) {
|
|
645
|
+
boundedSet(resultCache, cacheKey, { originalSource: nextResult }, MAX_RESULT_CACHE_SIZE);
|
|
646
|
+
if (debug) {
|
|
647
|
+
console.log('Resolved via Next.js __nextjs_original-stack-frames:', nextResult);
|
|
648
|
+
console.groupEnd();
|
|
649
|
+
}
|
|
650
|
+
return nextResult;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Strategy 2: GET /__nextjs_source-map (fetch source map, resolve client-side)
|
|
654
|
+
const filePath = extractFilePathFromRscUrl(url);
|
|
655
|
+
if (filePath) {
|
|
656
|
+
const sourceMapContent = await fetchSourceMapFromNextDevServer(filePath, debug);
|
|
657
|
+
if (sourceMapContent) {
|
|
658
|
+
if (debug) console.log('Got source map via __nextjs_source-map, resolving client-side');
|
|
659
|
+
|
|
660
|
+
const mapResult = await mapToOriginalSource(frameInfo, sourceMapContent);
|
|
661
|
+
if (mapResult) {
|
|
662
|
+
const resolvedSource = resolveSourcePath(
|
|
663
|
+
mapResult.info.source,
|
|
664
|
+
mapResult.sourceRoot,
|
|
665
|
+
url
|
|
666
|
+
);
|
|
667
|
+
const originalSourceContent = await getOriginalSourceContent(
|
|
668
|
+
mapResult.info,
|
|
669
|
+
sourceMapContent
|
|
670
|
+
);
|
|
671
|
+
const result: ResolvedSourceInfo = {
|
|
672
|
+
...mapResult.info,
|
|
673
|
+
source: resolvedSource,
|
|
674
|
+
sourceContent: originalSourceContent || undefined,
|
|
675
|
+
};
|
|
676
|
+
boundedSet(resultCache, cacheKey, { originalSource: result }, MAX_RESULT_CACHE_SIZE);
|
|
677
|
+
if (debug) {
|
|
678
|
+
console.log('Resolved via Next.js __nextjs_source-map:', result);
|
|
679
|
+
console.groupEnd();
|
|
680
|
+
}
|
|
681
|
+
return result;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (debug) {
|
|
687
|
+
console.warn('All Next.js resolution methods failed for RSC URL:', url);
|
|
688
|
+
console.groupEnd();
|
|
689
|
+
}
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── Standard path (Vite, webpack, non-RSC HTTP URLs) ─────────────────
|
|
694
|
+
|
|
695
|
+
// L2: source map cache (keyed by URL)
|
|
696
|
+
let sourceMapData = sourceMapCache.get(url);
|
|
697
|
+
let effectiveUrl = url;
|
|
698
|
+
|
|
699
|
+
if (!sourceMapData) {
|
|
700
|
+
if (debug) console.log('L2 cache miss — fetching source file:', url);
|
|
701
|
+
|
|
702
|
+
const sourceResult = await fetchSourceFile(url);
|
|
703
|
+
effectiveUrl = sourceResult.effectiveUrl;
|
|
704
|
+
|
|
705
|
+
if (debug) console.log('Fetched source, effective URL:', effectiveUrl);
|
|
706
|
+
|
|
707
|
+
const sourceMapContent = await resolveSourceMap(
|
|
708
|
+
sourceResult.content,
|
|
709
|
+
sourceResult.effectiveUrl
|
|
710
|
+
);
|
|
711
|
+
if (!sourceMapContent) {
|
|
712
|
+
if (debug) {
|
|
713
|
+
console.warn('No source map found for:', effectiveUrl);
|
|
714
|
+
console.groupEnd();
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (debug) console.log('Source map resolved (length:', sourceMapContent.length, ')');
|
|
720
|
+
|
|
721
|
+
sourceMapData = {
|
|
722
|
+
sourceContent: sourceResult.content,
|
|
723
|
+
sourceMapContent,
|
|
724
|
+
};
|
|
725
|
+
boundedSet(sourceMapCache, url, sourceMapData, MAX_SOURCE_MAP_CACHE_SIZE);
|
|
726
|
+
if (url !== effectiveUrl) {
|
|
727
|
+
boundedSet(sourceMapCache, effectiveUrl, sourceMapData, MAX_SOURCE_MAP_CACHE_SIZE);
|
|
728
|
+
}
|
|
729
|
+
} else {
|
|
730
|
+
if (debug) console.log('L2 cache hit for:', url);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const mapResult = await mapToOriginalSource(frameInfo, sourceMapData.sourceMapContent);
|
|
734
|
+
if (!mapResult) {
|
|
735
|
+
if (debug) {
|
|
736
|
+
console.warn('Source map lookup returned no result for position', { line, column });
|
|
737
|
+
console.groupEnd();
|
|
738
|
+
}
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (debug) console.log('Mapped to original:', mapResult.info);
|
|
743
|
+
|
|
744
|
+
const rawSource = mapResult.info.source;
|
|
745
|
+
const resolvedSource = resolveSourcePath(rawSource, mapResult.sourceRoot, effectiveUrl);
|
|
746
|
+
|
|
747
|
+
if (debug) {
|
|
748
|
+
console.log('Source path resolution:', {
|
|
749
|
+
rawSource,
|
|
750
|
+
sourceRoot: mapResult.sourceRoot,
|
|
751
|
+
effectiveUrl,
|
|
752
|
+
resolvedSource,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const originalInfo: OriginalSourceInfo = {
|
|
757
|
+
...mapResult.info,
|
|
758
|
+
source: resolvedSource,
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// Use the raw (pre-resolved) path for content lookup — that's what the source map indexes by
|
|
762
|
+
const originalSourceContent = await getOriginalSourceContent(
|
|
763
|
+
{ ...originalInfo, source: rawSource },
|
|
764
|
+
sourceMapData.sourceMapContent
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
const result: ResolvedSourceInfo = {
|
|
768
|
+
...originalInfo,
|
|
769
|
+
sourceContent: originalSourceContent || undefined,
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
boundedSet(resultCache, cacheKey, { originalSource: result }, MAX_RESULT_CACHE_SIZE);
|
|
773
|
+
|
|
774
|
+
if (debug) {
|
|
775
|
+
console.log('Resolved result:', {
|
|
776
|
+
source: result.source,
|
|
777
|
+
line: result.line,
|
|
778
|
+
column: result.column,
|
|
779
|
+
name: result.name,
|
|
780
|
+
hasSourceContent: !!result.sourceContent,
|
|
781
|
+
});
|
|
782
|
+
console.groupEnd();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return result;
|
|
786
|
+
} catch (error) {
|
|
787
|
+
if (debug) {
|
|
788
|
+
console.error('Resolution failed:', error);
|
|
789
|
+
console.groupEnd();
|
|
790
|
+
}
|
|
791
|
+
console.error('Error resolving stack frame to original source:', error);
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Clears all caches (including the Next.js dev server availability flag). */
|
|
797
|
+
export function clearCaches(): void {
|
|
798
|
+
resultCache.clear();
|
|
799
|
+
sourceMapCache.clear();
|
|
800
|
+
_nextDevServerAvailable = undefined;
|
|
801
|
+
}
|