@watchforge/browser 0.1.13 → 0.1.15

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/bin/watchforge.js CHANGED
@@ -344,6 +344,65 @@ export default function GlobalError(${propsSignature}) {
344
344
  log(`wrote ${path.relative(cwd, globalErrorPath)}`);
345
345
  }
346
346
 
347
+ function createNextAppError(cwd, layoutPath) {
348
+ const appDir = path.dirname(layoutPath);
349
+ const usesTs = projectUsesTypeScript(cwd);
350
+ const errorPath = path.join(appDir, usesTs ? "error.tsx" : "error.jsx");
351
+
352
+ if (fileExists(errorPath)) {
353
+ const content = fs.readFileSync(errorPath, "utf8");
354
+ if (content.includes("@watchforge/browser") || content.includes("captureException")) {
355
+ log(`${path.relative(cwd, errorPath)} already contains WatchForge setup`);
356
+ return;
357
+ }
358
+ log(`skipped ${path.relative(cwd, errorPath)} because it already exists`);
359
+ log("add captureException(error) there to report App Router segment render errors");
360
+ return;
361
+ }
362
+
363
+ const propsSignature = usesTs
364
+ ? `{\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}`
365
+ : `{ error, reset }`;
366
+
367
+ const content = `"use client";
368
+
369
+ import { useEffect } from "react";
370
+ import { captureException } from "@watchforge/browser";
371
+
372
+ export default function Error(${propsSignature}) {
373
+ useEffect(() => {
374
+ void captureException(error, {
375
+ tags: {
376
+ framework: "nextjs",
377
+ runtime: "error-boundary",
378
+ },
379
+ extra: {
380
+ digest: error.digest,
381
+ },
382
+ contexts: {
383
+ nextjs: {
384
+ error_boundary: "app-error",
385
+ digest: error.digest || null,
386
+ },
387
+ },
388
+ });
389
+ }, [error]);
390
+
391
+ return (
392
+ <div>
393
+ <h2>Something went wrong.</h2>
394
+ <button type="button" onClick={reset}>
395
+ Try again
396
+ </button>
397
+ </div>
398
+ );
399
+ }
400
+ `;
401
+
402
+ writeIfChanged(errorPath, content);
403
+ log(`wrote ${path.relative(cwd, errorPath)}`);
404
+ }
405
+
347
406
  function createNextInstrumentation(cwd, layoutPath) {
348
407
  const appRoot = path.dirname(layoutPath).includes(`${path.sep}src${path.sep}app`)
349
408
  ? path.join(cwd, "src")
@@ -399,6 +458,41 @@ export const onRequestError = watchForgeOnRequestError;
399
458
  log(`wrote ${path.relative(cwd, instrumentationPath)}`);
400
459
  }
401
460
 
