@watchforge/browser 0.1.3 → 0.1.4

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.
@@ -68,6 +68,36 @@ After installation, use it the same way:
68
68
  import { register, ErrorBoundary } from '@watchforge/browser';
69
69
  ```
70
70
 
71
+ ### Next.js Wizard Setup
72
+
73
+ From your Next.js project root:
74
+
75
+ ```bash
76
+ npx @watchforge/browser -i nextjs --dsn "https://PUBLIC_KEY@dev.watchforges.com/PROJECT_ID" --app-env production
77
+ ```
78
+
79
+ With error-triggered Session Replay:
80
+
81
+ ```bash
82
+ npx @watchforge/browser -i nextjs \
83
+ --dsn "https://PUBLIC_KEY@dev.watchforges.com/PROJECT_ID" \
84
+ --app-env production \
85
+ --replays-on-error 1
86
+ ```
87
+
88
+ The wizard:
89
+
90
+ 1. Installs `@watchforge/browser`
91
+ 2. Creates `watchforge.config.ts`
92
+ 3. Creates `app/watchforge-init.tsx` (or `src/app/watchforge-init.tsx`)
93
+ 4. Patches `app/layout.tsx` to render `<WatchForgeInit />`
94
+
95
+ If you only want file generation and no package install:
96
+
97
+ ```bash
98
+ npx @watchforge/browser -i nextjs --dsn "..." --skip-install
99
+ ```
100
+
71
101
  ## Quick Testing
72
102
 
73
103
  ### Prerequisites
@@ -710,7 +740,11 @@ try {
710
740
  ```javascript
711
741
  // server.js
712
742
  const express = require('express');
713
- const { register, expressMiddleware } = require('@watchforge/browser');
743
+ const {
744
+ register,
745
+ expressRequestMiddleware,
746
+ expressMiddleware,
747
+ } = require('@watchforge/browser');
714
748
 
715
749
  const app = express();
716
750
 
@@ -719,6 +753,11 @@ register({
719
753
  app_env: process.env.NODE_ENV,
720
754
  });
721
755
 
756
+ app.use(express.json());
757
+ app.use(expressRequestMiddleware());
758
+
759
+ // routes here
760
+
722
761
  // Add error handler (must be last middleware)
723
762
  app.use(expressMiddleware());
724
763
 
@@ -735,6 +774,11 @@ import App from './App';
735
774
  register({
736
775
  dsn: process.env.REACT_APP_WATCHFORGE_DSN,
737
776
  app_env: process.env.NODE_ENV,
777
+ // Optional: upload the last 60s of masked browser replay events when an error occurs.
778
+ replaysOnErrorSampleRate: 1.0,
779
+ // Optional: continuously sample full sessions. Keep 0 in production unless you need it.
780
+ replaysSessionSampleRate: 0,
781
+ maskAllInputs: true,
738
782
  });
739
783
 
