@watchforge/browser 0.1.4 → 0.1.6

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.
@@ -2,10 +2,51 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- The WatchForge JavaScript SDK automatically captures errors and events in:
5
+ The WatchForge JavaScript SDK is one npm package with typed framework entry points for:
6
+
7
+ - **Browser JavaScript** applications
8
+ - **Next.js** App Router applications
9
+ - **React** frontend applications
6
10
  - **Node.js** applications
7
11
  - **Express.js** web servers
8
- - **React** frontend applications
12
+
13
+ This package intentionally stays unified instead of splitting into separate `watchforge-next-sdk`, `watchforge-node-sdk`, `watchforge-react-sdk`, and `watchforge-express-sdk` packages. The shared package keeps DSN handling, event transport, breadcrumbs, stack traces, source context, and session replay consistent across JavaScript runtimes.
14
+
15
+ ## Supported Imports
16
+
17
+ ```ts
18
+ // Browser JavaScript, React client, and shared APIs
19
+ import { register, captureException, captureMessage } from "@watchforge/browser";
20
+
21
+ // Next.js App Router client provider
22
+ import { WatchForgeProvider } from "@watchforge/browser/next";
23
+
24
+ // Next.js server route handlers
25
+ import { withWatchForgeRouteHandler } from "@watchforge/browser/next/server";
26
+
27
+ // React error boundary
28
+ import { ErrorBoundary } from "@watchforge/browser/react";
29
+
30
+ // Express middleware
31
+ import {
32
+ expressRequestMiddleware,
33
+ expressMiddleware,
34
+ } from "@watchforge/browser/express";
35
+
36
+ // Explicit Node.js import
37
+ import { register as registerNode } from "@watchforge/browser/node";
38
+ ```
39
+
40
+ ## Support Matrix
41
+
42
+ | Runtime / Framework | Entry point | Recommended setup |
43
+ | --- | --- | --- |
44
+ | Browser JavaScript | `@watchforge/browser` | Call `register()` once in the main browser bundle |
45
+ | React | `@watchforge/browser` + `@watchforge/browser/react` | Call `register()` once and wrap the app with `ErrorBoundary` |
46
+ | Next.js App Router client | `@watchforge/browser/next` | Use the wizard or render `WatchForgeProvider` from a client component |
47
+ | Next.js App Router server | `@watchforge/browser/next/server` | Wrap route handlers with `withWatchForgeRouteHandler()` |
48
+ | Node.js | `@watchforge/browser/node` | Call `register()` once during process startup |
49
+ | Express.js | `@watchforge/browser/express` | Add request middleware before routes and error middleware after routes |
9
50
 
10
51
  ## Installation
11
52
 
@@ -64,8 +105,9 @@ npm install ../watchforge-javascript-sdk
64
105
  ```
65
106
 
66
107
  After installation, use it the same way:
67
- ```javascript
68
- import { register, ErrorBoundary } from '@watchforge/browser';
108
+ ```ts
109
+ import { register } from "@watchforge/browser";
110
+ import { ErrorBoundary } from "@watchforge/browser/react";
69
111
  ```
70
112
 
71
113
  ### Next.js Wizard Setup
@@ -89,7 +131,7 @@ The wizard:
89
131
 
90
132
  1. Installs `@watchforge/browser`
91
133
  2. Creates `watchforge.config.ts`
92
- 3. Creates `app/watchforge-init.tsx` (or `src/app/watchforge-init.tsx`)
134
+ 3. Creates `app/watchforge-init.tsx` (or `src/app/.../watchforge-init.tsx`)
93
135
  4. Patches `app/layout.tsx` to render `<WatchForgeInit />`
94
136
 
95
137
  If you only want file generation and no package install:
@@ -188,11 +230,92 @@ See the React setup section below for complete examples.
188
230
 
189
231
  ## Quick Start
190
232
 
