@brunyee-studio/onus-sdk 0.1.0 → 1.0.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * DSN parsing for @onus/sdk (ONUS-68).
2
+ * DSN parsing for @brunyee-studio/onus-sdk (ONUS-68).
3
3
  *
4
4
  * Mirrors `src/lib/ingest/dsn.ts` semantics (the server side of the same
5
5
  * protocol): `{PROTOCOL}://{PUBLIC_KEY}@{HOST}/{NUMERIC_PROJECT_ID}` with no
@@ -26,7 +26,34 @@ declare function parseDsn(raw: string | undefined | null): ParsedDsn | null;
26
26
  declare function buildSentryAuthHeader(dsn: ParsedDsn, clientVersion: string): string;
27
27
 
28
28
  /**
29
- * Sentry-compatible envelope building for @onus/sdk (ONUS-68).
29
+ * Replay privacy resolution (capture-safety spec): rrweb's own defaults only
30
+ * mask password inputs, so the SDK forces safer defaults BEFORE serialization
31
+ * and lets integrators opt down deliberately through `ReplayPrivacyOptions`.
32
+ * Keys in PRIVACY_CONTROLLED_KEYS are stripped from a bare `recordOptions`
33
+ * passthrough so it can never defeat masking, un-reviewed pixel/iframe
34
+ * capture, replace the SDK's `emit` callback, or pre-pack events through
35
+ * rrweb `packFn`/`plugins` (which run BEFORE emit and could bypass the SDK's
36
+ * Meta href sanitization).
37
+ */
38
+ interface ReplayPrivacyOptions {
39
+ /** Mask every input value (default true). Passwords are always masked. */
40
+ maskAllInputs?: boolean;
41
+ /** Selector whose text nodes are masked (default '*' — all text). */
42
+ maskTextSelector?: string;
43
+ /** Per-input-type mask map; `password: true` is forced. */
44
+ maskInputOptions?: Record<string, unknown>;
45
+ /** Custom masking transform for text nodes. */
46
+ maskTextFn?: (text: string) => string;
47
+ /** ADDITIVE extra block selectors appended to the built-in block list
48
+ * ('[data-onus-private]', 'input[type="hidden"]', 'input[type="radio"]',
49
+ * 'input[type="checkbox"]'); invalid extras are ignored. */
50
+ blockSelector?: string;
51
+ /** Elements to skip without blocking their subtree. */
52
+ ignoreSelector?: string;
53
+ }
54
+
55
+ /**
56
+ * Sentry-compatible envelope building for @brunyee-studio/onus-sdk (ONUS-68).
30
57
  *
31
58
  * Wire contract (`src/app/api/[projectId]/envelope/route.ts`):
32
59
  * - first line: JSON envelope header (event_id, sent_at, sdk)
@@ -73,13 +100,16 @@ interface ReplayOptions {
73
100
  environment?: string;
74
101
  release?: string;
75
102
  /**
76
- * @onus/analytics session id (or getter) attached to each replay_event so
103
+ * @brunyee-studio/onus-analytics session id (or getter) attached to each replay_event so
77
104
  * the sessions surface can link playback (ONUS-155). A getter is evaluated
78
105
  * per segment flush so rotated session ids stay fresh.
79
106
  */
80
107
  sessionId?: string | (() => string | null | undefined);
81
- /** rrweb record options (eventsPerSecond caps, etc.); kept passthrough. */
108
+ /** Non-privacy rrweb record tuning (sampling caps, etc.); privacy-controlled
109
+ * keys (masking, emit, packFn/plugins, canvas/iframe) are stripped. */
82
110
  recordOptions?: Record<string, unknown>;
111
+ /** Deliberate privacy opt-downs; safe defaults apply when omitted. */
112
+ privacy?: ReplayPrivacyOptions;
83
113
  /**
84
114
  * How long buffered rrweb events wait before flushing as one segment
85
115
  * envelope (ms, default 5000 — Sentry's segment cadence). Batching keeps a
@@ -91,6 +121,8 @@ interface ReplayOptions {
91
121
  /** Test seam: recorder + fetch injection (defaults to lazy rrweb import). */
92
122
  recorderFactory?: () => Promise<Recorder | null>;
93
123
  fetchImpl?: typeof fetch;
124
+ /** Internal guard: reject an auto-start invalidated during an async boundary. */
125
+ shouldProceed?: () => boolean;
94
126
  }
95
127
  /** Minimal structural subset of rrweb's record function used here. */