740
784
  ReactDOM.render(
@@ -745,6 +789,95 @@ ReactDOM.render(
745
789
  );
746
790
  ```
747
791
 
792
+ ## Stack Trace Source Context
793
+
794
+ The SDK enriches stack frames with Python-style source context when possible:
795
+
796
+ | Field | Description |
797
+ |-------|-------------|
798
+ | `context_line` | The exact line where the error occurred |
799
+ | `pre_context` | Up to 5 lines before the error |
800
+ | `post_context` | Up to 5 lines after the error |
801
+
802
+ ### Node.js / Express
803
+
804
+ For local project files, the SDK reads source from disk and attaches context lines automatically. No extra configuration is required.
805
+
806
+ ### Browser (development)
807
+
808
+ In dev builds (Next.js, Vite, etc.), the SDK attempts to fetch same-origin script URLs from the stack trace and slice source lines around the reported line number.
809
+
810
+ Works when stack frames point to readable URLs such as:
811
+
812
+ ```txt
813
+ http://localhost:3000/_next/static/chunks/...
814
+ http://localhost:3000/src/App.tsx
815
+ ```
816
+
817
+ ### Browser (production / minified bundles)
818
+
819
+ Production bundles often report locations like:
820
+
821
+ ```txt
822
+ https://your-app.com/_next/static/chunks/app-abc123.js:1:98432
823
+ ```
824
+
825
+ The SDK still sends filename, line, and column, but **cannot** show original TypeScript/JSX source without **source maps**.
826
+
827
+ **Current status:** source map upload and symbolication are not yet supported (planned for a future release).
828
+
829
+ **Workarounds today:**
830
+
831
+ 1. Test with `npm run dev` / unminified builds to see source lines in WatchForge.
832
+ 2. Use `release` in `register()` so issues are grouped by deploy version.
833
+ 3. Rely on breadcrumbs and the Browser Event Summary in the dashboard to understand user actions before the error.
834
+
835
+ When source map support ships, you will upload maps during deploy and WatchForge will resolve minified frames back to original files.
836
+
837
+ ## Session Replay
838
+
839
+ The browser SDK can record a Sentry-style DOM replay using `rrweb`. Replays are **not videos**; they are masked DOM snapshots and incremental browser events that WatchForge can play back in the Issue Detail page.
840
+
841
+ Enable replay capture when registering the SDK:
842
+
843
+ ```javascript
844
+ register({
845
+ dsn: "https://PUBLIC_KEY@dev.watchforges.com/PROJECT_ID",
846
+ app_env: "production",
847
+ replaysOnErrorSampleRate: 1.0,
848
+ replaysSessionSampleRate: 0,
849
+ maskAllInputs: true,
850
+ });
851
+ ```
852
+
853
+ Replay behavior:
854
+
855
+ - `replaysOnErrorSampleRate`: records in buffer mode and uploads the last 60 seconds when an error is captured.
856
+ - `replaysSessionSampleRate`: continuously samples full browser sessions.
857
+ - Text/input privacy is handled in the browser before upload.
858
+ - Password, email, tel and text inputs are masked when `maskAllInputs` is true.
859
+ - Elements with `.rr-block` are blocked.
860
+ - Elements with `.rr-ignore` ignore input events.
861
+ - Elements with `.rr-mask` mask text.
862
+
863
+ When an error is captured, the SDK attaches:
864
+
865
+ ```json
866
+ {
867
+ "replay_id": "...",
868
+ "session_id": "...",
869
+ "contexts": {
870
+ "replay": {
871
+ "replay_id": "...",
872
+ "session_id": "...",
873
+ "sampled": true
874
+ }
875
+ }
876
+ }
877
+ ```
878
+
879
+ The dashboard shows the linked replay in the Issue Detail **Session Replay** tab.
880
+
748
881
  ## Test Scripts
749
882
 
750
883
  Test scripts are included in the SDK directory:
package/README.md CHANGED
@@ -18,6 +18,25 @@ Browser and Node SDK for WatchForge. **One call to `register()`** turns on autom
18
18
  npm install @watchforge/browser
19
19
  ```
20
20
 
21
+ ## Next.js one-line setup
22
+
23
+ For Next.js apps, run the setup wizard from your app root:
24
+
25
+ ```bash
26
+ npx @watchforge/browser -i nextjs --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" --app-env production
27
+ ```
28
+
29
+ With Session Replay on errors:
30
+
31
+ ```bash
32
+ npx @watchforge/browser -i nextjs \
33
+ --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" \
34
+ --app-env production \
35
+ --replays-on-error 1
36
+ ```
37
+
38
+ The wizard installs `@watchforge/browser`, writes `watchforge.config.ts`, creates a client init component, and patches `app/layout.tsx` or `pages/_app.tsx`.
39
+
21
40
  ## Quick start (all most apps need)
22
41
 
23
42
  Call **`register()` once** as early as possible (e.g. app entry / main bundle), with your **DSN** from the WatchForge project settings.
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import process from "node:process";
7
+
8
+ const HELP = `
9
+ WatchForge setup wizard
10
+
11
+ Usage:
12
+ npx @watchforge/browser -i nextjs --dsn <dsn> [options]
13
+ npx @watchforge/browser init nextjs --dsn <dsn> [options]
14
+
15
+ Options:
16
+ -i, --integration <name> Framework integration. Currently: nextjs
17
+ --dsn <dsn> WatchForge DSN
18
+ --app-env <env> App environment (default: production)
19
+ --debug Enable SDK debug logging
20
+ --replays-on-error <rate> Upload replay when errors happen (default: 0)
21
+ --replays-session <rate> Continuously sample sessions (default: 0)
22
+ --skip-install Do not install @watchforge/browser
23
+ -h, --help Show help
24
+
25
+ Example:
26
+ npx @watchforge/browser -i nextjs --dsn "https://PUBLIC_KEY@dev.watchforges.com/PROJECT_ID" --app-env development --replays-on-error 1
27
+ `;
28
+
29
+ function parseArgs(argv) {
30
+ const args = {
31
+ integration: null,
32
+ dsn: null,
33
+ appEnv: "production",
34
+ debug: false,
35
+ replaysOnError: "0",
36
+ replaysSession: "0",
37
+ skipInstall: false,
38
+ help: false,
39
+ };
40
+
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const arg = argv[i];
43
+ const next = argv[i + 1];
44
+
45
+ if (arg === "-h" || arg === "--help") args.help = true;
46
+ else if (arg === "-i" || arg === "--integration") {
47
+ args.integration = next;
48
+ i++;
49
+ } else if (arg === "init" || arg === "setup") {
50
+ args.integration = argv[i + 1] || args.integration;
51
+ i++;
52
+ } else if (arg === "--dsn") {
53
+ args.dsn = next;
54
+ i++;
55
+ } else if (arg === "--app-env" || arg === "--environment") {
56
+ args.appEnv = next || args.appEnv;
57
+ i++;
58
+ } else if (arg === "--debug") {
59
+ args.debug = true;
60
+ } else if (arg === "--replays-on-error") {
61
+ args.replaysOnError = next || "0";
62
+ i++;
63
+ } else if (arg === "--replays-session") {
64
+ args.replaysSession = next || "0";
65
+ i++;
66
+ } else if (arg === "--skip-install") {
67
+ args.skipInstall = true;
68
+ }
69
+ }
70
+
71
+ return args;
72
+ }
73
+
74
+ function log(message) {
75
+ console.log(`WatchForge: ${message}`);
76
+ }
77
+
78
+ function fail(message) {
79
+ console.error(`WatchForge setup failed: ${message}`);
80
+ process.exit(1);
81
+ }
82
+
83
+ function fileExists(filePath) {
84
+ return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
85
+ }
86
+
87
+ function findNextLayout(cwd) {
88
+ const candidates = [
89
+ path.join(cwd, "src", "app", "layout.tsx"),
90
+ path.join(cwd, "src", "app", "layout.jsx"),
91
+ path.join(cwd, "app", "layout.tsx"),
92
+ path.join(cwd, "app", "layout.jsx"),
93
+ ];
94
+ return candidates.find(fileExists) || null;
95
+ }
96
+
97
+ function findPagesApp(cwd) {
98
+ const candidates = [
99
+ path.join(cwd, "src", "pages", "_app.tsx"),
100
+ path.join(cwd, "src", "pages", "_app.jsx"),
101
+ path.join(cwd, "pages", "_app.tsx"),
102
+ path.join(cwd, "pages", "_app.jsx"),
103
+ ];
104
+ return candidates.find(fileExists) || null;
105
+ }
106
+
107
+ function writeIfChanged(filePath, content) {
108
+ if (fileExists(filePath) && fs.readFileSync(filePath, "utf8") === content) {
109
+ return false;
110
+ }
111
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
112
+ fs.writeFileSync(filePath, content);
113
+ return true;
114
+ }
115
+
116
+ function createConfig(cwd, args) {
117
+ const configPath = path.join(cwd, "watchforge.config.ts");
118
+ const content = `export const watchforgeConfig = {
119
+ dsn: ${JSON.stringify(args.dsn)},
120
+ app_env: ${JSON.stringify(args.appEnv)},
121
+ debug: ${args.debug ? "true" : "false"},
122
+ replaysOnErrorSampleRate: ${Number(args.replaysOnError)},
123
+ replaysSessionSampleRate: ${Number(args.replaysSession)},
124
+ maskAllInputs: true,
125
+ };
126
+ `;
127
+ writeIfChanged(configPath, content);
128
+ log(`wrote ${path.relative(cwd, configPath)}`);
129
+ }
130
+
131
+ function installPackage(cwd, skipInstall) {
132
+ if (skipInstall) {
133
+ log("skipped npm install");
134
+ return;
135
+ }
136
+
137
+ const hasPnpm = fileExists(path.join(cwd, "pnpm-lock.yaml"));
138
+ const hasYarn = fileExists(path.join(cwd, "yarn.lock"));
139
+ const hasBun = fileExists(path.join(cwd, "bun.lockb"));
140
+
141
+ const command = hasPnpm
142
+ ? "pnpm add @watchforge/browser"
143
+ : hasYarn
144
+ ? "yarn add @watchforge/browser"
145
+ : hasBun
146
+ ? "bun add @watchforge/browser"
147
+ : "npm install @watchforge/browser";
148
+
149
+ log(`installing package: ${command}`);
150
+ execSync(command, { cwd, stdio: "inherit" });
151
+ }
152
+
153
+ function patchAppRouter(cwd, layoutPath) {
154
+ const appDir = path.dirname(layoutPath);
155
+ const isSrcApp = appDir.endsWith(path.join("src", "app"));
156
+ const configImport = isSrcApp ? "../../watchforge.config" : "../watchforge.config";
157
+ const initPath = path.join(appDir, "watchforge-init.tsx");
158
+
159
+ const initContent = `"use client";
160
+
161
+ import { useEffect } from "react";
162
+ import { register } from "@watchforge/browser";
163
+ import { watchforgeConfig } from "${configImport}";
164
+
165
+ export default function WatchForgeInit() {
166
+ useEffect(() => {
167
+ register(watchforgeConfig);
168
+ }, []);
169
+
170
+ return null;
171
+ }
172
+ `;
173
+
174
+ writeIfChanged(initPath, initContent);
175
+ log(`wrote ${path.relative(cwd, initPath)}`);
176
+
177
+ let layout = fs.readFileSync(layoutPath, "utf8");
178
+ if (!layout.includes("WatchForgeInit")) {
179
+ layout = `import WatchForgeInit from "./watchforge-init";\n${layout}`;
180
+ }
181
+
182
+ if (!layout.includes("<WatchForgeInit />")) {
183
+ layout = layout.replace(/<body([^>]*)>/, "<body$1>\n <WatchForgeInit />\n ");
184
+ }
185
+
186
+ fs.writeFileSync(layoutPath, layout);
187
+ log(`patched ${path.relative(cwd, layoutPath)}`);
188
+ }
189
+
190
+ function patchPagesRouter(cwd, appPath) {
191
+ const pagesDir = path.dirname(appPath);
192
+ const isSrcPages = pagesDir.endsWith(path.join("src", "pages"));
193
+ const configImport = isSrcPages ? "../../watchforge.config" : "../watchforge.config";
194
+ const content = fs.readFileSync(appPath, "utf8");
195
+
196
+ if (content.includes("register(watchforgeConfig)")) {
197
+ log(`${path.relative(cwd, appPath)} already contains WatchForge setup`);
198
+ return;
199
+ }
200
+
201
+ const patched = `import { useEffect } from "react";
202
+ import { register } from "@watchforge/browser";
203
+ import { watchforgeConfig } from "${configImport}";
204
+
205
+ ${content.replace(
206
+ /function\s+App\s*\(([^)]*)\)\s*{/,
207
+ "function App($1) {\n useEffect(() => {\n register(watchforgeConfig);\n }, []);"
208
+ )}`;
209
+
210
+ fs.writeFileSync(appPath, patched);
211
+ log(`patched ${path.relative(cwd, appPath)}`);
212
+ }
213
+
214
+ function initNextjs(args) {
215
+ const cwd = process.cwd();
216
+ if (!fileExists(path.join(cwd, "package.json"))) {
217
+ fail("run this command from the root of your Next.js project");
218
+ }
219
+
220
+ createConfig(cwd, args);
221
+ installPackage(cwd, args.skipInstall);
222
+
223
+ const layoutPath = findNextLayout(cwd);
224
+ if (layoutPath) {
225
+ patchAppRouter(cwd, layoutPath);
226
+ return;
227
+ }
228
+
229
+ const appPath = findPagesApp(cwd);
230
+ if (appPath) {
231
+ patchPagesRouter(cwd, appPath);
232
+ return;
233
+ }
234
+
235
+ fail("could not find app/layout.tsx or pages/_app.tsx");
236
+ }
237
+
238
+ const args = parseArgs(process.argv.slice(2));
239
+
240
+ if (args.help) {
241
+ console.log(HELP);
242
+ process.exit(0);
243
+ }
244
+
245
+ if (!args.integration) {
246
+ fail("missing integration. Use: -i nextjs");
247
+ }
248
+
249
+ if (args.integration !== "nextjs") {
250
+ fail(`unsupported integration "${args.integration}". Currently supported: nextjs`);
251
+ }
252
+
253
+ if (!args.dsn) {
254
+ fail("missing --dsn");
255
+ }
256
+
257
+ initNextjs(args);
258
+ log("setup complete");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@watchforge/browser",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "main": "./src/index.js",
5
5
  "description": "WatchForge JavaScript SDK for Node.js, Express.js, and React",
