@watchforge/browser 0.1.5 → 0.1.7

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 CHANGED
@@ -7,8 +7,9 @@ Browser, Next.js, React, Node.js, and Express SDK for WatchForge. **One call to
7
7
  | Runtime / Framework | Import | What it captures |
8
8
  | --- | --- | --- |
9
9
  | Browser JavaScript | `@watchforge/browser` | Uncaught errors, unhandled promise rejections, breadcrumbs, browser/device/page/performance context |
10
- | React | `@watchforge/browser` + `@watchforge/browser/react` | Browser errors plus React `ErrorBoundary` component stack context |
11
- | Next.js App Router | `@watchforge/browser/next` | Client-side Next.js errors through a client provider; wizard patches the app layout |
10
+ | React / Vite / Vue / Angular | `@watchforge/browser` | Browser errors, unhandled promise rejections, breadcrumbs, replay, browser/device/page/performance context |
11
+ | React | `@watchforge/browser/react` | Optional React `ErrorBoundary` component stack context |
12
+ | Next.js App Router | `@watchforge/browser/next` + `@watchforge/browser/next/server` | Client-side errors, replay, global app render errors, Node runtime unhandled errors, route handler helper |
12
13
  | Node.js | `@watchforge/browser` or `@watchforge/browser/node` | `uncaughtException`, `unhandledRejection`, Node runtime/server context |
13
14
  | Express.js | `@watchforge/browser/express` | Express request errors, request URL/method/headers/body/query, user/IP, route, duration |
14
15
 
@@ -47,7 +48,39 @@ npx @watchforge/browser -i nextjs \
47
48
  --replays-on-error 1