96
128
  interface Recorder {
@@ -101,26 +133,32 @@ interface Recorder {
101
133
  */
102
134
  wire?: (opts: unknown) => void;
103
135
  }
104
- interface ReplayState {
105
- replayId: string;
106
- stop: (() => void) | null;
107
- /** Buffered rrweb events awaiting the next segment flush. */
108
- buffer: unknown[];
109
- /** Pending interval flush (one envelope per flush, not per event). */
110
- timer: ReturnType<typeof setTimeout> | null;
111
- }
136
+ /** rrweb Meta events (type 4) carry the page href; strip query, fragment, and
137
+ * credentials before recording. */
138
+ declare function stripUrlQueryAndFragment(raw: string): string;
139
+ /** One recorded rrweb event as delivered by the recorder's emit callback. */
140
+ type RecordedEvent = Record<string, unknown>;
141
+ /** Minimize recorded URL metadata before buffering (Meta href only). */
142
+ declare function sanitizeRecordedEvent(event: unknown): RecordedEvent;
112
143
  /**
113
144
  * Start session replay. No-op (returns null) when replay is already running,
114
145
  * the DSN is invalid, the recorder factory fails, or rrweb cannot be loaded.
115
146
  */
116
147
  declare function startSessionReplay(options?: ReplayOptions): Promise<string | null>;
148
+ /** Internal SDK auto-start seam; direct host calls use startSessionReplay instead. */
149
+ declare function startAutoSessionReplay(options: ReplayOptions, generation: number): Promise<string | null>;
117
150
  /** Stop replay. No-op when not running. Flushes any buffered tail. */
118
151
  declare function stopSessionReplay(): void;
152
+ /** Hard revoke on denial: also abort all retained stopped-session tails. */
153
+ declare function revokeActiveReplay(): void;
119
154
  /** Currently active replay id (null when off). */
120
155
  declare function activeReplayId(): string | null;
121
156
  /** Test seam. */
122
157
  declare function resetReplayForTest(): void;
123
158
  /** Build + send one segment envelope (replay_event + replay_recording pair). */
124
- declare function emitSegment(dsn: NonNullable<ReturnType<typeof parseDsn>>, options: ReplayOptions, state: ReplayState, event: unknown, segmentId: number, timestamp: string): Promise<boolean>;
159
+ declare function emitSegment(dsn: NonNullable<ReturnType<typeof parseDsn>>, options: ReplayOptions, state: {
160
+ replayId: string;
161
+ abort: AbortController | null;
162
+ }, event: unknown, segmentId: number, timestamp: string): Promise<boolean>;
125
163
 
126
- export { type EnvelopeItem as E, MAX_ENVELOPE_BYTES as M, type ParsedDsn as P, type Recorder as R, REPLAY_ID_RE as a, buildEnvelope as b, buildSentryAuthHeader as c, type ReplayOptions as d, eventId as e, activeReplayId as f, emitSegment as g, stopSessionReplay as h, parseDsn as p, resetReplayForTest as r, startSessionReplay as s };
164
+ export { type EnvelopeItem as E, MAX_ENVELOPE_BYTES as M, type ParsedDsn as P, type ReplayPrivacyOptions as R, type Recorder as a, REPLAY_ID_RE as b, buildEnvelope as c, buildSentryAuthHeader as d, eventId as e, type RecordedEvent as f, type ReplayOptions as g, activeReplayId as h, emitSegment as i, revokeActiveReplay as j, startAutoSessionReplay as k, startSessionReplay as l, stopSessionReplay as m, stripUrlQueryAndFragment as n, parseDsn as p, resetReplayForTest as r, sanitizeRecordedEvent as s };
package/dist/replay.d.ts CHANGED
@@ -1 +1 @@
1
- export { a as REPLAY_ID_RE, R as Recorder, d as ReplayOptions, f as activeReplayId, g as emitSegment, p as parseDsn, r as resetReplayForTest, s as startSessionReplay, h as stopSessionReplay } from './replay-D7ejwI0s.js';
1
+ export { b as REPLAY_ID_RE, f as RecordedEvent, a as Recorder, g as ReplayOptions, h as activeReplayId, i as emitSegment, p as parseDsn, r as resetReplayForTest, j as revokeActiveReplay, s as sanitizeRecordedEvent, k as startAutoSessionReplay, l as startSessionReplay, m as stopSessionReplay, n as stripUrlQueryAndFragment } from './replay-BElhLiw8.js';
package/dist/replay.js CHANGED
@@ -3,30 +3,168 @@ import {
3
3
  SDK_NAME,
4
4
  buildEnvelope,
5
5
  eventId,
6
+ getActiveReplayId,
7
+ isReplayCaptureAllowed,
6
8
  parseDsn,
7
- sendEnvelope
8
- } from "./chunk-SLWBPQ6C.js";
9
+ registerReplayStop,
10
+ registerStaleAutoReplayRelease,
11
+ resetCaptureGateForTest,
12
+ sendEnvelope,
13
+ setActiveReplayId
14
+ } from "./chunk-YPQXIZ4X.js";
15
+
16
+ // src/replay-privacy.ts
17
+ var PRIVACY_CONTROLLED_KEYS = /* @__PURE__ */ new Set([
18
+ "emit",
19
+ "maskAllInputs",
20
+ "maskTextSelector",
21
+ "maskTextClass",
22
+ "maskInputOptions",
23
+ "maskInputFn",
24
+ "maskTextFn",
25
+ "blockSelector",
26
+ "blockClass",
27
+ "ignoreClass",
28
+ "ignoreSelector",
29
+ // rrweb runs packers/plugins BEFORE the emit callback, so an allowed
30
+ // passthrough could serialize events that never pass through the SDK's
31
+ // Meta href sanitization. They are SDK-controlled, never passthrough.
32
+ "packFn",
33
+ "plugins",
34
+ "recordCanvas",
35
+ "recordCrossOriginIframes",
36
+ "inlineImages"
37
+ ]);
38
+ var PRIVACY_DEFAULTS = {
39
+ maskAllInputs: true,
40
+ maskTextSelector: "*",
41
+ blockSelector: '[data-onus-private], input[type="hidden"], input[type="radio"], input[type="checkbox"]'
42
+ };
43
+ function isValidCssSelector(raw) {
44
+ if (typeof document === "undefined") return false;
45
+ try {
46
+ document.querySelector(raw);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+ function validSelectorExtra(raw) {
53
+ if (typeof raw !== "string" || raw.trim().length === 0) return "";
54
+ return isValidCssSelector(raw) ? raw : "";
55
+ }
56
+ function maskTextSelectorOrFallback(raw) {
57
+ if (typeof raw !== "string" || raw.trim().length === 0) return PRIVACY_DEFAULTS.maskTextSelector;
58
+ return isValidCssSelector(raw) ? raw : PRIVACY_DEFAULTS.maskTextSelector;
59
+ }
60
+ function blockSelectorWithExtra(extra) {
61
+ const extra_ = validSelectorExtra(extra);
62
+ return extra_ ? `${PRIVACY_DEFAULTS.blockSelector}, ${extra_}` : PRIVACY_DEFAULTS.blockSelector;
63
+ }
64
+ function resolveRecordPrivacy(recordOptions, privacy) {
65
+ const passthrough = {};
66
+ for (const [key, value] of Object.entries(recordOptions ?? {})) {
67
+ if (!PRIVACY_CONTROLLED_KEYS.has(key)) passthrough[key] = value;
68
+ }
69
+ const effective = {
70
+ ...passthrough,
71
+ maskAllInputs: privacy?.maskAllInputs ?? PRIVACY_DEFAULTS.maskAllInputs,
72
+ maskTextSelector: maskTextSelectorOrFallback(privacy?.maskTextSelector),
73
+ blockSelector: blockSelectorWithExtra(privacy?.blockSelector),
74
+ recordCanvas: false,
75
+ recordCrossOriginIframes: false,
76
+ inlineImages: false,
77
+ // Password masking is non-negotiable, regardless of opt-downs above.
78
+ maskInputOptions: {
79
+ ...privacy?.maskInputOptions ?? {},
80
+ password: true
81
+ }
82
+ };
83
+ if (privacy?.maskTextFn) effective["maskTextFn"] = privacy.maskTextFn;
84
+ if (privacy?.ignoreSelector) effective["ignoreSelector"] = privacy.ignoreSelector;
85
+ return effective;
86
+ }
9
87
 
10
88
  // src/replay.ts
89
+ function stripUrlQueryAndFragment(raw) {
90
+ try {
91
+ const url = new URL(raw);
92
+ url.username = "";
93
+ url.password = "";
94
+ url.search = "";
95
+ url.hash = "";
96
+ return url.toString();
97
+ } catch {
98
+ const noHash = raw.split("#")[0] ?? raw;
99
+ return noHash.split("?")[0] ?? noHash;
100
+ }
101
+ }
102
+ function sanitizeRecordedEvent(event) {
103
+ const candidate = event;
104
+ if (!candidate || typeof candidate !== "object" || candidate.type !== 4 || !candidate.data || typeof candidate.data !== "object") {
105
+ return event;
106
+ }
107
+ const data = { ...candidate.data };
108
+ if (typeof data["href"] === "string") data["href"] = stripUrlQueryAndFragment(data["href"]);
109
+ return { ...candidate, data };
110
+ }
11
111
  var active = null;
12
- async function startSessionReplay(options = {}) {
112
+ var pendingTails = /* @__PURE__ */ new Set();
113
+ var MAX_PENDING_TAILS = 8;
114
+ function retainPendingTail(state) {
115
+ pendingTails.add(state);
116
+ if (pendingTails.size <= MAX_PENDING_TAILS) return;
117
+ const oldest = pendingTails.values().next().value;
118
+ if (!oldest) return;
119
+ pendingTails.delete(oldest);
120
+ oldest.abort?.abort();
121
+ oldest.buffer.length = 0;
122
+ }
123
+ function clearFailedClaim(state) {
124
+ if (active === state) active = null;
125
+ if (getActiveReplayId() === state.replayId) setActiveReplayId(null);
126
+ }
127
+ function releaseStaleAutoReplay(generation) {
128
+ if (active?.origin.kind === "auto" && active.origin.generation !== generation) {
129
+ dropActiveReplay();
130
+ }
131
+ }
132
+ function startSessionReplay(options = {}) {
133
+ return startRecording(options, { kind: "manual" });
134
+ }
135
+ function startAutoSessionReplay(options, generation) {
136
+ return startRecording(options, { kind: "auto", generation });
137
+ }
138
+ async function startRecording(options, origin) {
139
+ if (!isReplayCaptureAllowed() || options.shouldProceed?.() === false) return null;
140
+ registerStaleAutoReplayRelease(releaseStaleAutoReplay);
13
141
  if (active) return active.replayId;
14
142
  const dsn = parseDsn(options.dsn);
15
143
  if (!dsn) return null;
16
144
  const replayId = eventId().replace(/-/g, "");
17
- const state = { replayId, stop: null, buffer: [], timer: null };
145
+ const state = {
146
+ replayId,
147
+ origin,
148
+ stop: null,
149
+ rawStop: null,
150
+ abort: new AbortController(),
151
+ pendingSends: 0,
152
+ buffer: [],
153
+ timer: null
154
+ };
18
155
  active = state;
156
+ registerReplayStop(revokeActiveReplay);
19
157
  let record = null;
20
158
  if (options.recorderFactory) {
21
159
  let recorder;
22
160
  try {
23
161
  recorder = await options.recorderFactory();
24
162
  } catch {
25
- active = null;
163
+ clearFailedClaim(state);
26
164
  return null;
27
165
  }
28
166
  if (!recorder) {
29
- active = null;
167
+ clearFailedClaim(state);
30
168
  return null;
31
169
  }
32
170
  record = (_opts) => {
@@ -38,15 +176,19 @@ async function startSessionReplay(options = {}) {
38
176
  const mod = await import("rrweb");
39
177
  const fn = mod.record;
40
178
  if (typeof fn !== "function") {
41
- active = null;
179
+ clearFailedClaim(state);
42
180
  return null;
43
181
  }
44
182
  record = fn;
45
183
  } catch {
46
- active = null;
184
+ clearFailedClaim(state);
47
185
  return null;
48
186
  }
49
187
  }
188
+ if (active !== state || !isReplayCaptureAllowed() || options.shouldProceed?.() === false) {
189
+ clearFailedClaim(state);
190
+ return null;
191
+ }
50
192
  const segmentIntervalMs = options.segmentIntervalMs ?? 5e3;
51
193
  const maxEventsPerSegment = options.maxEventsPerSegment ?? 100;
52
194
  const narrowedDsn = dsn;
@@ -55,21 +197,36 @@ async function startSessionReplay(options = {}) {
55
197
  clearTimeout(state.timer);
56
198
  state.timer = null;
57
199
  }
200
+ if (state.abort?.signal.aborted || options.shouldProceed?.() === false) {
201
+ state.buffer.length = 0;
202
+ return;
203
+ }
58
204
  if (state.buffer.length === 0) return;
59
205
  const events = state.buffer;
60
206
  state.buffer = [];
61
- await emitSegment(
62
- narrowedDsn,
63
- options,
64
- state,
65
- events,
66
- ++segmentCounter,
67
- (/* @__PURE__ */ new Date()).toISOString()
68
- );
207
+ state.pendingSends++;
208
+ try {
209
+ await emitSegment(
210
+ narrowedDsn,
211
+ options,
212
+ state,
213
+ events,
214
+ ++segmentCounter,
215
+ (/* @__PURE__ */ new Date()).toISOString()
216
+ );
217
+ } finally {
218
+ state.pendingSends--;
219
+ if (state.pendingSends === 0 && active !== state) pendingTails.delete(state);
220
+ }
69
221
  }
222
+ const effectiveRecordOptions = resolveRecordPrivacy(options.recordOptions, options.privacy);
70
223
  const stop = record({
224
+ ...effectiveRecordOptions,
71
225
  emit: (event) => {
72
- state.buffer.push(event);
226
+ if (active !== state || !isReplayCaptureAllowed() || options.shouldProceed?.() === false) {
227
+ return;
228
+ }
229
+ state.buffer.push(sanitizeRecordedEvent(event));
73
230
  if (state.buffer.length >= maxEventsPerSegment) {
74
231
  void flushBuffer();
75
232
  return;
@@ -80,9 +237,16 @@ async function startSessionReplay(options = {}) {
80
237
  void flushBuffer();
81
238
  }, segmentIntervalMs);
82
239
  }
83
- },
84
- ...options.recordOptions
240
+ }
85
241
  });
242
+ if (active !== state || !isReplayCaptureAllowed() || options.shouldProceed?.() === false) {
243
+ stop?.();
244
+ state.abort?.abort();
245
+ state.buffer.length = 0;
246
+ clearFailedClaim(state);
247
+ return null;
248
+ }
249
+ state.rawStop = typeof stop === "function" ? stop : null;
86
250
  state.stop = typeof stop === "function" ? () => {
87
251
  stop();
88
252
  void flushBuffer();
@@ -103,19 +267,48 @@ function stopSessionReplay() {
103
267
  const stopping = active;
104
268
  active = null;
105
269
  if (!stopping) return;
270
+ retainPendingTail(stopping);
106
271
  stopping.stop?.();
107
272
  if (stopping.timer !== null) {
108
273
  clearTimeout(stopping.timer);
109
274
  stopping.timer = null;
110
275
  }
111
276
  stopping.buffer.length = 0;
277
+ stopping.rawStop = null;
278
+ if (stopping.pendingSends === 0) pendingTails.delete(stopping);
279
+ setActiveReplayId(null);
280
+ }
281
+ function dropActiveReplay() {
282
+ const stopping = active;
283
+ active = null;
284
+ if (!stopping) return;
285
+ try {
286
+ stopping.rawStop?.();
287
+ } catch {
288
+ }
289
+ if (stopping.timer !== null) {
290
+ clearTimeout(stopping.timer);
291
+ stopping.timer = null;
292
+ }
293
+ stopping.abort?.abort();
294
+ stopping.buffer.length = 0;
295
+ setActiveReplayId(null);
296
+ }
297
+ function revokeActiveReplay() {
298
+ dropActiveReplay();
299
+ for (const tail of pendingTails) {
300
+ tail.abort?.abort();
301
+ tail.buffer.length = 0;
302
+ }
303
+ pendingTails.clear();
304
+ setActiveReplayId(null);
112
305
  }
113
306
  function activeReplayId() {
114
307
  return active?.replayId ?? null;
115
308
  }
116
309
  function resetReplayForTest() {
117
- active?.stop?.();
118
- active = null;
310
+ revokeActiveReplay();
311
+ resetCaptureGateForTest();
119
312
  segmentCounter = 0;
120
313
  }
121
314
  function resolveSessionId(options) {
@@ -159,7 +352,8 @@ ${JSON.stringify(event ?? [])}`;
159
352
  url: dsn.envelopeUrl,
160
353
  authHeader: `Sentry sentry_key=${dsn.key}, sentry_version=7, sentry_client=onus.javascript/0.1.0`,
161
354
  body: serialized,
162
- fetchImpl: options.fetchImpl
355
+ fetchImpl: options.fetchImpl,
356
+ signal: state.abort?.signal
163
357
  });
164
358
  return result.ok;
165
359
  }
@@ -169,6 +363,10 @@ export {
169
363
  emitSegment,
170
364
  parseDsn,
171
365
  resetReplayForTest,
366
+ revokeActiveReplay,
367
+ sanitizeRecordedEvent,
368
+ startAutoSessionReplay,
172
369
  startSessionReplay,
173
- stopSessionReplay
370
+ stopSessionReplay,
371
+ stripUrlQueryAndFragment
174
372
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunyee-studio/onus-sdk",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "Official Onus browser SDK: errors, session replay, and custom events via the Sentry-compatible envelope protocol",
5
5
  "license": "MIT",
6
6
  "author": "Brunyee Studio",