6
6
  "license": "MIT",
@@ -22,12 +22,19 @@
22
22
  "./express": "./src/express.js",
23
23
  "./react": "./src/react.js"
24
24
  },
25
+ "bin": {
26
+ "watchforge": "bin/watchforge.js"
27
+ },
25
28
  "files": [
29
+ "bin/**/*.js",
26
30
  "src/**/*.js",
27
31
  "README.md",
28
32
  "CONFIGURATION_GUIDE.md",
29
33
  "LICENSE"
30
34
  ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
31
38
  "type": "module",
32
39
  "peerDependencies": {
33
40
  "react": ">=16.8.0"
@@ -43,7 +50,8 @@
43
50
  "url": false
44
51
  },
45
52
  "dependencies": {
46
- "express": "^5.2.1"
53
+ "express": "^5.2.1",
54
+ "rrweb": "^2.0.1"
47
55
  },
48
56
  "devDependencies": {
49
57
  "rollup": "^4.60.0"
package/src/client.js CHANGED
@@ -9,6 +9,11 @@ import {
9
9
  getPerformanceContext,
10
10
  getNodeServerContext,
11
11
  } from "./contexts.js";
12
+ import {
13
+ flushReplayForEvent,
14
+ getReplayContext,
15
+ initReplay,
16
+ } from "./replay.js";
12
17
 
13
18
  let DSN = null;
14
19
  let APP_ENV = "production";
@@ -378,6 +383,12 @@ export function register({
378
383
  app_env = "production",
379
384
  release = null,
380
385
  debug = false,
386
+ replaysSessionSampleRate = 0,
387
+ replaysOnErrorSampleRate = 0,
388
+ maskAllInputs = true,
389
+ blockClass = "rr-block",
390
+ ignoreClass = "rr-ignore",
391
+ maskTextClass = "rr-mask",
381
392
  }) {
382
393
  DSN = dsn;
383
394
  APP_ENV = app_env;
@@ -409,6 +420,15 @@ export function register({
409
420
  // Browser: Set up full instrumentation (errors, breadcrumbs, HTTP, navigation)
410
421
  if (isBrowser) {
411
422
  setupBrowserInstrumentation();
423
+ initReplay({
424
+ replaysSessionSampleRate,
425
+ replaysOnErrorSampleRate,
426
+ maskAllInputs,
427
+ blockClass,
428
+ ignoreClass,
429
+ maskTextClass,
430
+ debug,
431
+ });
412
432
  }
413
433
 
414
434
  // Node.js: Set up process error handlers
@@ -448,6 +468,12 @@ export async function captureException(error, context = {}) {
448
468
  sdk: getSdkMetadata(),
449
469
  };
450
470
 
471
+ const replay = flushReplayForEvent(DSN, event.event_id);
472
+ if (replay) {
473
+ event.replay_id = replay.replay_id;
474
+ event.session_id = replay.session_id;
475
+ }
476
+
451
477
  if (RELEASE) {
452
478
  event.release = RELEASE;
453
479
  }
@@ -487,6 +513,10 @@ export async function captureException(error, context = {}) {
487
513
  }
488
514
 
489
515
  event.contexts = await buildEventContexts(context);
516
+ const replayContext = getReplayContext();
517
+ if (replayContext) {
518
+ event.contexts.replay = replayContext;
519
+ }
490
520
 
491
521
  const bcs = getBreadcrumbsSnapshot();
492
522
  if (bcs.length > 0) {
package/src/replay.js ADDED
@@ -0,0 +1,128 @@
1
+ import { sendReplay } from "./transport.js";
2
+
3
+ const isBrowser = typeof window !== "undefined";
4
+ const MAX_BUFFER_MS = 60 * 1000;
5
+ const MAX_EVENTS = 500;
6
+
7
+ let options = {
8
+ replaysSessionSampleRate: 0,
9
+ replaysOnErrorSampleRate: 0,
10
+ maskAllInputs: true,
11
+ blockClass: "rr-block",
12
+ ignoreClass: "rr-ignore",
13
+ maskTextClass: "rr-mask",
14
+ };
15
+ let stopRecording = null;
16
+ let events = [];
17
+ let replayId = null;
18
+ let sessionId = null;
19
+ let startedAt = null;
20
+ let sessionSampled = false;
21
+
22
+ function uuid() {
23
+ if (isBrowser && crypto?.randomUUID) return crypto.randomUUID();
24
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
25
+ const r = (Math.random() * 16) | 0;
26
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
27
+ return v.toString(16);
28
+ });
29
+ }
30
+
31
+ function shouldSample(rate) {
32
+ const n = Number(rate || 0);
33
+ if (n <= 0) return false;
34
+ if (n >= 1) return true;
35
+ return Math.random() < n;
36
+ }
37
+
38
+ function trimBuffer(now = Date.now()) {
39
+ events = events.filter((event) => now - event.timestamp <= MAX_BUFFER_MS);
40
+ if (events.length > MAX_EVENTS) {
41
+ events = events.slice(-MAX_EVENTS);
42
+ }
43
+ }
44
+
45
+ export async function initReplay(config = {}) {
46
+ if (!isBrowser) return;
47
+
48
+ options = {
49
+ ...options,
50
+ ...config,
51
+ };
52
+
53
+ sessionSampled = shouldSample(options.replaysSessionSampleRate);
54
+ const shouldRecord = sessionSampled || Number(options.replaysOnErrorSampleRate || 0) > 0;
55
+
56
+ if (!shouldRecord || stopRecording) return;
57
+
58
+ replayId = replayId || uuid();
59
+ sessionId = sessionId || uuid();
60
+ startedAt = new Date().toISOString();
61
+
62
+ try {
63
+ const { record } = await import("rrweb");
64
+ stopRecording = record({
65
+ emit(event) {
66
+ events.push(event);
67
+ trimBuffer(event.timestamp || Date.now());
68
+ },
69
+ maskAllInputs: options.maskAllInputs,
70
+ blockClass: options.blockClass,
71
+ ignoreClass: options.ignoreClass,
72
+ maskTextClass: options.maskTextClass,
73
+ maskInputOptions: {
74
+ password: true,
75
+ email: true,
76
+ tel: true,
77
+ text: Boolean(options.maskAllInputs),
78
+ },
79
+ });
80
+ } catch (error) {
81
+ if (options.debug) {
82
+ console.warn("WatchForge SDK: failed to start replay recording", error);
83
+ }
84
+ }
85
+ }
86
+
87
+ export function getReplayContext() {
88
+ if (!isBrowser || !replayId || !sessionId) return null;
89
+ return {
90
+ replay_id: replayId,
91
+ session_id: sessionId,
92
+ sampled: Boolean(stopRecording),
93
+ };
94
+ }
95
+
96
+ export function flushReplayForEvent(dsn, eventId) {
97
+ if (!isBrowser || !dsn || !replayId || !sessionId || events.length === 0) {
98
+ return null;
99
+ }
100
+
101
+ if (!sessionSampled && !shouldSample(options.replaysOnErrorSampleRate)) {
102
+ return null;
103
+ }
104
+
105
+ trimBuffer();
106
+
107
+ const payload = {
108
+ replay_id: replayId,
109
+ session_id: sessionId,
110
+ event_id: eventId,
111
+ started_at: startedAt,
112
+ finished_at: new Date().toISOString(),
113
+ url: window.location.href,
114
+ user_agent: navigator.userAgent,
115
+ events,
116
+ sdk: {
117
+ name: "@watchforge/browser",
118
+ version: "0.1.3",
119
+ },
120
+ };
121
+
122
+ sendReplay(dsn, payload);
123
+ return {
124
+ replay_id: replayId,
125
+ session_id: sessionId,
126
+ event_count: events.length,
127
+ };
128
+ }
package/src/stacktrace.js CHANGED
@@ -25,7 +25,7 @@ function parseStackLine(line) {
25
25
  const trimmed = line.trim();
26
26
  if (!trimmed.startsWith("at ")) return null;
27
27
 
28
- const withFn = trimmed.match(/^at\s+(.*?)\s+\((.*?):(\d+):(\d+)\)$/);
28
+ const withFn = trimmed.match(/^at\s+(?:async\s+)?(.*?)\s+\((.*?):(\d+):(\d+)\)$/);
29
29
  if (withFn) {
30
30
  return {
31
31
  function: withFn[1] || "<anonymous>",
@@ -35,7 +35,7 @@ function parseStackLine(line) {
35
35
  };
36
36
  }
37
37
 
38
- const withoutFn = trimmed.match(/^at\s+(.*?):(\d+):(\d+)$/);
38
+ const withoutFn = trimmed.match(/^at\s+(?:async\s+)?(.*?):(\d+):(\d+)$/);
39
39
  if (withoutFn) {
40
40
  return {
41
41
  function: "<anonymous>",
@@ -111,14 +111,16 @@ function applySourceContext(frame, sourceLines, lineno) {
111
111
  }
112
112
 
113
113
  let nodeModulesPromise = null;
114
+ const importNodeBuiltin = (specifier) =>
115
+ new Function("specifier", "return import(specifier)")(specifier);
114
116
 
115
117
  function getNodeModules() {
116
118
  if (!isNode) return Promise.resolve(null);
117
119
  if (!nodeModulesPromise) {
118
120
  nodeModulesPromise = (async () => {
119
121
  try {
120
- const { createRequire } = await import("module");
121
- const { fileURLToPath } = await import("url");
122
+ const { createRequire } = await importNodeBuiltin("module");
123
+ const { fileURLToPath } = await importNodeBuiltin("url");
122
124
  const req = createRequire(fileURLToPath(import.meta.url));
123
125
  return {
124
126
  fs: req("fs"),
package/src/transport.js CHANGED
@@ -2,9 +2,12 @@
2
2
  const isBrowser = typeof window !== 'undefined';
3
3
  const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
4
4
 
5
- // Lazy-load Node.js require function for ES modules
5
+ // Lazy-load Node.js require function for ES modules. Use Function-based import
6
+ // so browser bundlers do not try to resolve Node built-ins like "module".
6
7
  let nodeRequire = null;
7
8
  let nodeRequirePromise = null;
9
+ const importNodeBuiltin = (specifier) =>
10
+ new Function("specifier", "return import(specifier)")(specifier);
8
11
 
9
12
  function getNodeRequire() {
10
13
  if (!isNode) return Promise.resolve(null);
@@ -14,8 +17,8 @@ function getNodeRequire() {
14
17
  if (!nodeRequirePromise) {
15
18
  nodeRequirePromise = (async () => {
16
19
  try {
17
- const { createRequire } = await import('module');
18
- const { fileURLToPath } = await import('url');
20
+ const { createRequire } = await importNodeBuiltin('module');
21
+ const { fileURLToPath } = await importNodeBuiltin('url');
19
22
  nodeRequire = createRequire(fileURLToPath(import.meta.url));
20
23
  return nodeRequire;
21
24
  } catch (e) {
@@ -47,6 +50,7 @@ function parseDsn(dsn) {
47
50
  host,
48
51
  projectId,
49
52
  apiUrl: `${finalScheme}://${host}/api/ingestion/events/`,
53
+ replayUrl: `${finalScheme}://${host}/api/ingestion/replays/`,
50
54
  };
51
55
  }
52
56
  } catch (e) {
@@ -79,6 +83,20 @@ function getApiUrl(dsn) {
79
83
  return isBrowser ? "/api/ingestion/events/" : "http://127.0.0.1:8001/api/ingestion/events/";
80
84
  }
81
85
 
86
+ function getReplayUrl(dsn) {
87
+ const apiUrl = getApiUrl(dsn);
88
+ if (apiUrl.endsWith("/events/")) {
89
+ return apiUrl.replace(/\/events\/$/, "/replays/");
90
+ }
91
+
92
+ const parsed = parseDsn(dsn);
93
+ if (parsed) {
94
+ return parsed.replayUrl;
95
+ }
96
+
97
+ return isBrowser ? "/api/ingestion/replays/" : "http://127.0.0.1:8001/api/ingestion/replays/";
98
+ }
99
+
82
100
  export function sendEvent(dsn, payload) {
83
101
  const apiUrl = getApiUrl(dsn);
84
102
 
@@ -189,3 +207,27 @@ export function sendEvent(dsn, payload) {
189
207
  });
190
208
  }
191
209
  }
210
+
211
+ export function sendReplay(dsn, payload) {
212
+ const apiUrl = getReplayUrl(dsn);
213
+
214
+ if (isBrowser) {
215
+ fetch(apiUrl, {
216
+ method: "POST",
217
+ headers: {
218
+ Authorization: `DSN ${dsn}`,
219
+ "Content-Type": "application/json",
220
+ },
221
+ body: JSON.stringify(payload),
222
+ keepalive: false,
223
+ }).catch((error) => {
224
+ console.error("WatchForge SDK - Failed to send replay:", error);
225
+ });
226
+ return;
227
+ }
228
+
229
+ if (isNode) {
230
+ // Replays are browser-only, but keep this no-op explicit for framework code paths.
231
+ return;
232
+ }
233
+ }