@watchforge/browser 0.1.11 → 0.1.13

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
@@ -214,6 +214,7 @@ function createConfig(cwd, args) {
214
214
  replaysOnErrorSampleRate: ${Number(args.replaysOnError)},
215
215
  replaysSessionSampleRate: ${Number(args.replaysSession)},
216
216
  maskAllInputs: true,
217
+ projectRoot: process.env.INIT_CWD || process.env.PWD || "",
217
218
  };
218
219
  `;
219
220
  writeIfChanged(configPath, content);
@@ -308,21 +309,25 @@ import { watchforgeConfig } from "${configImport}";
308
309
  export default function GlobalError(${propsSignature}) {
309
310
  useEffect(() => {
310
311
  register(watchforgeConfig);
311
- void captureException(error, {
312
- tags: {
313
- framework: "nextjs",
314
- runtime: "error-boundary",
315
- },
316
- extra: {
317
- digest: error.digest,
318
- },
319
- contexts: {
320
- nextjs: {
321
- error_boundary: "global-error",
322
- digest: error.digest || null,
312
+ const timer = window.setTimeout(() => {
313
+ void captureException(error, {
314
+ tags: {
315
+ framework: "nextjs",
316
+ runtime: "error-boundary",
323
317
  },
324
- },
325
- });
318
+ extra: {
319
+ digest: error.digest,
320
+ },
321
+ contexts: {
322
+ nextjs: {
323
+ error_boundary: "global-error",
324
+ digest: error.digest || null,
325
+ },
326
+ },
327
+ });
328
+ }, 100);
329
+
330
+ return () => window.clearTimeout(timer);
326
331
  }, [error]);
327
332
 
328
333
  return (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watchforge/browser",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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
@@ -2,6 +2,7 @@ import { sendEvent, parseDsn } from "./transport.js";
2
2
  import {
3
3
  buildStacktraceFromError,
4
4
  enrichStacktraceAsync,
5
+ setProjectRoot,
5
6
  } from "./stacktrace.js";
6
7
  import {
7
8
  getSdkMetadata,
@@ -13,6 +14,7 @@ import {
13
14
  flushReplayForEvent,
14
15
  getReplayContext,
15
16
  initReplay,
17
+ waitForReplayReady,
16
18
  } from "./replay.js";
17
19
 
18
20
  let DSN = null;
@@ -31,6 +33,7 @@ const MAX_BREADCRUMBS = 100;
31
33
  let breadcrumbs = [];
32
34
  let browserInstrumentationInstalled = false;
33
35
  let nodeInstrumentationInstalled = false;
36
+ let replayInitPromise = null;
34
37
 
35
38
  export function addBreadcrumb(breadcrumb) {
36
39
  const entry = {
@@ -428,12 +431,17 @@ export function register({
428
431
  ignoreClass = "rr-ignore",
429
432
  maskTextClass = "rr-mask",
430
433
  captureConsoleErrors = true,
434
+ projectRoot = null,
431
435
  }) {
432
436
  DSN = dsn;
433
437
  APP_ENV = app_env;
434
438
  RELEASE = release;
435
439
  DEBUG = debug;
436
440
  CAPTURE_CONSOLE_ERRORS = Boolean(captureConsoleErrors);
441
+ setProjectRoot(
442
+ projectRoot ||
443
+ (isNode ? process.env.WATCHFORGE_PROJECT_ROOT || process.env.INIT_CWD : null)
444
+ );
437
445
 
438
446
  // Initialize tracing
439
447
  initTracing(dsn, app_env, debug);
@@ -460,7 +468,7 @@ export function register({
460
468
  // Browser: Set up full instrumentation (errors, breadcrumbs, HTTP, navigation)
461
469
  if (isBrowser) {
462
470
  setupBrowserInstrumentation();
463
- initReplay({
471
+ replayInitPromise = initReplay({
464
472
  replaysSessionSampleRate,
465
473
  replaysOnErrorSampleRate,
466
474
  maskAllInputs,
@@ -497,7 +505,10 @@ export async function captureException(error, context = {}) {
497
505
 
498
506
  let stacktrace = buildStacktraceFromError(error);
499
507
  if (stacktrace) {
500
- stacktrace = await enrichStacktraceAsync(stacktrace);
508
+ stacktrace = await enrichStacktraceAsync(
509
+ stacktrace,
510
+ error?.message || String(error || "")
511
+ );
501
512
  }
502
513
 
503
514
  const event = {
@@ -511,6 +522,15 @@ export async function captureException(error, context = {}) {
511
522
  sdk: getSdkMetadata(),
512
523
  };
513
524
 
525
+ if (isBrowser && replayInitPromise) {
526
+ try {
527
+ await replayInitPromise;
528
+ await waitForReplayReady();
529
+ } catch {
530
+ // Replay is best-effort; never block error delivery.
531
+ }
532
+ }
533
+
514
534
  const replay = flushReplayForEvent(DSN, event.event_id);
515
535
  if (replay) {
516
536
  event.replay_id = replay.replay_id;
package/src/contexts.js CHANGED
@@ -7,7 +7,7 @@ const isNode =
7
7
  typeof process !== "undefined" && process.versions && process.versions.node;
8
8
 
9
9
  export const SDK_NAME = "@watchforge/browser";
10
- export const SDK_VERSION = "0.1.11";
10
+ export const SDK_VERSION = "0.1.12";
11
11
 
12
12
  export function getSdkMetadata() {
13
13
  return {
package/src/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export interface WatchForgeRegisterOptions {
10
10
  ignoreClass?: string;
11
11
  maskTextClass?: string;
12
12
  captureConsoleErrors?: boolean;
13
+ projectRoot?: string | null;
13
14
  }
14
15
 
15
16
  export interface WatchForgeCaptureContext {
package/src/replay.js CHANGED
@@ -120,6 +120,27 @@ export async function initReplay(config = {}) {
120
120
  }
121
121
  }
122
122
 
123
+ export function waitForReplayReady(timeoutMs = 300) {
124
+ if (!isBrowser || !replayId || !sessionId) return Promise.resolve(false);
125
+ if (events.length > 0) return Promise.resolve(true);
126
+
127
+ return new Promise((resolve) => {
128
+ const started = Date.now();
129
+ const check = () => {
130
+ if (events.length > 0) {
131
+ resolve(true);
132
+ return;
133
+ }
134
+ if (Date.now() - started >= timeoutMs) {
135
+ resolve(false);
136
+ return;
137
+ }
138
+ setTimeout(check, 25);
139
+ };
140
+ check();
141
+ });
142
+ }
143
+
123
144
  export function getReplayContext() {
124
145
  if (!isBrowser || !replayId || !sessionId) return null;
125
146
  return {
@@ -151,7 +172,7 @@ export function flushReplayForEvent(dsn, eventId) {
151
172
  events,
152
173
  sdk: {
153
174
  name: "@watchforge/browser",
154
- version: "0.1.11",
175
+ version: "0.1.12",
155
176
  },
156
177
  };
157
178
 
package/src/stacktrace.js CHANGED
@@ -60,6 +60,7 @@ export function buildStacktraceFromError(error) {
60
60
  return null;
61
61
  }
62
62
 
63
+ const errorMessage = error?.message || "";
63
64
  const stackLines = error.stack.split("\n");
64
65
  const frames = [];
65
66
 
@@ -88,6 +89,7 @@ export function buildStacktraceFromError(error) {
88
89
  post_context: [],
89
90
  vars: {},
90
91
  in_app: inApp,
92
+ error_message: errorMessage,
91
93
  });
92
94
  }
93
95
 
@@ -114,6 +116,45 @@ function applySourceContext(frame, sourceLines, lineno) {
114
116
  .map((l) => l.replace(/\r$/, ""));
115
117
  }
116
118
 
119
+ function getUndefinedIdentifier(message) {
120
+ if (!message || typeof message !== "string") return null;
121
+ const match = message.match(/^([A-Za-z_$][\w$]*) is not defined$/);
122
+ return match?.[1] || null;
123
+ }
124
+
125
+ function inferLineFromErrorMessage(frame, sourceLines) {
126
+ const identifier = getUndefinedIdentifier(frame.error_message);
127
+ if (!identifier || !sourceLines?.length) return null;
128
+
129
+ const tokenPattern = new RegExp(`\\b${identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
130
+ const functionName = frame.function && frame.function !== "<anonymous>"
131
+ ? String(frame.function)
132
+ : null;
133
+
134
+ const candidates = [];
135
+ for (let i = 0; i < sourceLines.length; i++) {
136
+ if (!tokenPattern.test(sourceLines[i])) continue;
137
+ candidates.push(i);
138
+ }
139
+
140
+ if (!candidates.length) return null;
141
+
142
+ if (functionName) {
143
+ const functionPattern = new RegExp(`\\b${functionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
144
+ let nearestFunctionLine = -1;
145
+ for (let i = 0; i < sourceLines.length; i++) {
146
+ if (functionPattern.test(sourceLines[i])) {
147
+ nearestFunctionLine = i;
148
+ }
149
+ if (candidates.includes(i) && nearestFunctionLine !== -1) {
150
+ return i + 1;
151
+ }
152
+ }
153
+ }
154
+
155
+ return candidates[0] + 1;
156
+ }
157
+
117
158
  function normalizeSourcePath(source) {
118
159
  if (!source) return "";
119
160
 
@@ -167,6 +208,114 @@ function getNodeModules() {
167
208
  return nodeModulesPromise;
168
209
  }
169
210
 
211
+ let cachedProjectRoots = null;
212
+ let configuredProjectRoot = null;
213
+
214
+ export function setProjectRoot(projectRoot) {
215
+ configuredProjectRoot = projectRoot || null;
216
+ cachedProjectRoots = null;
217
+ }
218
+
219
+ function packageJsonHasNext(packageJson) {
220
+ return Boolean(packageJson.dependencies?.next || packageJson.devDependencies?.next);
221
+ }
222
+
223
+ async function findProjectRoots() {
224
+ if (cachedProjectRoots) return cachedProjectRoots;
225
+
226
+ const mods = await getNodeModules();
227
+ if (!mods) {
228
+ cachedProjectRoots = [process.cwd()];
229
+ return cachedProjectRoots;
230
+ }
231
+
232
+ const { fs, path } = mods;
233
+ const roots = new Set();
234
+ const seeds = [
235
+ configuredProjectRoot,
236
+ process.env.WATCHFORGE_PROJECT_ROOT,
237
+ process.env.INIT_CWD,
238
+ process.env.PWD,
239
+ process.cwd(),
240
+ ].filter(Boolean);
241
+
242
+ for (const seed of seeds) {
243
+ roots.add(seed);
244
+
245
+ let dir = seed;
246
+ for (let depth = 0; depth < 10; depth++) {
247
+ const packageJsonPath = path.join(dir, "package.json");
248
+ if (fs.existsSync(packageJsonPath)) {
249
+ try {
250
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
251
+ if (packageJsonHasNext(packageJson)) {
252
+ roots.add(dir);
253
+ }
254
+ } catch {
255
+ // ignore invalid package.json
256
+ }
257
+ }
258
+
259
+ const parent = path.dirname(dir);
260
+ if (parent === dir) break;
261
+ dir = parent;
262
+ }
263
+ }
264
+
265
+ cachedProjectRoots = [...roots];
266
+ return cachedProjectRoots;
267
+ }
268
+
269
+ function readFileLines(fs, filePath) {
270
+ const stat = fs.statSync(filePath);
271
+ if (stat.size > MAX_SOURCE_FILE_BYTES) return null;
272
+ return fs.readFileSync(filePath, "utf8").split("\n");
273
+ }
274
+
275
+ function findFileUnderRoot(fs, path, root, normalizedPath, basename, maxDepth = 5) {
276
+ const suffix = normalizedPath.replace(/^\/+/, "");
277
+ const directCandidate = path.join(root, suffix);
278
+ if (fs.existsSync(directCandidate)) {
279
+ return directCandidate;
280
+ }
281
+
282
+ const stack = [{ dir: root, depth: 0 }];
283
+ let scannedFiles = 0;
284
+
285
+ while (stack.length > 0 && scannedFiles < 500) {
286
+ const { dir, depth } = stack.pop();
287
+ if (depth > maxDepth) continue;
288
+
289
+ let entries = [];
290
+ try {
291
+ entries = fs.readdirSync(dir, { withFileTypes: true });
292
+ } catch {
293
+ continue;
294
+ }
295
+
296
+ for (const entry of entries) {
297
+ if (entry.name === "node_modules" || entry.name === ".next" || entry.name === ".git") {
298
+ continue;
299
+ }
300
+
301
+ const entryPath = path.join(dir, entry.name);
302
+ if (entry.isFile()) {
303
+ scannedFiles += 1;
304
+ if (entry.name === basename) {
305
+ const normalizedEntry = entryPath.replace(/\\/g, "/");
306
+ if (normalizedEntry.endsWith(suffix)) {
307
+ return entryPath;
308
+ }
309
+ }
310
+ } else if (entry.isDirectory()) {
311
+ stack.push({ dir: entryPath, depth: depth + 1 });
312
+ }
313
+ }
314
+ }
315
+
316
+ return null;
317
+ }
318
+
170
319
  async function readNodeSourceLines(absPath) {
171
320
  if (!isNode || !absPath) return null;
172
321
 
@@ -185,26 +334,87 @@ async function readNodeSourceLines(absPath) {
185
334
  filePath = fileURLToPath(filePath);
186
335
  }
187
336
 
188
- if (!path.isAbsolute(filePath)) {
189
- filePath = path.resolve(process.cwd(), filePath);
337
+ const normalizedPath = normalizeSourcePath(filePath);
338
+ const roots = await findProjectRoots();
339
+ const candidates = new Set();
340
+
341
+ if (path.isAbsolute(filePath)) {
342
+ candidates.add(filePath);
343
+ }
344
+
345
+ for (const root of roots) {
346
+ candidates.add(path.resolve(root, filePath));
347
+ candidates.add(path.resolve(root, normalizedPath));
348
+
349
+ const srcMatch = normalizedPath.match(/(?:^|\/)?(src\/.+)$/);
350
+ if (srcMatch) {
351
+ candidates.add(path.resolve(root, srcMatch[1]));
352
+ }
353
+ }
354
+
355
+ for (const candidate of candidates) {
356
+ if (!candidate || !fs.existsSync(candidate)) continue;
357
+ const lines = readFileLines(fs, candidate);
358
+ if (lines) return lines;
190
359
  }
191
360
 
192
- if (!fs.existsSync(filePath)) return null;
361
+ const basename = path.basename(normalizedPath);
362
+ for (const root of roots) {
363
+ const packageJsonPath = path.join(root, "package.json");
364
+ if (!fs.existsSync(packageJsonPath)) continue;
365
+
366
+ let isNextProject = root === process.env.INIT_CWD || root === process.env.PWD;
367
+ if (!isNextProject) {
368
+ try {
369
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
370
+ isNextProject = packageJsonHasNext(packageJson);
371
+ } catch {
372
+ isNextProject = false;
373
+ }
374
+ }
193
375
 
194
- const stat = fs.statSync(filePath);
195
- if (stat.size > MAX_SOURCE_FILE_BYTES) return null;
376
+ if (!isNextProject) continue;
377
+
378
+ const discovered = findFileUnderRoot(fs, path, root, normalizedPath, basename);
379
+ if (discovered && fs.existsSync(discovered)) {
380
+ const lines = readFileLines(fs, discovered);
381
+ if (lines) return lines;
382
+ }
383
+ }
196
384
 
197
- const content = fs.readFileSync(filePath, "utf8");
198
- return content.split("\n");
385
+ return null;
199
386
  } catch {
200
387
  return null;
201
388
  }
202
389
  }
203
390
 
391
+ function resolveFrameLine(frame, lines) {
392
+ let lineno = frame.lineno;
393
+ if (!lines?.length || !lineno) return lineno;
394
+
395
+ if (lineno > lines.length) {
396
+ const inferredLine = inferLineFromErrorMessage(frame, lines);
397
+ if (inferredLine) {
398
+ lineno = inferredLine;
399
+ frame.lineno = inferredLine;
400
+ const identifier = getUndefinedIdentifier(frame.error_message);
401
+ if (identifier) {
402
+ const col = lines[inferredLine - 1].indexOf(identifier);
403
+ if (col >= 0) frame.colno = col + 1;
404
+ }
405
+ }
406
+ }
407
+
408
+ return lineno;
409
+ }
410
+
204
411
  async function enrichFrameWithNodeSource(frame) {
205
412
  if (!frame.in_app || !frame.lineno) return;
206
413
  const lines = await readNodeSourceLines(frame.raw_abs_path || frame.abs_path);
207
- if (lines) applySourceContext(frame, lines, frame.lineno);
414
+ if (!lines) return;
415
+
416
+ const lineno = resolveFrameLine(frame, lines);
417
+ applySourceContext(frame, lines, lineno);
208
418
  }
209
419
 
210
420
  function getBrowserSourceFetchCandidates(absPath, rawAbsPath) {
@@ -475,13 +685,21 @@ async function enrichFrameWithBrowserSource(frame) {
475
685
  await enrichFrameWithSourceMap(frame);
476
686
  }
477
687
 
478
- export async function enrichStacktraceAsync(stacktrace) {
688
+ export async function enrichStacktraceAsync(stacktrace, errorMessage = null) {
479
689
  if (!stacktrace?.frames?.length) return stacktrace;
480
690
 
481
691
  const inAppFrames = stacktrace.frames.filter((f) => f.in_app);
482
692
  // Enrich the error frame first (last in-app frame), then nearby frames.
483
693
  const targets = [...inAppFrames.slice(-5)].reverse();
484
694
 
695
+ if (errorMessage) {
696
+ for (const frame of targets) {
697
+ if (!frame.error_message) {
698
+ frame.error_message = errorMessage;
699
+ }
700
+ }
701
+ }
702
+
485
703
  if (isNode) {
486
704
  await Promise.all(targets.map((frame) => enrichFrameWithNodeSource(frame)));
487
705
  } else if (isBrowser) {
package/src/tracing.js CHANGED
@@ -118,7 +118,7 @@ class Transaction {
118
118
  request: this.request,
119
119
  platform: typeof window !== "undefined" ? "javascript" : "node",
120
120
  sdk_name: "watchforge-javascript",
121
- sdk_version: "0.1.11",
121
+ sdk_version: "0.1.12",
122
122
  };
123
123
 
124
124
  if (DEBUG) {