@watchforge/browser 0.1.1 → 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.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Stack trace parsing and source context enrichment (Python SDK parity).
3
+ */
4
+
5
+ const CONTEXT_LINES = 5;
6
+ const MAX_SOURCE_FILE_BYTES = 512 * 1024;
7
+
8
+ const isBrowser = typeof window !== "undefined";
9
+ const isNode =
10
+ typeof process !== "undefined" && process.versions && process.versions.node;
11
+
12
+ const LIBRARY_PATH_PATTERNS = [
13
+ "/node_modules/",
14
+ "node_modules\\",
15
+ "/webpack/",
16
+ "webpack-internal://",
17
+ ];
18
+
19
+ function isLibraryPath(filePath) {
20
+ if (!filePath) return false;
21
+ return LIBRARY_PATH_PATTERNS.some((p) => filePath.includes(p));
22
+ }
23
+
24
+ function parseStackLine(line) {
25
+ const trimmed = line.trim();
26
+ if (!trimmed.startsWith("at ")) return null;
27
+
28
+ const withFn = trimmed.match(/^at\s+(?:async\s+)?(.*?)\s+\((.*?):(\d+):(\d+)\)$/);
29
+ if (withFn) {
30
+ return {
31
+ function: withFn[1] || "<anonymous>",
32
+ file: withFn[2],
33
+ lineno: parseInt(withFn[3], 10),
34
+ colno: parseInt(withFn[4], 10),
35
+ };
36
+ }
37
+
38
+ const withoutFn = trimmed.match(/^at\s+(?:async\s+)?(.*?):(\d+):(\d+)$/);
39
+ if (withoutFn) {
40
+ return {
41
+ function: "<anonymous>",
42
+ file: withoutFn[1],
43
+ lineno: parseInt(withoutFn[2], 10),
44
+ colno: parseInt(withoutFn[3], 10),
45
+ };
46
+ }
47
+
48
+ const evalMatch = trimmed.match(/^at\s+(.*?)\s+\((.*?)\)$/);
49
+ if (evalMatch) {
50
+ const inner = parseStackLine(`at ${evalMatch[2]}`);
51
+ if (inner) {
52
+ return { ...inner, function: evalMatch[1] || inner.function };
53
+ }
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ export function buildStacktraceFromError(error) {
60
+ if (!error || !error.stack || typeof error.stack !== "string") {
61
+ return null;
62
+ }
63
+
64
+ const stackLines = error.stack.split("\n");
65
+ const frames = [];
66
+
67
+ for (let i = 1; i < stackLines.length; i++) {
68
+ const parsed = parseStackLine(stackLines[i]);
69
+ if (!parsed) continue;
70
+
71
+ const { function: functionName, file, lineno, colno } = parsed;
72
+ const filename = file.split("/").pop()?.split("?")[0] || file;
73
+ const inApp = !isLibraryPath(file);
74
+
75
+ frames.push({
76
+ filename,
77
+ abs_path: file,
78
+ lineno,
79
+ colno,
80
+ function: functionName,
81
+ module: null,
82
+ context_line: null,
83
+ pre_context: [],
84
+ post_context: [],
85
+ vars: {},
86
+ in_app: inApp,
87
+ });
88
+ }
89
+
90
+ if (!frames.length) return null;
91
+
92
+ frames.reverse();
93
+ return { frames };
94
+ }
95
+
96
+ function applySourceContext(frame, sourceLines, lineno) {
97
+ if (!sourceLines?.length || !lineno || lineno < 1) return;
98
+
99
+ const idx = lineno - 1;
100
+ if (idx >= sourceLines.length) return;
101
+
102
+ frame.context_line = sourceLines[idx].replace(/\r$/, "");
103
+ const preStart = Math.max(0, idx - CONTEXT_LINES);
104
+ frame.pre_context = sourceLines
105
+ .slice(preStart, idx)
106
+ .map((l) => l.replace(/\r$/, ""));
107
+ const postEnd = Math.min(sourceLines.length, idx + CONTEXT_LINES + 1);
108
+ frame.post_context = sourceLines
109
+ .slice(idx + 1, postEnd)
110
+ .map((l) => l.replace(/\r$/, ""));
111
+ }
112
+
113
+ let nodeModulesPromise = null;
114
+ const importNodeBuiltin = (specifier) =>
115
+ new Function("specifier", "return import(specifier)")(specifier);
116
+
117
+ function getNodeModules() {
118
+ if (!isNode) return Promise.resolve(null);
119
+ if (!nodeModulesPromise) {
120
+ nodeModulesPromise = (async () => {
121
+ try {
122
+ const { createRequire } = await importNodeBuiltin("module");
123
+ const { fileURLToPath } = await importNodeBuiltin("url");
124
+ const req = createRequire(fileURLToPath(import.meta.url));
125
+ return {
126
+ fs: req("fs"),
127
+ path: req("path"),
128
+ fileURLToPath,
129
+ };
130
+ } catch {
131
+ return null;
132
+ }
133
+ })();
134
+ }
135
+ return nodeModulesPromise;
136
+ }
137
+
138
+ async function readNodeSourceLines(absPath) {
139
+ if (!isNode || !absPath) return null;
140
+
141
+ try {
142
+ if (absPath.startsWith("http://") || absPath.startsWith("https://")) {
143
+ return null;
144
+ }
145
+
146
+ const mods = await getNodeModules();
147
+ if (!mods) return null;
148
+
149
+ const { fs, path, fileURLToPath } = mods;
150
+ let filePath = absPath;
151
+
152
+ if (filePath.startsWith("file://")) {
153
+ filePath = fileURLToPath(filePath);
154
+ }
155
+
156
+ if (!path.isAbsolute(filePath)) {
157
+ filePath = path.resolve(process.cwd(), filePath);
158
+ }
159
+
160
+ if (!fs.existsSync(filePath)) return null;
161
+
162
+ const stat = fs.statSync(filePath);
163
+ if (stat.size > MAX_SOURCE_FILE_BYTES) return null;
164
+
165
+ const content = fs.readFileSync(filePath, "utf8");
166
+ return content.split("\n");
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ async function enrichFrameWithNodeSource(frame) {
173
+ if (!frame.in_app || !frame.lineno) return;
174
+ const lines = await readNodeSourceLines(frame.abs_path);
175
+ if (lines) applySourceContext(frame, lines, frame.lineno);
176
+ }
177
+
178
+ async function fetchBrowserSourceLines(absPath) {
179
+ if (!isBrowser || !absPath) return null;
180
+
181
+ try {
182
+ let url = absPath;
183
+ if (url.startsWith("webpack-internal://")) {
184
+ url = url.replace("webpack-internal:///", "/").replace("webpack-internal://", "/");
185
+ }
186
+
187
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
188
+ if (url.startsWith("/")) {
189
+ url = `${window.location.origin}${url}`;
190
+ } else {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ const target = new URL(url, window.location.href);
196
+ if (target.origin !== window.location.origin) return null;
197
+
198
+ const response = await fetch(target.href, { credentials: "same-origin" });
199
+ if (!response.ok) return null;
200
+
201
+ const contentType = response.headers.get("content-type") || "";
202
+ if (!contentType.includes("javascript") && !contentType.includes("text")) {
203
+ return null;
204
+ }
205
+
206
+ const text = await response.text();
207
+ if (text.length > MAX_SOURCE_FILE_BYTES) return null;
208
+ return text.split("\n");
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ async function enrichFrameWithBrowserSource(frame) {
215
+ if (!frame.in_app || !frame.lineno) return;
216
+ const lines = await fetchBrowserSourceLines(frame.abs_path);
217
+ if (lines) applySourceContext(frame, lines, frame.lineno);
218
+ }
219
+
220
+ export async function enrichStacktraceAsync(stacktrace) {
221
+ if (!stacktrace?.frames?.length) return stacktrace;
222
+
223
+ const inAppFrames = stacktrace.frames.filter((f) => f.in_app);
224
+ const targets = inAppFrames.slice(-5);
225
+
226
+ if (isNode) {
227
+ await Promise.all(targets.map((frame) => enrichFrameWithNodeSource(frame)));
228
+ } else if (isBrowser) {
229
+ await Promise.all(targets.map((frame) => enrichFrameWithBrowserSource(frame)));
230
+ }
231
+
232
+ return stacktrace;
233
+ }
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
+ }