233
+ ### Browser JavaScript
234
+
235
+ ```ts
236
+ import { register } from "@watchforge/browser";
237
+
238
+ register({
239
+ dsn: "https://PUBLIC_KEY@watchforge.io/PROJECT_ID",
240
+ app_env: "production",
241
+ });
242
+ ```
243
+
244
+ This captures uncaught browser errors, unhandled promise rejections, breadcrumbs, page context, browser/device/OS details, and performance context.
245
+
246
+ ### Next.js App Router
247
+
248
+ Create `watchforge.config.ts` in your project root:
249
+
250
+ ```ts
251
+ export const watchforgeConfig = {
252
+ dsn: "https://PUBLIC_KEY@watchforge.io/PROJECT_ID",
253
+ app_env: "production",
254
+ replaysOnErrorSampleRate: 1,
255
+ maskAllInputs: true,
256
+ };
257
+ ```
258
+
259
+ Create a client init component beside your frontend layout, for example `src/app/watchforge-init.tsx` or `src/app/(frontend)/watchforge-init.tsx`:
260
+
261
+ ```tsx
262
+ "use client";
263
+
264
+ import { WatchForgeProvider } from "@watchforge/browser/next";
265
+ import { watchforgeConfig } from "../watchforge.config";
266
+
267
+ export default function WatchForgeInit() {
268
+ return <WatchForgeProvider options={watchforgeConfig} />;
269
+ }
270
+ ```
271
+
272
+ Render it once in the matching `layout.tsx`:
273
+
274
+ ```tsx
275
+ import WatchForgeInit from "./watchforge-init";
276
+
277
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
278
+ return (
279
+ <html lang="en">
280
+ <body>
281
+ <WatchForgeInit />
282
+ {children}
283
+ </body>
284
+ </html>
285
+ );
286
+ }
287
+ ```
288
+
289
+ For route-group projects like `src/app/(frontend)/layout.tsx`, put `watchforge-init.tsx` in that same route group and adjust the config import path if needed.
290
+
291
+ For Next.js route handlers, use the server entry point:
292
+
293
+ ```ts
294
+ // app/api/example/route.ts
295
+ import {
296
+ register,
297
+ withWatchForgeRouteHandler,
298
+ } from "@watchforge/browser/next/server";
299
+
300
+ register({
301
+ dsn: "https://PUBLIC_KEY@watchforge.io/PROJECT_ID",
302
+ app_env: "production",
303
+ });
304
+
305
+ export const GET = withWatchForgeRouteHandler(async () => {
306
+ throw new Error("Next.js API route test error");
307
+ });
308
+ ```
309
+
191
310
  ### 1. Node.js Application
192
311
 
193
- ```javascript
312
+ ```ts
194
313
  // app.js
195
- const { register, captureException, captureMessage } = require('@watchforge/browser');
314
+ import {
315
+ register,
316
+ captureException,
317
+ captureMessage,
318
+ } from "@watchforge/browser/node";
196
319
 
197
320
  // Initialize SDK
198
321
  // API URL is automatically derived from DSN host
@@ -214,10 +337,14 @@ captureMessage("User logged in", "info");
214
337
 
215
338
  ### 2. Express.js Application
216
339
 
217
- ```javascript
340
+ ```ts
218
341
  // server.js
219
- const express = require('express');
220
- const { register, expressMiddleware } = require('@watchforge/browser');
342
+ import express from "express";
343
+ import { register } from "@watchforge/browser/node";
344
+ import {
345
+ expressRequestMiddleware,
346
+ expressMiddleware,
347
+ } from "@watchforge/browser/express";
221
348
 
222
349
  const app = express();
223
350
 