48
49
  ```
49
50
 
50
- The wizard installs `@watchforge/browser`, writes `watchforge.config.ts`, creates a client init component, and patches `app/layout.tsx` or `pages/_app.tsx`.
51
+ The wizard installs `@watchforge/browser` and writes the files needed for a full Next.js setup:
52
+
53
+ - `watchforge.config.ts` or `watchforge.config.js`
54
+ - `app/**/watchforge-init.tsx` / `.jsx` for browser errors and session replay
55
+ - `app/**/global-error.tsx` / `.jsx` for Next.js app render errors surfaced through Next's error boundary
56
+ - `src/instrumentation.ts` / `.js` or `instrumentation.ts` / `.js` for server runtime registration
57
+ - patches `app/layout.tsx` / `.jsx` or `pages/_app.tsx` / `.jsx`
58
+
59
+ For route handlers, wrap handlers with `withWatchForgeRouteHandler` from `@watchforge/browser/next/server`.
60
+
61
+ ## One-line setup for other JavaScript projects
62
+
63
+ ```bash
64
+ # React
65
+ npx @watchforge/browser -i react --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" --replays-on-error 1
66
+
67
+ # Vite
68
+ npx @watchforge/browser -i vite --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" --replays-on-error 1
69
+
70
+ # Vue
71
+ npx @watchforge/browser -i vue --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" --replays-on-error 1
72
+
73
+ # Angular
74
+ npx @watchforge/browser -i angular --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" --replays-on-error 1
75
+
76
+ # Express
77
+ npx @watchforge/browser -i express --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID"
78
+
79
+ # Node.js
80
+ npx @watchforge/browser -i node --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID"
81
+ ```
82
+
83
+ For React/Vite/Vue/Angular, the wizard creates a browser init module and imports it from the detected entry file. For Express and Node.js, it creates a server setup file and prints the one or two lines to add to your server entry because middleware placement depends on each app.
51
84
 
52
85
  ## Framework Imports
53
86
 
package/bin/watchforge.js CHANGED
@@ -10,10 +10,15 @@ WatchForge setup wizard
10
10
 
11
11
  Usage:
12
12
  npx @watchforge/browser -i nextjs --dsn <dsn> [options]
13
- npx @watchforge/browser init nextjs --dsn <dsn> [options]
13
+ npx @watchforge/browser -i react --dsn <dsn> [options]
14
+ npx @watchforge/browser -i vite --dsn <dsn> [options]
15
+ npx @watchforge/browser -i vue --dsn <dsn> [options]
16
+ npx @watchforge/browser -i angular --dsn <dsn> [options]
17
+ npx @watchforge/browser -i express --dsn <dsn> [options]
18
+ npx @watchforge/browser -i node --dsn <dsn> [options]
14
19
 
15
20
  Options:
16
- -i, --integration <name> Framework integration. Currently: nextjs
21
+ -i, --integration <name> Framework integration: nextjs, react, vite, vue, angular, express, node
17
22
  --dsn <dsn> WatchForge DSN
18
23
  --app-env <env> App environment (default: production)
19
24
  --debug Enable SDK debug logging
@@ -140,8 +145,58 @@ function writeIfChanged(filePath, content) {
140
145
  return true;
141
146
  }
142
147
 
148
+ function toImportPath(fromDir, targetPath) {
149
+ let importPath = path.relative(fromDir, targetPath).replace(/\\/g, "/");
150
+ importPath = importPath.replace(/\.(ts|tsx|js|jsx)$/, "");
151
+ if (!importPath.startsWith(".")) {
152
+ importPath = `./${importPath}`;
153
+ }
154
+ return importPath;
155
+ }
156
+
157
+ function toNodeImportPath(fromDir, targetPath) {
158
+ let importPath = path.relative(fromDir, targetPath).replace(/\\/g, "/");
159
+ importPath = importPath.replace(/\.(ts|tsx)$/, "");
160
+ if (!importPath.startsWith(".")) {
161
+ importPath = `./${importPath}`;
162
+ }
163
+ return importPath;
164
+ }
165
+
166
+ function projectUsesTypeScript(cwd) {
167
+ if (fileExists(path.join(cwd, "tsconfig.json"))) return true;
168
+
169
+ const srcDir = path.join(cwd, "src");
170
+ if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) return false;
171
+
172
+ const stack = [srcDir];
173
+ while (stack.length > 0) {
174
+ const dir = stack.pop();
175
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
176
+ const entryPath = path.join(dir, entry.name);
177
+ if (entry.isDirectory()) {
178
+ if (entry.name !== "node_modules" && entry.name !== ".next") {
179
+ stack.push(entryPath);
180
+ }
181
+ } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
182
+ return true;
183
+ }
184
+ }
185
+ }
186
+
187
+ return false;
188
+ }
189
+
190
+ function getConfigPath(cwd) {
191
+ const tsConfig = path.join(cwd, "watchforge.config.ts");
192
+ const jsConfig = path.join(cwd, "watchforge.config.js");
193
+ if (fileExists(tsConfig)) return tsConfig;
194
+ if (fileExists(jsConfig)) return jsConfig;
195
+ return projectUsesTypeScript(cwd) ? tsConfig : jsConfig;
196
+ }
197
+
143
198
  function createConfig(cwd, args) {
144
- const configPath = path.join(cwd, "watchforge.config.ts");
199
+ const configPath = getConfigPath(cwd);
145
200
  const content = `export const watchforgeConfig = {
146
201
  dsn: ${JSON.stringify(args.dsn)},
147
202
  app_env: ${JSON.stringify(args.appEnv)},
@@ -153,6 +208,7 @@ function createConfig(cwd, args) {
153
208
  `;
154
209
  writeIfChanged(configPath, content);
155
210
  log(`wrote ${path.relative(cwd, configPath)}`);
211
+ return configPath;
156
212
  }
157
213
 
158
214
  function installPackage(cwd, skipInstall) {
@@ -179,13 +235,12 @@ function installPackage(cwd, skipInstall) {
179
235
 
180
236
  function patchAppRouter(cwd, layoutPath) {
181
237
  const appDir = path.dirname(layoutPath);
182
- const configPath = path.join(cwd, "watchforge.config.ts");
183
- let configImport = path.relative(appDir, configPath).replace(/\\/g, "/");
184
- configImport = configImport.replace(/\.ts$/, "");
185
- if (!configImport.startsWith(".")) {
186
- configImport = `./${configImport}`;
187
- }
188
- const initPath = path.join(appDir, "watchforge-init.tsx");
238
+ const configPath = getConfigPath(cwd);
239
+ const configImport = toImportPath(appDir, configPath);
240
+ const initPath = path.join(
241
+ appDir,
242
+ projectUsesTypeScript(cwd) ? "watchforge-init.tsx" : "watchforge-init.jsx"
243
+ );
189
244
 
190
245
  const initContent = `"use client";
191
246
 
@@ -213,10 +268,102 @@ export default function WatchForgeInit() {
213
268
  log(`patched ${path.relative(cwd, layoutPath)}`);
214
269
  }
215
270
 
271
+ function createNextGlobalError(cwd, layoutPath) {
272
+ const appDir = path.dirname(layoutPath);
273
+ const usesTs = projectUsesTypeScript(cwd);
274
+ const globalErrorPath = path.join(appDir, usesTs ? "global-error.tsx" : "global-error.jsx");
275
+
276
+ if (fileExists(globalErrorPath)) {
277
+ const content = fs.readFileSync(globalErrorPath, "utf8");
278
+ if (content.includes("@watchforge/browser") || content.includes("captureException")) {
279
+ log(`${path.relative(cwd, globalErrorPath)} already contains WatchForge setup`);
280
+ return;
281
+ }
282
+ log(`skipped ${path.relative(cwd, globalErrorPath)} because it already exists`);
283
+ log("add captureException(error) there manually to report Next.js render errors");
284
+ return;
285
+ }
286
+
287
+ const propsSignature = usesTs
288
+ ? `{\n error,\n}: {\n error: Error & { digest?: string };\n}`
289
+ : `{ error }`;
290
+
291
+ const content = `"use client";
292
+
293
+ import { useEffect } from "react";
294
+ import { captureException } from "@watchforge/browser";
295
+
296
+ export default function GlobalError(${propsSignature}) {
297
+ useEffect(() => {
298
+ void captureException(error, {
299
+ tags: {
300
+ framework: "nextjs",
301
+ runtime: "error-boundary",
302
+ },
303
+ extra: {
304
+ digest: error.digest,
305
+ },
306
+ contexts: {
307
+ nextjs: {
308
+ error_boundary: "global-error",
309
+ digest: error.digest || null,
310
+ },
311
+ },
312
+ });
313
+ }, [error]);
314
+
315
+ return (
316
+ <html>
317
+ <body>
318
+ <h2>Something went wrong.</h2>
319
+ </body>
320
+ </html>
321
+ );
322
+ }
323
+ `;
324
+
325
+ writeIfChanged(globalErrorPath, content);
326
+ log(`wrote ${path.relative(cwd, globalErrorPath)}`);
327
+ }
328
+
329
+ function createNextInstrumentation(cwd, layoutPath) {
330
+ const appRoot = path.dirname(layoutPath).includes(`${path.sep}src${path.sep}app`)
331
+ ? path.join(cwd, "src")
332
+ : cwd;
333
+ const instrumentationPath = path.join(
334
+ appRoot,
335
+ projectUsesTypeScript(cwd) ? "instrumentation.ts" : "instrumentation.js"
336
+ );
337
+ const configImport = toImportPath(path.dirname(instrumentationPath), getConfigPath(cwd));
338
+
339
+ if (fileExists(instrumentationPath)) {
340
+ const content = fs.readFileSync(instrumentationPath, "utf8");
341
+ if (content.includes("@watchforge/browser/next/server")) {
342
+ log(`${path.relative(cwd, instrumentationPath)} already contains WatchForge setup`);
343
+ return;
344
+ }
345
+ log(`skipped ${path.relative(cwd, instrumentationPath)} because it already exists`);
346
+ log("add registerWatchForge(watchforgeConfig) there manually to report Next.js server runtime errors");
347
+ return;
348
+ }
349
+
350
+ const content = `import { register as registerWatchForge } from "@watchforge/browser/next/server";
351
+ import { watchforgeConfig } from "${configImport}";
352
+
353
+ export async function register() {
354
+ if (process.env.NEXT_RUNTIME === "nodejs") {
355
+ registerWatchForge(watchforgeConfig);
356
+ }
357
+ }
358
+ `;
359
+
360
+ writeIfChanged(instrumentationPath, content);
361
+ log(`wrote ${path.relative(cwd, instrumentationPath)}`);
362
+ }
363
+
216
364
  function patchPagesRouter(cwd, appPath) {
217
365
  const pagesDir = path.dirname(appPath);
218
- const isSrcPages = pagesDir.endsWith(path.join("src", "pages"));
219
- const configImport = isSrcPages ? "../../watchforge.config" : "../watchforge.config";
366
+ const configImport = toImportPath(pagesDir, getConfigPath(cwd));
220
367
  const content = fs.readFileSync(appPath, "utf8");
221
368
 
222
369
  if (content.includes("register(watchforgeConfig)")) {
@@ -237,6 +384,133 @@ ${content.replace(
237
384
  log(`patched ${path.relative(cwd, appPath)}`);
238
385
  }
239
386
 
387
+ function findFirstFile(cwd, candidates) {
388
+ return candidates.map((candidate) => path.join(cwd, candidate)).find(fileExists) || null;
389
+ }
390
+
391
+ function prependImportOnce(filePath, importLine) {
392
+ const content = fs.readFileSync(filePath, "utf8");
393
+ if (content.includes(importLine)) return false;
394
+ fs.writeFileSync(filePath, `${importLine}\n${content}`);
395
+ return true;
396
+ }
397
+
398
+ function createBrowserInit(cwd, initRelativePath = null) {
399
+ const defaultPath = projectUsesTypeScript(cwd)
400
+ ? "src/watchforge-init.ts"
401
+ : "src/watchforge-init.js";
402
+ const initPath = path.join(cwd, initRelativePath || defaultPath);
403
+ const configImport = toImportPath(path.dirname(initPath), getConfigPath(cwd));
404
+ const content = `import { register } from "@watchforge/browser";
405
+ import { watchforgeConfig } from "${configImport}";
406
+
407
+ register(watchforgeConfig);
408
+ `;
409
+
410
+ writeIfChanged(initPath, content);
411
+ log(`wrote ${path.relative(cwd, initPath)}`);
412
+ return initPath;
413
+ }
414
+
415
+ function initBrowserFramework(args, integration) {
416
+ const cwd = process.cwd();
417
+ if (!fileExists(path.join(cwd, "package.json"))) {
418
+ fail(`run this command from the root of your ${integration} project`);
419
+ }
420
+
421
+ createConfig(cwd, args);
422
+ installPackage(cwd, args.skipInstall);
423
+
424
+ const entryPath = findFirstFile(cwd, [
425
+ "src/main.tsx",
426
+ "src/main.jsx",
427
+ "src/main.ts",
428
+ "src/main.js",
429
+ "src/index.tsx",
430
+ "src/index.jsx",
431
+ "src/index.ts",
432
+ "src/index.js",
433
+ "main.ts",
434
+ "main.js",
435
+ "index.ts",
436
+ "index.js",
437
+ ]);
438
+
439
+ const initPath = createBrowserInit(cwd);
440
+ if (!entryPath) {
441
+ log("could not find a browser entry file to patch");
442
+ log(`import "./${path.relative(cwd, initPath).replace(/\\/g, "/").replace(/\.(ts|js)$/, "")}" from your app entry`);
443
+ return;
444
+ }
445
+
446
+ const initImport = toImportPath(path.dirname(entryPath), initPath);
447
+ if (prependImportOnce(entryPath, `import "${initImport}";`)) {
448
+ log(`patched ${path.relative(cwd, entryPath)}`);
449
+ } else {
450
+ log(`${path.relative(cwd, entryPath)} already imports WatchForge`);
451
+ }
452
+ }
453
+
454
+ function initExpress(args) {
455
+ const cwd = process.cwd();
456
+ if (!fileExists(path.join(cwd, "package.json"))) {
457
+ fail("run this command from the root of your Express project");
458
+ }
459
+
460
+ createConfig(cwd, args);
461
+ installPackage(cwd, args.skipInstall);
462
+
463
+ const setupPath = path.join(
464
+ cwd,
465
+ projectUsesTypeScript(cwd) ? "watchforge-express.ts" : "watchforge-express.js"
466
+ );
467
+ const configImport = toNodeImportPath(path.dirname(setupPath), getConfigPath(cwd));
468
+ const content = `import { register } from "@watchforge/browser/node";
469
+ import {
470
+ expressMiddleware,
471
+ expressRequestMiddleware,
472
+ } from "@watchforge/browser/express";
473
+ import { watchforgeConfig } from "${configImport}";
474
+
475
+ register(watchforgeConfig);
476
+
477
+ export { expressMiddleware, expressRequestMiddleware };
478
+ `;
479
+
480
+ writeIfChanged(setupPath, content);
481
+ log(`wrote ${path.relative(cwd, setupPath)}`);
482
+ log("add this near the top of your Express app:");
483
+ log(' import { expressRequestMiddleware, expressMiddleware } from "./watchforge-express";');
484
+ log(" app.use(expressRequestMiddleware());");
485
+ log("and add this after your routes:");
486
+ log(" app.use(expressMiddleware());");
487
+ }
488
+
489
+ function initNode(args) {
490
+ const cwd = process.cwd();
491
+ if (!fileExists(path.join(cwd, "package.json"))) {
492
+ fail("run this command from the root of your Node.js project");
493
+ }
494
+
495
+ createConfig(cwd, args);
496
+ installPackage(cwd, args.skipInstall);
497
+
498
+ const setupPath = path.join(
499
+ cwd,
500
+ projectUsesTypeScript(cwd) ? "watchforge-node.ts" : "watchforge-node.js"
501
+ );
502
+ const configImport = toNodeImportPath(path.dirname(setupPath), getConfigPath(cwd));
503
+ const content = `import { register } from "@watchforge/browser/node";
504
+ import { watchforgeConfig } from "${configImport}";
505
+
506
+ register(watchforgeConfig);
507
+ `;
508
+
509
+ writeIfChanged(setupPath, content);
510
+ log(`wrote ${path.relative(cwd, setupPath)}`);
511
+ log('import "./watchforge-node"; as early as possible in your server entry file');
512
+ }
513
+
240
514
  function initNextjs(args) {
241
515
  const cwd = process.cwd();
242
516
  if (!fileExists(path.join(cwd, "package.json"))) {
@@ -249,6 +523,8 @@ function initNextjs(args) {
249
523
  const layoutPath = findNextLayout(cwd);
250
524
  if (layoutPath) {
251
525
  patchAppRouter(cwd, layoutPath);
526
+ createNextGlobalError(cwd, layoutPath);
527
+ createNextInstrumentation(cwd, layoutPath);
252
528
  return;
253
529
  }
254
530
 
@@ -272,13 +548,24 @@ if (!args.integration) {
272
548
  fail("missing integration. Use: -i nextjs");
273
549
  }
274
550
 
275
- if (args.integration !== "nextjs") {
276
- fail(`unsupported integration "${args.integration}". Currently supported: nextjs`);
277
- }
278
-
279
551
  if (!args.dsn) {
280
552
  fail("missing --dsn");
281
553
  }
282
554
 
283
- initNextjs(args);
555
+ const integration = args.integration.toLowerCase();
556
+
557
+ if (integration === "nextjs" || integration === "next") {
558
+ initNextjs(args);
559
+ } else if (["react", "vite", "vue", "angular", "browser"].includes(integration)) {
560
+ initBrowserFramework(args, integration);
561
+ } else if (integration === "express") {
562
+ initExpress(args);
563
+ } else if (integration === "node" || integration === "nodejs") {
564
+ initNode(args);
565
+ } else {
566
+ fail(
567
+ `unsupported integration "${args.integration}". Supported: nextjs, react, vite, vue, angular, express, node`
568
+ );
569
+ }
570
+
284
571
  log("setup complete");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watchforge/browser",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "main": "./src/index.js",
5
5
  "types": "./src/index.d.ts",
6
6
  "description": "WatchForge JavaScript SDK for browser JavaScript, Next.js, React, Node.js, and Express.js",
package/src/stacktrace.js CHANGED
@@ -68,16 +68,21 @@ export function buildStacktraceFromError(error) {
68
68
  if (!parsed) continue;
69
69
 
70
70
  const { function: functionName, file, lineno, colno } = parsed;
71
- const filename = file.split("/").pop()?.split("?")[0] || file;
72
- const inApp = !isLibraryPath(file);
71
+ const normalizedPath = normalizeSourcePath(file) || file;
72
+ const filename =
73
+ normalizedPath.split("/").pop()?.split("?")[0] ||
74
+ file.split("/").pop()?.split("?")[0] ||
75
+ file;
76
+ const inApp = !isLibraryPath(file) && !isLibraryPath(normalizedPath);
73
77
 
74
78
  frames.push({
75
79
  filename,
76
- abs_path: file,
80
+ abs_path: normalizedPath,
81
+ raw_abs_path: file,
77
82
  lineno,
78
83
  colno,
79
84
  function: functionName,
80
- module: null,
85
+ module: normalizedPath,
81
86
  context_line: null,
82
87
  pre_context: [],
83
88
  post_context: [],
@@ -198,44 +203,73 @@ async function readNodeSourceLines(absPath) {
198
203
 
199
204
  async function enrichFrameWithNodeSource(frame) {
200
205
  if (!frame.in_app || !frame.lineno) return;
201
- const lines = await readNodeSourceLines(frame.abs_path);
206
+ const lines = await readNodeSourceLines(frame.raw_abs_path || frame.abs_path);
202
207
  if (lines) applySourceContext(frame, lines, frame.lineno);
203
208
  }
204
209
 
205
- async function fetchBrowserSourceLines(absPath) {
206
- if (!isBrowser || !absPath) return null;
210
+ function getBrowserSourceFetchCandidates(absPath, rawAbsPath) {
211
+ const candidates = new Set();
212
+ const paths = [absPath, rawAbsPath].filter(Boolean);
207
213
 
208
- try {
209
- let url = absPath;
210
- if (url.startsWith("webpack-internal://")) {
211
- url = url.replace("webpack-internal:///", "/").replace("webpack-internal://", "/");
214
+ for (const path of paths) {
215
+ const normalized = normalizeSourcePath(path);
216
+ if (!normalized) continue;
217
+
218
+ if (path.startsWith("http://") || path.startsWith("https://")) {
219
+ candidates.add(path);
212
220
  }
213
221
 
214
- if (!url.startsWith("http://") && !url.startsWith("https://")) {
215
- if (url.startsWith("/")) {
216
- url = `${window.location.origin}${url}`;
217
- } else {
218
- return null;
219
- }
222
+ if (normalized.startsWith("/")) {
223
+ candidates.add(`${window.location.origin}${normalized}`);
224
+ } else {
225
+ candidates.add(`${window.location.origin}/${normalized}`);
226
+ candidates.add(new URL(normalized, window.location.href).href);
220
227
  }
221
228
 
222
- const target = new URL(url, window.location.href);
223
- if (target.origin !== window.location.origin) return null;
229
+ if (path.startsWith("webpack-internal://")) {
230
+ const stripped = path
231
+ .replace(/^webpack-internal:\/\/\/?/, "/")
232
+ .replace(/^webpack-internal:\/\//, "/");
233
+ candidates.add(`${window.location.origin}${stripped}`);
234
+ }
235
+ }
224
236
 
225
- const response = await fetch(target.href, { credentials: "same-origin" });
226
- if (!response.ok) return null;
237
+ return [...candidates];
238
+ }
227
239
 
228
- const contentType = response.headers.get("content-type") || "";
229
- if (!contentType.includes("javascript") && !contentType.includes("text")) {
230
- return null;
231
- }
240
+ async function fetchBrowserSourceLines(absPath, rawAbsPath) {
241
+ if (!isBrowser || !absPath) return null;
232
242
 
233
- const text = await response.text();
234
- if (text.length > MAX_SOURCE_FILE_BYTES) return null;
235
- return text.split("\n");
236
- } catch {
237
- return null;
243
+ const candidates = getBrowserSourceFetchCandidates(absPath, rawAbsPath);
244
+
245
+ for (const candidate of candidates) {
246
+ try {
247
+ const target = new URL(candidate, window.location.href);
248
+ if (target.origin !== window.location.origin) continue;
249
+
250
+ const response = await fetch(target.href, { credentials: "same-origin" });
251
+ if (!response.ok) continue;
252
+
253
+ const contentType = response.headers.get("content-type") || "";
254
+ if (
255
+ !contentType.includes("javascript") &&
256
+ !contentType.includes("text") &&
257
+ !contentType.includes("typescript") &&
258
+ !contentType.includes("json")
259
+ ) {
260
+ continue;
261
+ }
262
+
263
+ const text = await response.text();
264
+ if (text.length > MAX_SOURCE_FILE_BYTES) continue;
265
+ if (!text.includes("\n") && !text.includes(";") && text.length > 500) continue;
266
+ return text.split("\n");
267
+ } catch {
268
+ // try next candidate
269
+ }
238
270
  }
271
+
272
+ return null;
239
273
  }
240
274
 
241
275
  async function getSourceMapConsumer() {
@@ -294,9 +328,55 @@ async function fetchBrowserSourceMap(absPath) {
294
328
  }
295
329
  }
296
330
 
331
+ async function findSourceMapForFrame(frame) {
332
+ const wanted = normalizeSourcePath(frame.abs_path || frame.module);
333
+ const scriptUrls = [];
334
+
335
+ if (frame.raw_abs_path?.startsWith("http://") || frame.raw_abs_path?.startsWith("https://")) {
336
+ scriptUrls.push(frame.raw_abs_path);
337
+ }
338
+ if (frame.abs_path?.startsWith("http://") || frame.abs_path?.startsWith("https://")) {
339
+ scriptUrls.push(frame.abs_path);
340
+ }
341
+
342
+ if (isBrowser) {
343
+ for (const script of document.querySelectorAll("script[src]")) {
344
+ const src = script.getAttribute("src");
345
+ if (!src) continue;
346
+ try {
347
+ const url = new URL(src, window.location.href);
348
+ if (url.origin === window.location.origin) {
349
+ scriptUrls.push(url.href);
350
+ }
351
+ } catch {
352
+ // ignore invalid script src
353
+ }
354
+ }
355
+ }
356
+
357
+ const seen = new Set();
358
+ for (const scriptUrl of scriptUrls) {
359
+ if (!scriptUrl || seen.has(scriptUrl)) continue;
360
+ seen.add(scriptUrl);
361
+
362
+ const sourceMap = await fetchBrowserSourceMap(scriptUrl);
363
+ if (!sourceMap) continue;
364
+
365
+ const sources = Array.isArray(sourceMap.sources) ? sourceMap.sources : [];
366
+ const hasMatch = sources.some((source) => sourceMatchesFrame(source, wanted));
367
+ if (hasMatch || !wanted) {
368
+ return { sourceMap, scriptUrl };
369
+ }
370
+ }
371
+
372
+ return null;
373
+ }
374
+
297
375
  async function enrichFrameWithSourceMap(frame) {
298
- const sourceMap = await fetchBrowserSourceMap(frame.abs_path);
299
- if (!sourceMap) return false;
376
+ const resolved = await findSourceMapForFrame(frame);
377
+ if (!resolved) return false;
378
+
379
+ const { sourceMap } = resolved;
300
380
 
301
381
  try {
302
382
  const SourceMapConsumer = await getSourceMapConsumer();
@@ -358,7 +438,9 @@ function getSourceContentFromWebpackFrame(frame) {
358
438
  value.some((item) => Array.isArray(item) && item.length >= 2)
359
439
  ) || [];
360
440
 
361
- const framePath = normalizeSourcePath(frame.abs_path);
441
+ const framePath = normalizeSourcePath(
442
+ frame.abs_path || frame.module || frame.raw_abs_path
443
+ );
362
444
  if (!framePath || !Array.isArray(webpackChunks)) return null;
363
445
 
364
446
  for (const chunk of webpackChunks) {
@@ -384,7 +466,7 @@ async function enrichFrameWithBrowserSource(frame) {
384
466
  if (frame.context_line) return;
385
467
  }
386
468
 
387
- const lines = await fetchBrowserSourceLines(frame.abs_path);
469
+ const lines = await fetchBrowserSourceLines(frame.abs_path, frame.raw_abs_path);
388
470
  if (lines) {
389
471
  applySourceContext(frame, lines, frame.lineno);
390
472
  if (frame.context_line) return;
@@ -397,12 +479,15 @@ export async function enrichStacktraceAsync(stacktrace) {
397
479
  if (!stacktrace?.frames?.length) return stacktrace;
398
480
 
399
481
  const inAppFrames = stacktrace.frames.filter((f) => f.in_app);
400
- const targets = inAppFrames.slice(-5);
482
+ // Enrich the error frame first (last in-app frame), then nearby frames.
483
+ const targets = [...inAppFrames.slice(-5)].reverse();
401
484
 
402
485
  if (isNode) {
403
486
  await Promise.all(targets.map((frame) => enrichFrameWithNodeSource(frame)));
404
487
  } else if (isBrowser) {
405
- await Promise.all(targets.map((frame) => enrichFrameWithBrowserSource(frame)));
488
+ for (const frame of targets) {
489
+ await enrichFrameWithBrowserSource(frame);
490
+ }
406
491
  }
407
492
 
408
493
  return stacktrace;