@usereelay/browser 0.1.0

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/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # @usereelay/browser
2
+
3
+ Reelay error tracking for browsers. Captures uncaught errors and unhandled
4
+ promise rejections, records click/console/fetch breadcrumbs and a
5
+ masked-by-default DOM session replay, and stitches frontend sessions to
6
+ backend traces — with **zero runtime dependencies** and a hard rule of zero
7
+ host-app disruption.
8
+
9
+ Standalone project: everything the SDK needs (transport, PII scrubbing,
10
+ stack parsing, trace ids, replay) is built in. Ships as a single ESM bundle
11
+ (~22 KB unminified) plus CJS + types.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ # from npm (when published)
17
+ npm install @usereelay/browser
18
+
19
+ # or as a local file dependency
20
+ npm install /path/to/reelay-browser-sdk
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```ts
26
+ import * as Reelay from '@usereelay/browser';
27
+
28
+ Reelay.init({
29
+ endpoint: 'https://api.usereelay.com', // your Reelay ingest base URL
30
+ token: 'rlyt_live_…', // node API key for this frontend
31
+ release: 'web@' + import.meta.env.VITE_GIT_SHA,
32
+ environment: 'production',
33
+ // Origins that receive trace headers so backend errors link to this session:
34
+ tracePropagationTargets: ['https://api.acme.com'],
35
+ });
36
+ ```
37
+
38
+ Call `init()` once, as early as possible (before your app boots). Everything
39
+ else is automatic. The built `dist/index.js` is dependency-free ESM, so it
40
+ also works without a bundler:
41
+
42
+ ```html
43
+ <script type="module">
44
+ import * as Reelay from '/vendor/reelay-browser.js';
45
+ Reelay.init({ endpoint: '…', token: '…' });
46
+ </script>
47
+ ```
48
+
49
+ ## What each piece does
50
+
51
+ | Export / feature | Purpose |
52
+ |---|---|
53
+ | `init(options)` | Creates the singleton client and installs all instrumentation. Idempotent — repeat calls return the existing client. |
54
+ | Global error capture | `window` `error` + `unhandledrejection` via `addEventListener` — never by overwriting `window.onerror`, so other tooling keeps working. |
55
+ | Breadcrumbs | Clicks (element descriptor only, no text), `popstate` navigations, `console.error`/`console.warn` (wrapped, originals preserved), and every `fetch` (method + URL + status). Ring buffer of the latest 50, attached to every event. |
56
+ | Fetch trace stitching | For URLs matching `tracePropagationTargets`, outgoing requests get W3C `traceparent` + `reelay-session-id` headers. The `@usereelay/node` middleware adopts them, so a backend error and this page's replay share one `trace_id`. Same-origin only by default; **never** cross-origin unless you allow-list it. |
57
+ | Session replay | Initial DOM snapshot + `MutationObserver` deltas, serialized inside `requestIdleCallback`, shipped as sequenced chunks every 5 s. With `maskAllText` (default) every text node is masked **before** serialization and inputs are always masked — content never leaves the page. |
58
+ | `captureException(err)` | Manually report a handled error. Returns the event id (`''` if dropped). |
59
+ | `captureMessage(msg)` | Report a string message as an event. |
60
+ | `getClient()` | The active `BrowserClient` (exposes `sessionId`, `addBreadcrumb`, `flush`, `uninstall`). |
61
+
62
+ ## Options
63
+
64
+ ```ts
65
+ Reelay.init({
66
+ endpoint: 'https://api.usereelay.com', // required: ingest base URL
67
+ token: 'rlyt_live_…', // required: node API key
68
+ release: 'web@abc123', // optional: ties events to a deploy
69
+ environment: 'production', // optional
70
+ sampleRate: 1, // 0–1 probability an event is sent
71
+ beforeSend: (event) => event, // mutate or return null to drop
72
+ scrubPatterns: [/order-\d{6}/g], // extra PII regexes (additive)
73
+ maxQueueSize: 30, // offline/retry buffer (drop-oldest)
74
+ tracePropagationTargets: [ // origins/patterns that get trace headers
75
+ 'https://api.acme.com',
76
+ /^https:\/\/.*\.internal\.acme\.com/,
77
+ ],
78
+ replay: true, // DOM session replay on/off
79
+ maskAllText: true, // mask every text node in replay
80
+ debug: (msg, detail) => {}, // SDK self-diagnostics (silent by default)
81
+ });
82
+ ```
83
+
84
+ ## Engineering guarantees
85
+
86
+ - **Zero host disruption** — every listener and public API runs inside a
87
+ defensive boundary; SDK failures go to the `debug` hook, never your app.
88
+ `fetch` wrapping preserves the original's exact arguments/return; failed
89
+ instrumentation degrades to a passthrough call.
90
+ - **Performance budget** — replay serialization runs in `requestIdleCallback`,
91
+ click listeners are passive + capture-phase, breadcrumbs are an O(1) ring
92
+ buffer; nothing blocks the UI thread, so LCP/INP/CLS are unaffected.
93
+ - **No data loss on navigation** — events flush on `visibilitychange: hidden`
94
+ using `keepalive` fetch, which survives page unload.
95
+ - **PII never leaves the page** — sensitive keys redacted wholesale; tokens,
96
+ JWTs, card numbers and emails pattern-scrubbed; replay masks all text and
97
+ all input values by default.
98
+ - **Bounded memory** — breadcrumb ring (50), transport queue (30,
99
+ drop-oldest), replay delta buffer (500), traced-request map (100).
100
+
101
+ ## Source maps (frontend symbolication)
102
+
103
+ Production bundles are minified, so raw stack frames point at hashed files like
104
+ `assets/index-a1b2c3.js:1:54023`. To see original files, lines, and function
105
+ names in Reelay, provision your source maps at build/deploy time with the
106
+ bundled `reelay-sourcemaps` CLI.
107
+
108
+ ```bash
109
+ # 1. Build your app with source maps enabled (prefer hidden-source-map so the
110
+ # maps are emitted but not referenced from shipped JS).
111
+ npm run build
112
+
113
+ # 2. Inject debug ids into the built JS + maps (the robust, release-independent
114
+ # match key), then upload the maps to Reelay.
115
+ npx reelay-sourcemaps inject-and-upload ./dist \
116
+ --url https://api.reelay.app \
117
+ --token "$REELAY_TOKEN" \
118
+ --release "web@$(git rev-parse --short HEAD)" \
119
+ --delete # remove .map files after upload so they never reach your CDN
120
+ ```
121
+
122
+ `--url`, `--token`, and `--release` also read from `REELAY_URL`, `REELAY_TOKEN`,
123
+ and `REELAY_RELEASE`. Set the **same `release`** in `Reelay.init({ release })`
124
+ so release-based matching works even for bundles without debug ids.
125
+
126
+ How it works:
127
+
128
+ - **Debug ids (preferred).** `inject` stamps each chunk with a content-derived
129
+ id, recorded both in the JS (a tiny runtime snippet) and in the map. The SDK
130
+ reads that registry at capture time and tags each frame with its bundle's
131
+ debug id, so the backend resolves the exact map regardless of release naming
132
+ or CDN hashing. The injector offsets the map's `mappings` for the one line it
133
+ adds, so positions stay exact.
134
+ - **Release + filename (fallback).** When a frame has no debug id, the backend
135
+ matches on the event's `release` and the minified file's basename.
136
+ - Already using a Sentry bundler plugin? The SDK also reads `_sentryDebugIds`,
137
+ so existing debug ids are picked up with no extra tooling.
138
+
139
+ Source maps expose original source — Reelay stores them privately, never serves
140
+ them back, and expires them on the same retention window as your events.
141
+
142
+ ## Development
143
+
144
+ ```bash
145
+ npm install
146
+ npm run build # tsup → dist/ (ESM + CJS + .d.ts)
147
+ npm test # vitest
148
+ npm run typecheck
149
+ ```
150
+
151
+ See `../reelay-test-project/frontend` for a runnable page wired up with this
152
+ SDK (including buttons that trigger every class of frontend error).
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * reelay-sourcemaps — build/deploy-time source-map provisioning for the Reelay
4
+ * browser SDK. Zero dependencies; runs on Node 18+ (uses global fetch).
5
+ *
6
+ * reelay-sourcemaps inject <dir> Inject debug ids into built JS + maps
7
+ * reelay-sourcemaps upload <dir> [opts] Upload maps to Reelay
8
+ * reelay-sourcemaps inject-and-upload <dir> Both, in order
9
+ *
10
+ * Options (or env: REELAY_URL, REELAY_TOKEN, REELAY_RELEASE):
11
+ * --url <endpoint> Reelay ingestion base URL [REELAY_URL]
12
+ * --token <token> Node API key (rlyt_live_…) [REELAY_TOKEN]
13
+ * --release <release> Release tag matching your SDK init [REELAY_RELEASE]
14
+ * --delete Delete .map files after a successful upload
15
+ *
16
+ * Debug ids are the primary, unambiguous match key: each bundle is stamped with
17
+ * a content-derived id recorded both in the JS (a tiny runtime snippet the SDK
18
+ * reads) and in the map. `upload` also sends release+filename as a fallback, so
19
+ * symbolication works even for bundlers that emit no debug ids.
20
+ *
21
+ * Source maps expose original source — keep them private. Prefer `hidden-source-map`
22
+ * (no sourceMappingURL comment in shipped JS) and pass --delete so maps never
23
+ * reach your CDN.
24
+ */
25
+ import { createHash } from 'node:crypto';
26
+ import { readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'node:fs';
27
+ import { join, basename, dirname, resolve } from 'node:path';
28
+
29
+ const JS_EXT = /\.(?:js|mjs|cjs)$/;
30
+ const SOURCE_MAPPING_URL = /\/\/# sourceMappingURL=([^\s'"]+)\s*$/m;
31
+ const DEBUG_ID_COMMENT = /\/\/# debugId=([0-9a-fA-F-]+)/;
32
+
33
+ function log(msg) {
34
+ process.stdout.write(`[reelay-sourcemaps] ${msg}\n`);
35
+ }
36
+ function warn(msg) {
37
+ process.stderr.write(`[reelay-sourcemaps] WARN ${msg}\n`);
38
+ }
39
+ function fail(msg) {
40
+ process.stderr.write(`[reelay-sourcemaps] ERROR ${msg}\n`);
41
+ process.exit(1);
42
+ }
43
+
44
+ /** Deterministic UUIDv4-shaped id from content, so re-runs are idempotent. */
45
+ function debugIdFromContent(buf) {
46
+ const h = createHash('sha256').update(buf).digest();
47
+ const b = Buffer.from(h.subarray(0, 16));
48
+ b[6] = (b[6] & 0x0f) | 0x40; // version 4
49
+ b[8] = (b[8] & 0x3f) | 0x80; // variant 10
50
+ const hex = b.toString('hex');
51
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
52
+ }
53
+
54
+ function walk(dir, out = []) {
55
+ for (const entry of readdirSync(dir)) {
56
+ const full = join(dir, entry);
57
+ const st = statSync(full);
58
+ if (st.isDirectory()) walk(full, out);
59
+ else out.push(full);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** Resolves the map path for a JS file via its sourceMappingURL or `<js>.map`. */
65
+ function mapPathFor(jsFile, jsText) {
66
+ const m = SOURCE_MAPPING_URL.exec(jsText);
67
+ if (m && m[1] && !m[1].startsWith('data:')) {
68
+ return resolve(dirname(jsFile), m[1]);
69
+ }
70
+ const sibling = `${jsFile}.map`;
71
+ try {
72
+ statSync(sibling);
73
+ return sibling;
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Runtime snippet recording this chunk's debug id, keyed by a freshly-captured
81
+ * stack so the SDK can map script URL -> debug id. One physical line; we prepend
82
+ * exactly one `;` to the map's mappings to offset the inserted generated line.
83
+ */
84
+ function injectionSnippet(debugId) {
85
+ return (
86
+ `;!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:` +
87
+ `"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},` +
88
+ `n=(new e.Error).stack;n&&(e._reelayDebugIds=e._reelayDebugIds||{},` +
89
+ `e._reelayDebugIds[n]="${debugId}")}catch(e){}}();`
90
+ );
91
+ }
92
+
93
+ function injectPair(jsFile, jsText) {
94
+ const existing = DEBUG_ID_COMMENT.exec(jsText);
95
+ const mapFile = mapPathFor(jsFile, jsText);
96
+ if (!mapFile) {
97
+ return { status: 'skipped', reason: 'no source map' };
98
+ }
99
+
100
+ let map;
101
+ try {
102
+ map = JSON.parse(readFileSync(mapFile, 'utf8'));
103
+ } catch {
104
+ return { status: 'skipped', reason: 'unparseable map' };
105
+ }
106
+ if (map.version !== 3 || typeof map.mappings !== 'string') {
107
+ // Indexed maps (sections) would need per-section offsetting; skip rather
108
+ // than risk corrupting the mapping.
109
+ return { status: 'skipped', reason: 'unsupported map (not flat v3)' };
110
+ }
111
+
112
+ // Idempotent: if already injected, just make sure the map carries the id.
113
+ if (existing) {
114
+ const debugId = existing[1];
115
+ if (map.debugId !== debugId || map.debug_id !== debugId) {
116
+ map.debugId = debugId;
117
+ map.debug_id = debugId;
118
+ writeFileSync(mapFile, JSON.stringify(map));
119
+ }
120
+ return { status: 'already', debugId, mapFile };
121
+ }
122
+
123
+ const debugId = debugIdFromContent(jsText);
124
+ const snippet = injectionSnippet(debugId);
125
+
126
+ // Prepend snippet as one full line; original code keeps its columns and moves
127
+ // down one generated line, which we offset with a single leading ';'.
128
+ let next = `${snippet}\n${jsText}`;
129
+ next = next.replace(/\s*$/, '');
130
+ next += `\n//# debugId=${debugId}\n`;
131
+ writeFileSync(jsFile, next);
132
+
133
+ map.mappings = `;${map.mappings}`;
134
+ map.debugId = debugId;
135
+ map.debug_id = debugId;
136
+ writeFileSync(mapFile, JSON.stringify(map));
137
+
138
+ return { status: 'injected', debugId, mapFile };
139
+ }
140
+
141
+ function cmdInject(dir) {
142
+ let injected = 0;
143
+ let already = 0;
144
+ for (const file of walk(dir)) {
145
+ if (!JS_EXT.test(file)) continue;
146
+ let jsText;
147
+ try {
148
+ jsText = readFileSync(file, 'utf8');
149
+ } catch {
150
+ continue;
151
+ }
152
+ const res = injectPair(file, jsText);
153
+ if (res.status === 'injected') {
154
+ injected++;
155
+ log(`injected ${res.debugId} -> ${basename(file)}`);
156
+ } else if (res.status === 'already') {
157
+ already++;
158
+ }
159
+ }
160
+ log(`inject complete: ${injected} injected, ${already} already done`);
161
+ }
162
+
163
+ async function uploadMap(opts, mapFile) {
164
+ let map;
165
+ try {
166
+ map = JSON.parse(readFileSync(mapFile, 'utf8'));
167
+ } catch {
168
+ warn(`skipping unreadable map ${mapFile}`);
169
+ return false;
170
+ }
171
+ const debugId = map.debugId || map.debug_id || '';
172
+ // Minified file the map belongs to: prefer the map's own `file`, else strip
173
+ // the .map suffix. Sent as the release+filename fallback key.
174
+ const file = basename(map.file || mapFile.replace(/\.map$/, ''));
175
+
176
+ if (!debugId && !opts.release) {
177
+ warn(`skipping ${basename(mapFile)}: no debug id and no --release to key on`);
178
+ return false;
179
+ }
180
+
181
+ const body = JSON.stringify({
182
+ release: opts.release || undefined,
183
+ debug_id: debugId || undefined,
184
+ file,
185
+ sourcemap: readFileSync(mapFile, 'utf8'),
186
+ });
187
+
188
+ const res = await fetch(`${opts.url.replace(/\/$/, '')}/api/ingest/sourcemaps`, {
189
+ method: 'POST',
190
+ headers: { 'Content-Type': 'application/json', 'X-Reelay-Token': opts.token },
191
+ body,
192
+ });
193
+ if (!res.ok) {
194
+ const text = await res.text().catch(() => '');
195
+ warn(`upload failed for ${basename(mapFile)}: ${res.status} ${text.slice(0, 200)}`);
196
+ return false;
197
+ }
198
+ log(`uploaded ${basename(mapFile)}${debugId ? ` (debugId ${debugId})` : ''}`);
199
+ if (opts.delete) rmSync(mapFile);
200
+ return true;
201
+ }
202
+
203
+ async function cmdUpload(dir, opts) {
204
+ if (!opts.url) fail('missing --url (or REELAY_URL)');
205
+ if (!opts.token) fail('missing --token (or REELAY_TOKEN)');
206
+
207
+ const maps = walk(dir).filter((f) => f.endsWith('.map'));
208
+ if (maps.length === 0) {
209
+ warn(`no .map files found under ${dir}`);
210
+ return;
211
+ }
212
+ let ok = 0;
213
+ for (const mapFile of maps) {
214
+ if (await uploadMap(opts, mapFile)) ok++;
215
+ }
216
+ log(`upload complete: ${ok}/${maps.length} maps stored`);
217
+ }
218
+
219
+ function parseArgs(argv) {
220
+ const opts = {
221
+ url: process.env.REELAY_URL || process.env.REELAY_ENDPOINT || '',
222
+ token: process.env.REELAY_TOKEN || '',
223
+ release: process.env.REELAY_RELEASE || '',
224
+ delete: false,
225
+ _: [],
226
+ };
227
+ for (let i = 0; i < argv.length; i++) {
228
+ const a = argv[i];
229
+ switch (a) {
230
+ case '--url': opts.url = argv[++i]; break;
231
+ case '--token': opts.token = argv[++i]; break;
232
+ case '--release': opts.release = argv[++i]; break;
233
+ case '--delete': opts.delete = true; break;
234
+ default: opts._.push(a);
235
+ }
236
+ }
237
+ return opts;
238
+ }
239
+
240
+ async function main() {
241
+ const [, , command, ...rest] = process.argv;
242
+ const opts = parseArgs(rest);
243
+ const dir = opts._[0];
244
+
245
+ if (!command || command === '--help' || command === '-h') {
246
+ process.stdout.write(
247
+ 'Usage:\n' +
248
+ ' reelay-sourcemaps inject <dir>\n' +
249
+ ' reelay-sourcemaps upload <dir> --url <u> --token <t> [--release <r>] [--delete]\n' +
250
+ ' reelay-sourcemaps inject-and-upload <dir> --url <u> --token <t> [--release <r>] [--delete]\n',
251
+ );
252
+ return;
253
+ }
254
+ if (!dir) fail('missing <dir> argument');
255
+ try {
256
+ statSync(dir);
257
+ } catch {
258
+ fail(`directory not found: ${dir}`);
259
+ }
260
+
261
+ switch (command) {
262
+ case 'inject':
263
+ cmdInject(dir);
264
+ break;
265
+ case 'upload':
266
+ await cmdUpload(dir, opts);
267
+ break;
268
+ case 'inject-and-upload':
269
+ cmdInject(dir);
270
+ await cmdUpload(dir, opts);
271
+ break;
272
+ default:
273
+ fail(`unknown command: ${command}`);
274
+ }
275
+ }
276
+
277
+ main().catch((err) => fail(err?.stack || String(err)));