@@ -755,6 +882,7 @@ register({
755
882
 
756
883
  app.use(express.json());
757
884
  app.use(expressRequestMiddleware());
885
+ app.use(expressRequestMiddleware());
758
886
 
759
887
  // routes here
760
888
 
package/README.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # WatchForge JavaScript SDK (`@watchforge/browser`)
2
2
 
3
- Browser and Node SDK for WatchForge. **One call to `register()`** turns on automatic error reporting—no manual `try/catch` required for typical crashes.
3
+ Browser, Next.js, React, Node.js, and Express SDK for WatchForge. **One call to `register()`** turns on automatic error reporting for typical crashes, and framework entry points add richer context where needed.
4
+
5
+ ## Runtime Support
6
+
7
+ | Runtime / Framework | Import | What it captures |
8
+ | --- | --- | --- |
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 |
12
+ | Node.js | `@watchforge/browser` or `@watchforge/browser/node` | `uncaughtException`, `unhandledRejection`, Node runtime/server context |
13
+ | Express.js | `@watchforge/browser/express` | Express request errors, request URL/method/headers/body/query, user/IP, route, duration |
14
+
15
+ The package is intentionally shipped as **one npm package** with typed subpath exports instead of separate SDK packages. This keeps DSN setup, replay, stack traces, breadcrumbs, and event transport consistent across JavaScript runtimes.
4
16
 
5
17
  ## What this package does *not* require
6
18
 
@@ -37,6 +49,131 @@ npx @watchforge/browser -i nextjs \
37
49
 
38
50
  The wizard installs `@watchforge/browser`, writes `watchforge.config.ts`, creates a client init component, and patches `app/layout.tsx` or `pages/_app.tsx`.
39
51
 
52
+ ## Framework Imports
53
+
54
+ WatchForge ships as one JavaScript SDK package with typed framework entry points:
55
+
56
+ ```ts
57
+ // Browser / React client / Node global handlers
58
+ import { register } from "@watchforge/browser";
59
+
60
+ // Next.js App Router client provider
61
+ import { WatchForgeProvider } from "@watchforge/browser/next";
62
+
63
+ // Next.js App Router server route helper
64
+ import { withWatchForgeRouteHandler } from "@watchforge/browser/next/server";
65
+
66
+ // React error boundary
67
+ import { ErrorBoundary } from "@watchforge/browser/react";
68
+
69
+ // Express middleware
70
+ import {
71
+ expressRequestMiddleware,
72
+ expressMiddleware,
73
+ } from "@watchforge/browser/express";
74
+
75
+ // Optional explicit Node import
76
+ import { captureException } from "@watchforge/browser/node";
77
+ ```
78
+
79
+ ## Next.js Manual Setup
80
+
81
+ Use the wizard when possible. For manual setup in App Router projects, create a client init component for browser-side errors:
82
+
83
+ ```tsx
84
+ "use client";
85
+
86
+ import { WatchForgeProvider } from "@watchforge/browser/next";
87
+ import { watchforgeConfig } from "../watchforge.config";
88
+
89
+ export default function WatchForgeInit() {
90
+ return <WatchForgeProvider options={watchforgeConfig} />;
91
+ }
92
+ ```
93
+
94
+ Then render it once in your frontend `layout.tsx`:
95
+
96
+ ```tsx
97
+ import WatchForgeInit from "./watchforge-init";
98
+
99
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
100
+ return (
101
+ <html lang="en">
102
+ <body>
103
+ <WatchForgeInit />
104
+ {children}
105
+ </body>
106
+ </html>
107
+ );
108
+ }
109
+ ```
110
+
111
+ For route groups, place the init component beside the frontend layout, for example `src/app/(frontend)/watchforge-init.tsx`, and import `watchforge.config.ts` using the correct relative path.
112
+
113
+ For server route handlers, initialize once in server code and wrap handlers:
114
+
115
+ ```ts
116
+ import {
117
+ register,
118
+ withWatchForgeRouteHandler,
119
+ } from "@watchforge/browser/next/server";
120
+
121
+ register({
122
+ dsn: "https://PUBLIC_KEY@your-host/PROJECT_ID",
123
+ app_env: "production",
124
+ });
125
+
126
+ export const GET = withWatchForgeRouteHandler(async () => {
127
+ throw new Error("Next.js route handler test error");
128
+ });
129
+ ```
130
+
131
+ ## Express Setup
132
+
133
+ ```ts
134
+ import express from "express";
135
+ import { register } from "@watchforge/browser/node";
136
+ import {
137
+ expressRequestMiddleware,
138
+ expressMiddleware,
139
+ } from "@watchforge/browser/express";
140
+
141
+ register({
142
+ dsn: "https://PUBLIC_KEY@your-host/PROJECT_ID",
143
+ app_env: "production",
144
+ });
145
+
146
+ const app = express();
147
+ app.use(express.json());
148
+ app.use(expressRequestMiddleware());
149
+
150
+ app.get("/error", () => {
151
+ throw new Error("Express test error");
152
+ });
153
+
154
+ app.use(expressMiddleware());
155
+ ```
156
+
157
+ ## React Setup
158
+
159
+ ```tsx
160
+ import { register } from "@watchforge/browser";
161
+ import { ErrorBoundary } from "@watchforge/browser/react";
162
+
163
+ register({
164
+ dsn: "https://PUBLIC_KEY@your-host/PROJECT_ID",
165
+ app_env: "production",
166
+ });
167
+
168
+ export function AppRoot() {
169
+ return (
170
+ <ErrorBoundary fallback={<div>Something went wrong.</div>}>
171
+ <App />
172
+ </ErrorBoundary>
173
+ );
174
+ }
175
+ ```
176
+
40
177
  ## Quick start (all most apps need)
41
178
 
42
179
  Call **`register()` once** as early as possible (e.g. app entry / main bundle), with your **DSN** from the WatchForge project settings.
package/bin/watchforge.js CHANGED
@@ -84,6 +84,15 @@ function fileExists(filePath) {
84
84
  return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
85
85
  }
86
86
 
87
+ function commandExists(command) {
88
+ try {
89
+ execSync(`command -v ${command}`, { stdio: "ignore" });
90
+ return true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
87
96
  function findNextLayout(cwd) {
88
97
  const candidates = [
89
98
  path.join(cwd, "src", "app", "layout.tsx"),
@@ -91,6 +100,24 @@ function findNextLayout(cwd) {
91
100
  path.join(cwd, "app", "layout.tsx"),
92
101
  path.join(cwd, "app", "layout.jsx"),
93
102
  ];
103
+
104
+ for (const appRoot of [path.join(cwd, "src", "app"), path.join(cwd, "app")]) {
105
+ if (!fs.existsSync(appRoot) || !fs.statSync(appRoot).isDirectory()) continue;
106
+ const entries = fs.readdirSync(appRoot, { withFileTypes: true }).sort((a, b) => {
107
+ const score = (name) => {
108
+ if (name.includes("frontend") || name.includes("site")) return -1;
109
+ if (name.includes("payload") || name.includes("admin")) return 1;
110
+ return 0;
111
+ };
112
+ return score(a.name) - score(b.name) || a.name.localeCompare(b.name);
113
+ });
114
+ for (const entry of entries) {
115
+ if (!entry.isDirectory()) continue;
116
+ candidates.push(path.join(appRoot, entry.name, "layout.tsx"));
117
+ candidates.push(path.join(appRoot, entry.name, "layout.jsx"));
118
+ }
119
+ }
120
+
94
121
  return candidates.find(fileExists) || null;
95
122
  }
96
123
 
@@ -138,11 +165,11 @@ function installPackage(cwd, skipInstall) {
138
165
  const hasYarn = fileExists(path.join(cwd, "yarn.lock"));
139
166
  const hasBun = fileExists(path.join(cwd, "bun.lockb"));
140
167
 
141
- const command = hasPnpm
168
+ const command = hasPnpm && commandExists("pnpm")
142
169
  ? "pnpm add @watchforge/browser"
143
- : hasYarn
170
+ : hasYarn && commandExists("yarn")
144
171
  ? "yarn add @watchforge/browser"
145
- : hasBun
172
+ : hasBun && commandExists("bun")
146
173
  ? "bun add @watchforge/browser"
147
174
  : "npm install @watchforge/browser";
148
175
 
@@ -152,22 +179,21 @@ function installPackage(cwd, skipInstall) {
152
179
 
153
180
  function patchAppRouter(cwd, layoutPath) {
154
181
  const appDir = path.dirname(layoutPath);
155
- const isSrcApp = appDir.endsWith(path.join("src", "app"));
156
- const configImport = isSrcApp ? "../../watchforge.config" : "../watchforge.config";
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
+ }
157
188
  const initPath = path.join(appDir, "watchforge-init.tsx");
158
189
 
159
190
  const initContent = `"use client";
160
191
 
161
- import { useEffect } from "react";
162
- import { register } from "@watchforge/browser";
192
+ import { WatchForgeProvider } from "@watchforge/browser/next";
163
193
  import { watchforgeConfig } from "${configImport}";
164
194
 
165
195
  export default function WatchForgeInit() {
166
- useEffect(() => {
167
- register(watchforgeConfig);
168
- }, []);
169
-
170
- return null;
196
+ return <WatchForgeProvider options={watchforgeConfig} />;
171
197
  }
172
198
  `;
173
199
 
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@watchforge/browser",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "main": "./src/index.js",
5
- "description": "WatchForge JavaScript SDK for Node.js, Express.js, and React",
5
+ "types": "./src/index.d.ts",
6
+ "description": "WatchForge JavaScript SDK for browser JavaScript, Next.js, React, Node.js, and Express.js",
6
7
  "license": "MIT",
7
8
  "keywords": [
8
9
  "watchforge",
@@ -15,12 +16,31 @@
15
16
  ],
16
17
  "exports": {
17
18
  ".": {
19
+ "types": "./src/index.d.ts",
18
20
  "browser": "./src/index.js",
19
21
  "node": "./src/index.js",
20
22
  "default": "./src/index.js"
21
23
  },
22
- "./express": "./src/express.js",
23
- "./react": "./src/react.js"
24
+ "./node": {
25
+ "types": "./src/index.d.ts",
26
+ "default": "./src/index.js"
27
+ },
28
+ "./next": {
29
+ "types": "./src/next.d.ts",
30
+ "default": "./src/next.js"
31
+ },
32
+ "./next/server": {
33
+ "types": "./src/next-server.d.ts",
34
+ "default": "./src/next-server.js"
35
+ },
36
+ "./express": {
37
+ "types": "./src/express.d.ts",
38
+ "default": "./src/express.js"
39
+ },
40
+ "./react": {
41
+ "types": "./src/react.d.ts",
42
+ "default": "./src/react.js"
43
+ }
24
44
  },
25
45
  "bin": {
26
46
  "watchforge": "bin/watchforge.js"
@@ -28,6 +48,7 @@
28
48
  "files": [
29
49
  "bin/**/*.js",
30
50
  "src/**/*.js",
51
+ "src/**/*.d.ts",
31
52
  "README.md",
32
53
  "CONFIGURATION_GUIDE.md",
33
54
  "LICENSE"
@@ -37,9 +58,13 @@
37
58
  },
38
59
  "type": "module",
39
60
  "peerDependencies": {
61
+ "express": ">=4.0.0",
40
62
  "react": ">=16.8.0"
41
63
  },
42
64
  "peerDependenciesMeta": {
65
+ "express": {
66
+ "optional": true
67
+ },
43
68
  "react": {
44
69
  "optional": true
45
70
  }
@@ -50,8 +75,8 @@
50
75
  "url": false
51
76
  },
52
77
  "dependencies": {
53
- "express": "^5.2.1",
54
- "rrweb": "^2.0.1"
78
+ "rrweb": "^2.0.1",
79
+ "source-map-js": "^1.2.1"
55
80
  },
56
81
  "devDependencies": {
57
82
  "rollup": "^4.60.0"
@@ -0,0 +1,12 @@
1
+ export function expressRequestMiddleware(): (
2
+ req: any,
3
+ res: any,
4
+ next: (error?: unknown) => void
5
+ ) => void;
6
+
7
+ export function expressMiddleware(): (
8
+ err: Error,
9
+ req: any,
10
+ res: any,
11
+ next: (error?: unknown) => void
12
+ ) => void;
package/src/index.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ export interface WatchForgeRegisterOptions {
2
+ dsn: string;
3
+ app_env?: string;
4
+ release?: string | null;
5
+ debug?: boolean;
6
+ replaysSessionSampleRate?: number;
7
+ replaysOnErrorSampleRate?: number;
8
+ maskAllInputs?: boolean;
9
+ blockClass?: string;
10
+ ignoreClass?: string;
11
+ maskTextClass?: string;
12
+ }
13
+
14
+ export interface WatchForgeCaptureContext {
15
+ user?: Record<string, unknown>;
16
+ request?: Record<string, unknown>;
17
+ tags?: Record<string, unknown>;
18
+ extra?: Record<string, unknown>;
19
+ contexts?: Record<string, unknown>;
20
+ }
21
+
22
+ export function register(options: WatchForgeRegisterOptions): void;
23
+ export function init(options: WatchForgeRegisterOptions): void;
24
+ export function captureException(
25
+ error: unknown,
26
+ context?: WatchForgeCaptureContext
27
+ ): Promise<void>;
28
+ export function captureMessage(
29
+ message: string,
30
+ level?: string,
31
+ context?: WatchForgeCaptureContext
32
+ ): Promise<void>;
33
+ export function addBreadcrumb(breadcrumb: Record<string, unknown>): void;
34
+
35
+ export {
36
+ startTransaction,
37
+ getCurrentTransaction,
38
+ finishTransaction,
39
+ Transaction,
40
+ Span,
41
+ } from "./tracing.js";
package/src/index.js CHANGED
@@ -13,5 +13,4 @@ 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";
17
- export { ErrorBoundary } from "./react.js";
16
+ export { expressMiddleware, expressRequestMiddleware } from "./express.js";
@@ -0,0 +1,26 @@
1
+ import type {
2
+ WatchForgeCaptureContext,
3
+ WatchForgeRegisterOptions,
4
+ } from "./index.js";
5
+
6
+ export function register(options: WatchForgeRegisterOptions): void;
7
+
8
+ export function captureException(
9
+ error: unknown,
10
+ context?: WatchForgeCaptureContext
11
+ ): Promise<void>;
12
+
13
+ export function captureMessage(
14
+ message: string,
15
+ level?: string,
16
+ context?: WatchForgeCaptureContext
17
+ ): Promise<void>;
18
+
19
+ export type NextRouteHandler<TContext = unknown> = (
20
+ request: Request,
21
+ context: TContext
22
+ ) => Response | Promise<Response>;
23
+
24
+ export function withWatchForgeRouteHandler<TContext = unknown>(
25
+ handler: NextRouteHandler<TContext>
26
+ ): NextRouteHandler<TContext>;
@@ -0,0 +1,58 @@
1
+ import {
2
+ captureException,
3
+ captureMessage,
4
+ register,
5
+ } from "./client.js";
6
+
7
+ export { captureException, captureMessage, register };
8
+
9
+ function sanitizeHeaders(headers) {
10
+ if (!headers || typeof headers.entries !== "function") return {};
11
+
12
+ const sensitive = new Set([
13
+ "authorization",
14
+ "cookie",
15
+ "set-cookie",
16
+ "x-api-key",
17
+ "x-csrftoken",
18
+ ]);
19
+
20
+ return Object.fromEntries(
21
+ Array.from(headers.entries()).filter(
22
+ ([key]) => !sensitive.has(key.toLowerCase())
23
+ )
24
+ );
25
+ }
26
+
27
+ function getRequestContext(request) {
28
+ if (!request) return null;
29
+
30
+ return {
31
+ url: request.url || "",
32
+ method: request.method || "",
33
+ headers: sanitizeHeaders(request.headers),
34
+ };
35
+ }
36
+
37
+ export function withWatchForgeRouteHandler(handler) {
38
+ return async function watchForgeRouteHandler(request, context) {
39
+ try {
40
+ return await handler(request, context);
41
+ } catch (error) {
42
+ await captureException(error, {
43
+ request: getRequestContext(request),
44
+ tags: {
45
+ framework: "nextjs",
46
+ runtime: "server",
47
+ },
48
+ contexts: {
49
+ nextjs: {
50
+ router: "app",
51
+ route_handler: true,
52
+ },
53
+ },
54
+ });
55
+ throw error;
56
+ }
57
+ };
58
+ }
package/src/next.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import type { WatchForgeRegisterOptions } from "./index.js";
3
+
4
+ export interface WatchForgeProviderProps {
5
+ options: WatchForgeRegisterOptions;
6
+ children?: ReactNode;
7
+ }
8
+
9
+ export function WatchForgeProvider(
10
+ props: WatchForgeProviderProps
11
+ ): ReactElement | null;
package/src/next.js ADDED
@@ -0,0 +1,12 @@
1
+ "use client";
2
+
3
+ import React, { useEffect } from "react";
4
+ import { register } from "./client.js";
5
+
6
+ export function WatchForgeProvider({ options, children = null }) {
7
+ useEffect(() => {
8
+ register(options);
9
+ }, [options]);
10
+
11
+ return React.createElement(React.Fragment, null, children);
12
+ }
package/src/react.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { Component, ReactNode } from "react";
2
+
3
+ export interface ErrorBoundaryProps {
4
+ children?: ReactNode;
5
+ fallback?: ReactNode;
6
+ }
7
+
8
+ export class ErrorBoundary extends Component<ErrorBoundaryProps> {}
package/src/stacktrace.js CHANGED
@@ -12,8 +12,7 @@ const isNode =
12
12
  const LIBRARY_PATH_PATTERNS = [
13
13
  "/node_modules/",
14
14
  "node_modules\\",
15
- "/webpack/",
16
- "webpack-internal://",
15
+ "/webpack/runtime/",
17
16
  ];
18
17
 
19
18
  function isLibraryPath(filePath) {
@@ -69,16 +68,21 @@ export function buildStacktraceFromError(error) {
69
68
  if (!parsed) continue;
70
69
 
71
70
  const { function: functionName, file, lineno, colno } = parsed;
72
- const filename = file.split("/").pop()?.split("?")[0] || file;
73
- 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);
74
77
 
75
78
  frames.push({
76
79
  filename,
77
- abs_path: file,
80
+ abs_path: normalizedPath,
81
+ raw_abs_path: file,
78
82
  lineno,
79
83
  colno,
80
84
  function: functionName,
81
- module: null,
85
+ module: normalizedPath,
82
86
  context_line: null,
83
87
  pre_context: [],
84
88
  post_context: [],
@@ -110,6 +114,34 @@ function applySourceContext(frame, sourceLines, lineno) {
110
114
  .map((l) => l.replace(/\r$/, ""));
111
115
  }
112
116
 
117
+ function normalizeSourcePath(source) {
118
+ if (!source) return "";
119
+
120
+ return String(source)
121
+ .replace(/^webpack-internal:\/\/\/?/, "")
122
+ .replace(/^webpack:\/\/(?:\([^)]*\)\/)?/, "")
123
+ .replace(/^webpack:\/\/[^/]+\//, "")
124
+ .replace(/^\([^)]*\)\//, "")
125
+ .replace(/^file:\/\//, "")
126
+ .split("?")[0]
127
+ .split("#")[0]
128
+ .replace(/\\/g, "/")
129
+ .replace(/^\/+/, "")
130
+ .replace(/^\.\//, "");
131
+ }
132
+
133
+ function sourceMatchesFrame(source, framePath) {
134
+ const normalizedSource = normalizeSourcePath(source);
135
+ const normalizedFrame = normalizeSourcePath(framePath);
136
+ if (!normalizedSource || !normalizedFrame) return false;
137
+
138
+ return (
139
+ normalizedSource === normalizedFrame ||
140
+ normalizedSource.endsWith(`/${normalizedFrame}`) ||
141
+ normalizedFrame.endsWith(`/${normalizedSource}`)
142
+ );
143
+ }
144
+
113
145
  let nodeModulesPromise = null;
114
146
  const importNodeBuiltin = (specifier) =>
115
147
  new Function("specifier", "return import(specifier)")(specifier);
@@ -171,62 +203,291 @@ async function readNodeSourceLines(absPath) {
171
203
 
172
204
  async function enrichFrameWithNodeSource(frame) {
173
205
  if (!frame.in_app || !frame.lineno) return;
174
- const lines = await readNodeSourceLines(frame.abs_path);
206
+ const lines = await readNodeSourceLines(frame.raw_abs_path || frame.abs_path);
175
207
  if (lines) applySourceContext(frame, lines, frame.lineno);
176
208
  }
177
209
 
178
- async function fetchBrowserSourceLines(absPath) {
179
- if (!isBrowser || !absPath) return null;
210
+ function getBrowserSourceFetchCandidates(absPath, rawAbsPath) {
211
+ const candidates = new Set();
212
+ const paths = [absPath, rawAbsPath].filter(Boolean);
180
213
 
181
- try {
182
- let url = absPath;
183
- if (url.startsWith("webpack-internal://")) {
184
- 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);
185
220
  }
186
221
 
187
- if (!url.startsWith("http://") && !url.startsWith("https://")) {
188
- if (url.startsWith("/")) {
189
- url = `${window.location.origin}${url}`;
190
- } else {
191
- return null;
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);
227
+ }
228
+
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
+ }
236
+
237
+ return [...candidates];
238
+ }
239
+
240
+ async function fetchBrowserSourceLines(absPath, rawAbsPath) {
241
+ if (!isBrowser || !absPath) return null;
242
+
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;
192
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
193
269
  }
270
+ }
194
271
 
195
- const target = new URL(url, window.location.href);
196
- if (target.origin !== window.location.origin) return null;
272
+ return null;
273
+ }
274
+
275
+ async function getSourceMapConsumer() {
276
+ try {
277
+ const mod = await import("source-map-js");
278
+ return mod.SourceMapConsumer || mod.default?.SourceMapConsumer || null;
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+
284
+ function getSourceMappingUrl(sourceText) {
285
+ const match = sourceText.match(/\/\/# sourceMappingURL=([^\s]+)\s*$/m);
286
+ return match?.[1] || null;
287
+ }
197
288
 
198
- const response = await fetch(target.href, { credentials: "same-origin" });
199
- if (!response.ok) return null;
289
+ async function fetchText(url) {
290
+ const response = await fetch(url, { credentials: "same-origin" });
291
+ if (!response.ok) return null;
292
+ const text = await response.text();
293
+ return text.length <= MAX_SOURCE_FILE_BYTES * 10 ? text : null;
294
+ }
200
295
 
201
- const contentType = response.headers.get("content-type") || "";
202
- if (!contentType.includes("javascript") && !contentType.includes("text")) {
296
+ async function fetchBrowserSourceMap(absPath) {
297
+ if (!isBrowser || !absPath) return null;
298
+ if (!absPath.startsWith("http://") && !absPath.startsWith("https://") && !absPath.startsWith("/")) {
299
+ return null;
300
+ }
301
+
302
+ try {
303
+ const scriptUrl = new URL(absPath, window.location.href);
304
+ if (scriptUrl.origin !== window.location.origin) return null;
305
+
306
+ const scriptText = await fetchText(scriptUrl.href);
307
+ if (!scriptText) return null;
308
+
309
+ const sourceMappingUrl = getSourceMappingUrl(scriptText);
310
+ if (!sourceMappingUrl) return null;
311
+
312
+ const mapUrl = new URL(sourceMappingUrl, scriptUrl.href);
313
+ if (mapUrl.origin !== window.location.origin && !sourceMappingUrl.startsWith("data:")) {
203
314
  return null;
204
315
  }
205
316
 
206
- const text = await response.text();
207
- if (text.length > MAX_SOURCE_FILE_BYTES) return null;
208
- return text.split("\n");
317
+ if (sourceMappingUrl.startsWith("data:")) {
318
+ const encoded = sourceMappingUrl.split(",", 2)[1];
319
+ if (!encoded) return null;
320
+ const decoded = decodeURIComponent(escape(atob(encoded)));
321
+ return JSON.parse(decoded);
322
+ }
323
+
324
+ const mapText = await fetchText(mapUrl.href);
325
+ return mapText ? JSON.parse(mapText) : null;
209
326
  } catch {
210
327
  return null;
211
328
  }
212
329
  }
213
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
+
375
+ async function enrichFrameWithSourceMap(frame) {
376
+ const resolved = await findSourceMapForFrame(frame);
377
+ if (!resolved) return false;
378
+
379
+ const { sourceMap } = resolved;
380
+
381
+ try {
382
+ const SourceMapConsumer = await getSourceMapConsumer();
383
+ if (!SourceMapConsumer) return false;
384
+
385
+ const consumer = await new SourceMapConsumer(sourceMap);
386
+ let source = null;
387
+ let lineno = frame.lineno;
388
+ let colno = frame.colno;
389
+
390
+ if (frame.lineno && frame.colno != null) {
391
+ const original = consumer.originalPositionFor({
392
+ line: frame.lineno,
393
+ column: frame.colno,
394
+ });
395
+ if (original?.source && original?.line) {
396
+ source = original.source;
397
+ lineno = original.line;
398
+ colno = original.column ?? frame.colno;
399
+ }
400
+ }
401
+
402
+ if (!source) {
403
+ source = sourceMap.sources?.find((candidate) =>
404
+ sourceMatchesFrame(candidate, frame.abs_path)
405
+ );
406
+ }
407
+
408
+ if (!source || !lineno) {
409
+ consumer.destroy?.();
410
+ return false;
411
+ }
412
+
413
+ const content =
414
+ consumer.sourceContentFor?.(source, true) ||
415
+ sourceMap.sourcesContent?.[sourceMap.sources.indexOf(source)];
416
+
417
+ consumer.destroy?.();
418
+
419
+ if (!content) return false;
420
+
421
+ frame.abs_path = source;
422
+ frame.filename = normalizeSourcePath(source).split("/").pop() || frame.filename;
423
+ frame.lineno = lineno;
424
+ frame.colno = colno;
425
+ applySourceContext(frame, content.split("\n"), lineno);
426
+ return Boolean(frame.context_line);
427
+ } catch {
428
+ return false;
429
+ }
430
+ }
431
+
432
+ function getSourceContentFromWebpackFrame(frame) {
433
+ const globalObject = typeof globalThis !== "undefined" ? globalThis : window;
434
+ const webpackChunks =
435
+ Object.values(globalObject).find(
436
+ (value) =>
437
+ Array.isArray(value) &&
438
+ value.some((item) => Array.isArray(item) && item.length >= 2)
439
+ ) || [];
440
+
441
+ const framePath = normalizeSourcePath(
442
+ frame.abs_path || frame.module || frame.raw_abs_path
443
+ );
444
+ if (!framePath || !Array.isArray(webpackChunks)) return null;
445
+
446
+ for (const chunk of webpackChunks) {
447
+ const modules = Array.isArray(chunk) ? chunk[1] : null;
448
+ if (!modules || typeof modules !== "object") continue;
449
+
450
+ for (const [moduleId, factory] of Object.entries(modules)) {
451
+ if (!sourceMatchesFrame(moduleId, framePath)) continue;
452
+ const source = String(factory);
453
+ return source.includes("\n") ? source.split("\n") : null;
454
+ }
455
+ }
456
+
457
+ return null;
458
+ }
459
+
214
460
  async function enrichFrameWithBrowserSource(frame) {
215
461
  if (!frame.in_app || !frame.lineno) return;
216
- const lines = await fetchBrowserSourceLines(frame.abs_path);
217
- if (lines) applySourceContext(frame, lines, frame.lineno);
462
+
463
+ const webpackLines = getSourceContentFromWebpackFrame(frame);
464
+ if (webpackLines) {
465
+ applySourceContext(frame, webpackLines, frame.lineno);
466
+ if (frame.context_line) return;
467
+ }
468
+
469
+ const lines = await fetchBrowserSourceLines(frame.abs_path, frame.raw_abs_path);
470
+ if (lines) {
471
+ applySourceContext(frame, lines, frame.lineno);
472
+ if (frame.context_line) return;
473
+ }
474
+
475
+ await enrichFrameWithSourceMap(frame);
218
476
  }
219
477
 
220
478
  export async function enrichStacktraceAsync(stacktrace) {
221
479
  if (!stacktrace?.frames?.length) return stacktrace;
222
480
 
223
481
  const inAppFrames = stacktrace.frames.filter((f) => f.in_app);
224
- 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();
225
484
 
226
485
  if (isNode) {
227
486
  await Promise.all(targets.map((frame) => enrichFrameWithNodeSource(frame)));
228
487
  } else if (isBrowser) {
229
- await Promise.all(targets.map((frame) => enrichFrameWithBrowserSource(frame)));
488
+ for (const frame of targets) {
489
+ await enrichFrameWithBrowserSource(frame);
490
+ }
230
491
  }
231
492
 
232
493
  return stacktrace;
@@ -0,0 +1,39 @@
1
+ export interface Span {
2
+ span_id?: string;
3
+ op?: string;
4
+ description?: string;
5
+ start_timestamp?: string;
6
+ finish_timestamp?: string | null;
7
+ duration_ms?: number;
8
+ status?: string;
9
+ data?: Record<string, unknown>;
10
+ tags?: Record<string, unknown>;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ export interface Transaction {
15
+ trace_id?: string;
16
+ transaction?: string;
17
+ transaction_name?: string;
18
+ op?: string;
19
+ status?: string;
20
+ spans?: Span[];
21
+ data?: Record<string, unknown>;
22
+ tags?: Record<string, unknown>;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ export function initTracing(
27
+ dsn: string,
28
+ environment?: string,
29
+ debug?: boolean
30
+ ): void;
31
+
32
+ export function startTransaction(
33
+ transaction: string,
34
+ transactionName?: string,
35
+ op?: string
36
+ ): Transaction;
37
+
38
+ export function getCurrentTransaction(): Transaction | null;
39
+ export function finishTransaction(status?: string): void;