461
+ function createNextClientInstrumentation(cwd, layoutPath) {
462
+ const appRoot = path.dirname(layoutPath).includes(`${path.sep}src${path.sep}app`)
463
+ ? path.join(cwd, "src")
464
+ : cwd;
465
+ const instrumentationClientPath = path.join(
466
+ appRoot,
467
+ projectUsesTypeScript(cwd) ? "instrumentation-client.ts" : "instrumentation-client.js"
468
+ );
469
+ const configImport = toImportPath(
470
+ path.dirname(instrumentationClientPath),
471
+ getConfigPath(cwd)
472
+ );
473
+
474
+ if (fileExists(instrumentationClientPath)) {
475
+ const content = fs.readFileSync(instrumentationClientPath, "utf8");
476
+ if (content.includes("@watchforge/browser") && content.includes("register(")) {
477
+ log(`${path.relative(cwd, instrumentationClientPath)} already contains WatchForge client setup`);
478
+ return;
479
+ }
480
+
481
+ log(`skipped ${path.relative(cwd, instrumentationClientPath)} because it already exists`);
482
+ log("add register(watchforgeConfig) there to install browser error handlers before hydration");
483
+ return;
484
+ }
485
+
486
+ const content = `import { register } from "@watchforge/browser";
487
+ import { watchforgeConfig } from "${configImport}";
488
+
489
+ register(watchforgeConfig);
490
+ `;
491
+
492
+ writeIfChanged(instrumentationClientPath, content);
493
+ log(`wrote ${path.relative(cwd, instrumentationClientPath)}`);
494
+ }
495
+
402
496
  function patchNextConfig(cwd) {
403
497
  const configPath = findNextConfig(cwd);
404
498
  if (!configPath) {
@@ -595,8 +689,10 @@ function initNextjs(args) {
595
689
  if (layoutPath) {
596
690
  patchNextConfig(cwd);
597
691
  patchAppRouter(cwd, layoutPath);
692
+ createNextAppError(cwd, layoutPath);
598
693
  createNextGlobalError(cwd, layoutPath);
599
694
  createNextInstrumentation(cwd, layoutPath);
695
+ createNextClientInstrumentation(cwd, layoutPath);
600
696
  return;
601
697
  }
602
698
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watchforge/browser",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
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/client.js CHANGED
@@ -22,6 +22,9 @@ let APP_ENV = "production";
22
22
  let RELEASE = null;
23
23
  let DEBUG = false;
24
24
  let CAPTURE_CONSOLE_ERRORS = true;
25
+ let CAPTURE_RESOURCE_ERRORS = true;
26
+ let CAPTURE_FAILED_REQUESTS = true;
27
+ let CAPTURE_NODE_WARNINGS = false;
25
28
 
26
29
  // Detect environment
27
30
  const isBrowser = typeof window !== "undefined";
@@ -34,6 +37,8 @@ let breadcrumbs = [];
34
37
  let browserInstrumentationInstalled = false;
35
38
  let nodeInstrumentationInstalled = false;
36
39
  let replayInitPromise = null;
40
+ let capturingGlobalError = false;
41
+ let browserRegisterKey = null;
37
42
 
38
43
  export function addBreadcrumb(breadcrumb) {
39
44
  const entry = {
@@ -58,6 +63,59 @@ function isWatchForgeInternalMessage(message) {
58
63
  return String(message || "").includes("WatchForge SDK");
59
64
  }
60
65
 
66
+ function normalizeException(error, fallbackMessage = "Unknown error") {
67
+ if (error instanceof Error) return error;
68
+
69
+ const message =
70
+ typeof error === "string"
71
+ ? error
72
+ : error?.message ||
73
+ error?.reason?.message ||
74
+ String(error || fallbackMessage);
75
+ const normalized = new Error(message || fallbackMessage);
76
+
77
+ if (error && typeof error === "object") {
78
+ if (typeof error.stack === "string") {
79
+ normalized.stack = error.stack;
80
+ }
81
+ if (typeof error.name === "string") {
82
+ normalized.name = error.name;
83
+ }
84
+ }
85
+
86
+ return normalized;
87
+ }
88
+
89
+ function captureGlobalException(error, context = {}) {
90
+ if (capturingGlobalError) return;
91
+ capturingGlobalError = true;
92
+ setTimeout(() => {
93
+ capturingGlobalError = false;
94
+ }, 0);
95
+
96
+ void captureException(normalizeException(error, context.message), context);
97
+ }
98
+
99
+ function getElementDescriptor(element) {
100
+ if (!element || typeof element !== "object") return null;
101
+ const tag = element.tagName ? String(element.tagName).toLowerCase() : "unknown";
102
+ const src =
103
+ element.currentSrc ||
104
+ element.src ||
105
+ element.href ||
106
+ element.action ||
107
+ null;
108
+ return {
109
+ tag,
110
+ url: src ? String(src) : null,
111
+ id: element.id || null,
112
+ className:
113
+ typeof element.className === "string"
114
+ ? element.className
115
+ : null,
116
+ };
117
+ }
118
+
61
119
  // Simple browser environment detectors (best-effort, not 100% accurate)
62
120
  function getBrowserContext() {
63
121
  if (!isBrowser) return null;
@@ -163,8 +221,7 @@ function setupBrowserInstrumentation() {
163
221
  if (browserInstrumentationInstalled) return;
164
222
  browserInstrumentationInstalled = true;
165
223
 
166
- // Global error handlers (already existed but keep here with breadcrumbs)
167
- window.onerror = function (msg, url, line, col, error) {
224
+ const handleWindowError = function (msg, url, line, col, error) {
168
225
  addBreadcrumb({
169
226
  type: "error",
170
227
  level: "error",
@@ -172,20 +229,94 @@ function setupBrowserInstrumentation() {
172
229
  message: String(msg),
173
230
  data: { url, line, col },
174
231
  });
175
- void captureException(error || msg);
232
+ captureGlobalException(error || msg, {
233
+ tags: {
234
+ handled: false,
235
+ mechanism: "window.onerror",
236
+ },
237
+ extra: { url, line, col },
238
+ message: String(msg),
239
+ });
176
240
  };
177
241
 
178
- window.onunhandledrejection = function (event) {
242
+ const handleUnhandledRejection = function (event) {
243
+ const reason = event?.reason;
179
244
  addBreadcrumb({
180
245
  type: "error",
181
246
  level: "error",
182
247
  category: "unhandledrejection",
183
- message: String(event.reason),
248
+ message: String(reason),
184
249
  data: {},
185
250
  });
186
- void captureException(event.reason);
251
+ captureGlobalException(reason, {
252
+ tags: {
253
+ handled: false,
254
+ mechanism: "unhandledrejection",
255
+ },
256
+ });
187
257
  };
188
258
 
259
+ const previousOnError = window.onerror;
260
+ window.onerror = function (msg, url, line, col, error) {
261
+ handleWindowError(msg, url, line, col, error);
262
+ if (typeof previousOnError === "function") {
263
+ return previousOnError.call(window, msg, url, line, col, error);
264
+ }
265
+ return false;
266
+ };
267
+
268
+ const previousUnhandledRejection = window.onunhandledrejection;
269
+ window.onunhandledrejection = function (event) {
270
+ handleUnhandledRejection(event);
271
+ if (typeof previousUnhandledRejection === "function") {
272
+ return previousUnhandledRejection.call(window, event);
273
+ }
274
+ return false;
275
+ };
276
+
277
+ window.addEventListener(
278
+ "error",
279
+ (event) => {
280
+ if (event?.error || event?.message) {
281
+ handleWindowError(
282
+ event.message,
283
+ event.filename,
284
+ event.lineno,
285
+ event.colno,
286
+ event.error
287
+ );
288
+ return;
289
+ }
290
+
291
+ const target = getElementDescriptor(event?.target);
292
+ if (!target) return;
293
+
294
+ addBreadcrumb({
295
+ type: "error",
296
+ level: "error",
297
+ category: "resource",
298
+ message: `Failed to load <${target.tag}> resource`,
299
+ data: target,
300
+ });
301
+
302
+ if (!CAPTURE_RESOURCE_ERRORS) return;
303
+
304
+ captureGlobalException(
305
+ new Error(`Failed to load <${target.tag}> resource${target.url ? `: ${target.url}` : ""}`),
306
+ {
307
+ tags: {
308
+ handled: false,
309
+ mechanism: "resource.error",
310
+ resource_tag: target.tag,
311
+ },
312
+ extra: target,
313
+ }
314
+ );
315
+ },
316
+ true
317
+ );
318
+ window.addEventListener("unhandledrejection", handleUnhandledRejection);
319
+
189
320
  // Console breadcrumbs
190
321
  ["log", "info", "warn", "error", "debug"].forEach((level) => {
191
322
  if (!console[level]) return;
@@ -348,6 +479,20 @@ function setupBrowserInstrumentation() {
348
479
  duration_ms: duration,
349
480
  },
350
481
  });
482
+ if (CAPTURE_FAILED_REQUESTS) {
483
+ void captureException(error, {
484
+ tags: {
485
+ handled: true,
486
+ mechanism: "fetch",
487
+ operation: "http.client",
488
+ },
489
+ extra: {
490
+ url,
491
+ method,
492
+ duration_ms: duration,
493
+ },
494
+ });
495
+ }
351
496
  throw error;
352
497
  }
353
498
  };
@@ -373,9 +518,10 @@ function setupBrowserInstrumentation() {
373
518
  const start = Date.now();
374
519
  xhr.addEventListener("loadend", () => {
375
520
  const duration = Date.now() - start;
521
+ const level = xhr.status >= 500 || xhr.status === 0 ? "error" : "info";
376
522
  addBreadcrumb({
377
523
  type: "http",
378
- level: "info",
524
+ level,
379
525
  category: "http",
380
526
  message: `${method} ${url}`,
381
527
  data: {
@@ -386,6 +532,20 @@ function setupBrowserInstrumentation() {
386
532
  },
387
533
  });
388
534
  });
535
+ xhr.addEventListener("error", () => {
536
+ if (!CAPTURE_FAILED_REQUESTS) return;
537
+ void captureException(new Error(`XMLHttpRequest failed: ${method || "GET"} ${url || ""}`), {
538
+ tags: {
539
+ handled: true,
540
+ mechanism: "xhr",
541
+ operation: "http.client",
542
+ },
543
+ extra: {
544
+ url,
545
+ method,
546
+ },
547
+ });
548
+ });
389
549
  origSend.apply(xhr, sendArgs);
390
550
  };
391
551
 
@@ -431,13 +591,41 @@ export function register({
431
591
  ignoreClass = "rr-ignore",
432
592
  maskTextClass = "rr-mask",
433
593
  captureConsoleErrors = true,
594
+ captureResourceErrors = true,
595
+ captureFailedRequests = true,
596
+ captureNodeWarnings = false,
434
597
  projectRoot = null,
435
598
  }) {
599
+ const nextBrowserRegisterKey = isBrowser
600
+ ? JSON.stringify({
601
+ dsn,
602
+ app_env,
603
+ release,
604
+ debug,
605
+ replaysSessionSampleRate,
606
+ replaysOnErrorSampleRate,
607
+ maskAllInputs,
608
+ blockClass,
609
+ ignoreClass,
610
+ maskTextClass,
611
+ captureConsoleErrors,
612
+ captureResourceErrors,
613
+ captureFailedRequests,
614
+ })
615
+ : null;
616
+
617
+ if (isBrowser && browserRegisterKey === nextBrowserRegisterKey) {
618
+ return;
619
+ }
620
+
436
621
  DSN = dsn;
437
622
  APP_ENV = app_env;
438
623
  RELEASE = release;
439
624
  DEBUG = debug;
440
625
  CAPTURE_CONSOLE_ERRORS = Boolean(captureConsoleErrors);
626
+ CAPTURE_RESOURCE_ERRORS = Boolean(captureResourceErrors);
627
+ CAPTURE_FAILED_REQUESTS = Boolean(captureFailedRequests);
628
+ CAPTURE_NODE_WARNINGS = Boolean(captureNodeWarnings);
441
629
  setProjectRoot(
442
630
  projectRoot ||
443
631
  (isNode ? process.env.WATCHFORGE_PROJECT_ROOT || process.env.INIT_CWD : null)
@@ -467,6 +655,7 @@ export function register({
467
655
 
468
656
  // Browser: Set up full instrumentation (errors, breadcrumbs, HTTP, navigation)
469
657
  if (isBrowser) {
658
+ browserRegisterKey = nextBrowserRegisterKey;
470
659
  setupBrowserInstrumentation();
471
660
  replayInitPromise = initReplay({
472
661
  replaysSessionSampleRate,
@@ -491,6 +680,40 @@ export function register({
491
680
  process.on("unhandledRejection", (reason) => {
492
681
  void captureException(reason);
493
682
  });
683
+
684
+ process.on("multipleResolves", (type, promise, reason) => {
685
+ void captureException(
686
+ normalizeException(reason, `Node.js promise multipleResolves: ${type}`),
687
+ {
688
+ tags: {
689
+ handled: false,
690
+ mechanism: "process.multipleResolves",
691
+ type,
692
+ },
693
+ }
694
+ );
695
+ });
696
+
697
+ process.on("warning", (warning) => {
698
+ addBreadcrumb({
699
+ type: "warning",
700
+ level: "warning",
701
+ category: "process.warning",
702
+ message: warning?.message || String(warning),
703
+ data: {
704
+ name: warning?.name || null,
705
+ code: warning?.code || null,
706
+ },
707
+ });
708
+
709
+ if (!CAPTURE_NODE_WARNINGS) return;
710
+ void captureException(normalizeException(warning, "Node.js process warning"), {
711
+ tags: {
712
+ handled: true,
713
+ mechanism: "process.warning",
714
+ },
715
+ });
716
+ });
494
717
  }
495
718
  }
496
719
 
package/src/express.d.ts CHANGED
@@ -10,3 +10,15 @@ export function expressMiddleware(): (
10
10
  res: any,
11
11
  next: (error?: unknown) => void
12
12
  ) => void;
13
+
14
+ export type ExpressHandler = (
15
+ req: any,
16
+ res: any,
17
+ next?: (error?: unknown) => void
18
+ ) => unknown | Promise<unknown>;
19
+
20
+ export function withWatchForgeExpressHandler<T extends ExpressHandler>(
21
+ handler: T
22
+ ): T;
23
+
24
+ export const wrapExpressHandler: typeof withWatchForgeExpressHandler;
package/src/express.js CHANGED
@@ -87,6 +87,75 @@ export function expressMiddleware() {
87
87
  };
88
88
  }
89
89
 
90
+ export function withWatchForgeExpressHandler(handler) {
91
+ return async function watchForgeExpressHandler(req, res, next) {
92
+ try {
93
+ return await handler(req, res, next);
94
+ } catch (error) {
95
+ await captureException(error, {
96
+ user: getUserContext(req),
97
+ request: getExpressRequestContext(req),
98
+ tags: {
99
+ framework: "express",
100
+ route: req.route?.path || req.path,
101
+ runtime: "server",
102
+ },
103
+ extra: {
104
+ params: req.params || {},
105
+ },
106
+ contexts: {
107
+ express: {
108
+ route: req.route?.path || req.path,
109
+ base_url: req.baseUrl || null,
110
+ handler_wrapper: true,
111
+ },
112
+ },
113
+ });
114
+ if (typeof next === "function") {
115
+ return next(error);
116
+ }
117
+ throw error;
118
+ }
119
+ };
120
+ }
121
+
122
+ export const wrapExpressHandler = withWatchForgeExpressHandler;
123
+
124
+ function getRequestIp(req) {
125
+ return (
126
+ req.ip ||
127
+ req.connection?.remoteAddress ||
128
+ req.socket?.remoteAddress ||
129
+ (req.connection?.socket ? req.connection.socket.remoteAddress : null) ||
130
+ req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
131
+ req.headers["x-real-ip"] ||
132
+ null
133
+ );
134
+ }
135
+
136
+ function getUserContext(req) {
137
+ const ip = getRequestIp(req);
138
+ return req.user
139
+ ? {
140
+ id: String(req.user.id),
141
+ email: req.user.email || null,
142
+ username: req.user.username || null,
143
+ ip_address: ip,
144
+ }
145
+ : null;
146
+ }
147
+
148
+ function getExpressRequestContext(req) {
149
+ return {
150
+ url: `${req.protocol}://${req.get("host")}${req.originalUrl || req.url}`,
151
+ method: req.method,
152
+ headers: sanitizeHeaders(req.headers),
153
+ query: req.query || {},
154
+ body: req.body || {},
155
+ ip: getRequestIp(req),
156
+ };
157
+ }
158
+
90
159
  function sanitizeHeaders(headers) {
91
160
  const sanitized = {};
92
161
  for (const [key, value] of Object.entries(headers)) {
package/src/index.d.ts CHANGED
@@ -10,6 +10,9 @@ export interface WatchForgeRegisterOptions {
10
10
  ignoreClass?: string;
11
11
  maskTextClass?: string;
12
12
  captureConsoleErrors?: boolean;
13
+ captureResourceErrors?: boolean;
14
+ captureFailedRequests?: boolean;
15
+ captureNodeWarnings?: boolean;
13
16
  projectRoot?: string | null;
14
17
  }
15
18
 
@@ -41,3 +44,10 @@ export {
41
44
  Transaction,
42
45
  Span,
43
46
  } from "./tracing.js";
47
+
48
+ export {
49
+ expressMiddleware,
50
+ expressRequestMiddleware,
51
+ withWatchForgeExpressHandler,
52
+ wrapExpressHandler,
53
+ } from "./express.js";
package/src/index.js CHANGED
@@ -13,4 +13,9 @@ export { init } from "./client.js";
13
13
  export { startTransaction, getCurrentTransaction, finishTransaction, Transaction, Span } from "./tracing.js";
14
14
 
15
15
  // Export framework integrations
16
- export { expressMiddleware, expressRequestMiddleware } from "./express.js";
16
+ export {
17
+ expressMiddleware,
18
+ expressRequestMiddleware,
19
+ withWatchForgeExpressHandler,
20
+ wrapExpressHandler,
21
+ } from "./express.js";
@@ -45,3 +45,18 @@ export type NextRouteHandler<TContext = unknown> = (
45
45
  export function withWatchForgeRouteHandler<TContext = unknown>(
46
46
  handler: NextRouteHandler<TContext>
47
47
  ): NextRouteHandler<TContext>;
48
+
49
+ export function withWatchForgeServerAction<TArgs extends unknown[], TResult>(
50
+ action: (...args: TArgs) => TResult | Promise<TResult>,
51
+ actionName?: string
52
+ ): (...args: TArgs) => Promise<TResult>;
53
+
54
+ export type NextApiHandler = (req: any, res: any) => unknown | Promise<unknown>;
55
+
56
+ export function withWatchForgeApiHandler<T extends NextApiHandler>(
57
+ handler: T
58
+ ): T;
59
+
60
+ export const wrapRouteHandler: typeof withWatchForgeRouteHandler;
61
+ export const wrapServerAction: typeof withWatchForgeServerAction;
62
+ export const wrapApiHandler: typeof withWatchForgeApiHandler;
@@ -116,3 +116,63 @@ export function withWatchForgeRouteHandler(handler) {
116
116
  }
117
117
  };
118
118
  }
119
+
120
+ export function withWatchForgeServerAction(action, actionName = action?.name || "serverAction") {
121
+ return async function watchForgeServerAction(...args) {
122
+ try {
123
+ return await action(...args);
124
+ } catch (error) {
125
+ await captureException(error, {
126
+ tags: {
127
+ framework: "nextjs",
128
+ runtime: "server",
129
+ mechanism: "server-action",
130
+ action: actionName,
131
+ },
132
+ contexts: {
133
+ nextjs: {
134
+ router: "app",
135
+ server_action: true,
136
+ action: actionName,
137
+ },
138
+ },
139
+ });
140
+ throw error;
141
+ }
142
+ };
143
+ }
144
+
145
+ export function withWatchForgeApiHandler(handler) {
146
+ return async function watchForgeApiHandler(req, res) {
147
+ try {
148
+ return await handler(req, res);
149
+ } catch (error) {
150
+ await captureException(error, {
151
+ request: {
152
+ url: req?.url || "",
153
+ method: req?.method || "",
154
+ headers: sanitizeRequestHeaders(req?.headers),
155
+ query: req?.query || {},
156
+ body: req?.body,
157
+ },
158
+ tags: {
159
+ framework: "nextjs",
160
+ runtime: "server",
161
+ router: "pages",
162
+ route_type: "api",
163
+ },
164
+ contexts: {
165
+ nextjs: {
166
+ router: "pages",
167
+ api_route: true,
168
+ },
169
+ },
170
+ });
171
+ throw error;
172
+ }
173
+ };
174
+ }
175
+
176
+ export const wrapRouteHandler = withWatchForgeRouteHandler;
177
+ export const wrapServerAction = withWatchForgeServerAction;
178
+ export const wrapApiHandler = withWatchForgeApiHandler;
package/src/next.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { WatchForgeRegisterOptions } from "./index.js";
4
4
  export interface WatchForgeProviderProps {
5
5
  options: WatchForgeRegisterOptions;
6
6
  children?: ReactNode;
7
+ fallback?: ReactNode;
7
8
  }
8
9
 
9
10
  export function WatchForgeProvider(
package/src/next.js CHANGED
@@ -2,11 +2,14 @@
2
2
 
3
3
  import React, { useEffect } from "react";
4
4
  import { register } from "./client.js";
5
+ import { ErrorBoundary } from "./react.js";
5
6
 
6
- export function WatchForgeProvider({ options, children = null }) {
7
+ export function WatchForgeProvider({ options, children = null, fallback = null }) {
7
8
  useEffect(() => {
8
9
  register(options);
9
10
  }, [options]);
10
11
 
11
- return React.createElement(React.Fragment, null, children);
12
+ if (!children) return null;
13
+
14
+ return React.createElement(ErrorBoundary, { fallback }, children);
12
15
  }
package/src/react.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Component, ReactNode } from "react";
1
+ import type { Component, ComponentType, ReactNode } from "react";
2
2
 
3
3
  export interface ErrorBoundaryProps {
4
4
  children?: ReactNode;
@@ -6,3 +6,10 @@ export interface ErrorBoundaryProps {
6
6
  }
7
7
 
8
8
  export class ErrorBoundary extends Component<ErrorBoundaryProps> {}
9
+
10
+ export function withWatchForgeErrorBoundary<P>(
11
+ component: ComponentType<P>,
12
+ options?: Pick<ErrorBoundaryProps, "fallback">
13
+ ): ComponentType<P>;
14
+
15
+ export const withErrorBoundary: typeof withWatchForgeErrorBoundary;
package/src/react.js CHANGED
@@ -50,3 +50,21 @@ export class ErrorBoundary extends React.Component {
50
50
  return this.props.children;
51
51
  }
52
52
  }
53
+
54
+ export function withWatchForgeErrorBoundary(Component, options = {}) {
55
+ const Wrapped = function WatchForgeErrorBoundaryWrapper(props) {
56
+ return React.createElement(
57
+ ErrorBoundary,
58
+ { fallback: options.fallback },
59
+ React.createElement(Component, props)
60
+ );
61
+ };
62
+
63
+ Wrapped.displayName = `withWatchForgeErrorBoundary(${
64
+ Component.displayName || Component.name || "Component"
65
+ })`;
66
+
67
+ return Wrapped;
68
+ }
69
+
70
+ export const withErrorBoundary = withWatchForgeErrorBoundary;
package/src/stacktrace.js CHANGED
@@ -538,6 +538,37 @@ async function fetchBrowserSourceMap(absPath) {
538
538
  }
539
539
  }
540
540
 
541
+ function parseDataSourceMap(sourceMappingUrl) {
542
+ if (!sourceMappingUrl?.startsWith("data:")) return null;
543
+
544
+ try {
545
+ const commaIndex = sourceMappingUrl.indexOf(",");
546
+ if (commaIndex === -1) return null;
547
+
548
+ const metadata = sourceMappingUrl.slice(0, commaIndex);
549
+ const payload = sourceMappingUrl.slice(commaIndex + 1);
550
+ const json = metadata.includes(";base64")
551
+ ? decodeURIComponent(escape(atob(payload)))
552
+ : decodeURIComponent(payload);
553
+ return JSON.parse(json);
554
+ } catch {
555
+ return null;
556
+ }
557
+ }
558
+
559
+ function findInlineSourceMap(sourceText) {
560
+ if (!sourceText || typeof sourceText !== "string") return null;
561
+
562
+ const sourceMapPattern = /\/\/# sourceMappingURL=([^\s"'\\)]+)/g;
563
+ let match;
564
+ while ((match = sourceMapPattern.exec(sourceText))) {
565
+ const sourceMap = parseDataSourceMap(match[1]);
566
+ if (sourceMap) return sourceMap;
567
+ }
568
+
569
+ return null;
570
+ }
571
+
541
572
  async function findSourceMapForFrame(frame) {
542
573
  const wanted = normalizeSourcePath(frame.abs_path || frame.module);
543
574
  const scriptUrls = [];
@@ -582,16 +613,31 @@ async function findSourceMapForFrame(frame) {
582
613
  return null;
583
614
  }
584
615
 
585
- async function enrichFrameWithSourceMap(frame) {
586
- const resolved = await findSourceMapForFrame(frame);
587
- if (!resolved) return false;
588
-
589
- const { sourceMap } = resolved;
590
-
616
+ async function applySourceMapToFrame(frame, sourceMap) {
591
617
  try {
592
618
  const SourceMapConsumer = await getSourceMapConsumer();
593
619
  if (!SourceMapConsumer) return false;
594
620
 
621
+ const sources = Array.isArray(sourceMap.sources) ? sourceMap.sources : [];
622
+ const contents = Array.isArray(sourceMap.sourcesContent)
623
+ ? sourceMap.sourcesContent
624
+ : [];
625
+ const matchedSourceIndex = sources.findIndex((candidate) =>
626
+ sourceMatchesFrame(candidate, frame.abs_path || frame.module || frame.raw_abs_path)
627
+ );
628
+
629
+ if (matchedSourceIndex >= 0 && contents[matchedSourceIndex]) {
630
+ const source = sources[matchedSourceIndex];
631
+ const sourceLines = contents[matchedSourceIndex].split("\n");
632
+ const originalLine = frame.lineno;
633
+ if (originalLine && originalLine <= sourceLines.length) {
634
+ frame.abs_path = source;
635
+ frame.filename = normalizeSourcePath(source).split("/").pop() || frame.filename;
636
+ applySourceContext(frame, sourceLines, originalLine);
637
+ if (frame.context_line) return true;
638
+ }
639
+ }
640
+
595
641
  const consumer = await new SourceMapConsumer(sourceMap);
596
642
  let source = null;
597
643
  let lineno = frame.lineno;
@@ -639,28 +685,49 @@ async function enrichFrameWithSourceMap(frame) {
639
685
  }
640
686
  }
641
687
 
642
- function getSourceContentFromWebpackFrame(frame) {
643
- const globalObject = typeof globalThis !== "undefined" ? globalThis : window;
644
- const webpackChunks =
645
- Object.values(globalObject).find(
646
- (value) =>
647
- Array.isArray(value) &&
648
- value.some((item) => Array.isArray(item) && item.length >= 2)
649
- ) || [];
688
+ async function enrichFrameWithSourceMap(frame) {
689
+ const resolved = await findSourceMapForFrame(frame);
690
+ if (!resolved) return false;
650
691
 
692
+ return applySourceMapToFrame(frame, resolved.sourceMap);
693
+ }
694
+
695
+ function getWebpackChunkGlobals(globalObject) {
696
+ return Object.entries(globalObject)
697
+ .filter(([, value]) => Array.isArray(value))
698
+ .sort(([nameA], [nameB]) => {
699
+ const score = (name) => (name.startsWith("webpackChunk") ? 0 : 1);
700
+ return score(nameA) - score(nameB);
701
+ })
702
+ .map(([, value]) => value);
703
+ }
704
+
705
+ function getWebpackFrameArtifacts(frame) {
706
+ const globalObject = typeof globalThis !== "undefined" ? globalThis : window;
651
707
  const framePath = normalizeSourcePath(
652
708
  frame.abs_path || frame.module || frame.raw_abs_path
653
709
  );
654
- if (!framePath || !Array.isArray(webpackChunks)) return null;
655
-
656
- for (const chunk of webpackChunks) {
657
- const modules = Array.isArray(chunk) ? chunk[1] : null;
658
- if (!modules || typeof modules !== "object") continue;
710
+ if (!framePath) return null;
711
+
712
+ for (const webpackChunks of getWebpackChunkGlobals(globalObject)) {
713
+ for (const chunk of webpackChunks) {
714
+ const modules = Array.isArray(chunk) ? chunk[1] : null;
715
+ if (!modules || typeof modules !== "object") continue;
716
+
717
+ for (const [moduleId, factory] of Object.entries(modules)) {
718
+ const factorySource = String(factory);
719
+ if (
720
+ !sourceMatchesFrame(moduleId, framePath) &&
721
+ !sourceMatchesFrame(factorySource.match(/sourceURL=([^\s"'\\)]+)/)?.[1], framePath)
722
+ ) {
723
+ continue;
724
+ }
659
725
 
660
- for (const [moduleId, factory] of Object.entries(modules)) {
661
- if (!sourceMatchesFrame(moduleId, framePath)) continue;
662
- const source = String(factory);
663
- return source.includes("\n") ? source.split("\n") : null;
726
+ return {
727
+ lines: factorySource.includes("\n") ? factorySource.split("\n") : null,
728
+ sourceMap: findInlineSourceMap(factorySource),
729
+ };
730
+ }
664
731
  }
665
732
  }
666
733
 
@@ -670,9 +737,14 @@ function getSourceContentFromWebpackFrame(frame) {
670
737
  async function enrichFrameWithBrowserSource(frame) {
671
738
  if (!frame.in_app || !frame.lineno) return;
672
739
 
673
- const webpackLines = getSourceContentFromWebpackFrame(frame);
674
- if (webpackLines) {
675
- applySourceContext(frame, webpackLines, frame.lineno);
740
+ const webpackArtifacts = getWebpackFrameArtifacts(frame);
741
+ if (webpackArtifacts?.sourceMap) {
742
+ const applied = await applySourceMapToFrame(frame, webpackArtifacts.sourceMap);
743
+ if (applied) return;
744
+ }
745
+
746
+ if (webpackArtifacts?.lines) {
747
+ applySourceContext(frame, webpackArtifacts.lines, frame.lineno);
676
748
  if (frame.context_line) return;
677
749
  }
678
750