@anyreplay/browser 0.2.1 → 0.2.3

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 CHANGED
@@ -3,11 +3,11 @@
3
3
  Session replay recorder for the browser, made by [AnyReplay](https://anyreplay.com):
4
4
  watch any user's session, translated into your own language.
5
5
 
6
- - DOM-based recording (built on rrweb), not video — about 28 KB gzipped
6
+ - DOM-based recording (built on rrweb), not video — about 35 KB gzipped
7
7
  - Records what people type; one line masks it, and passwords and payment fields never are
8
8
  - Tag moments with `track()` and see them on the player's timeline
9
9
  - Sessions survive reloads and route changes; chunked, retried, and flushed with `sendBeacon` on page close
10
- - Deterministic sampling per visitor, a circuit breaker, and an optional consent gate
10
+ - Deterministic sampling per visitor, retries that wait out an outage instead of giving up, and an optional consent gate
11
11
 
12
12
  ## Install
13
13
 
@@ -25,7 +25,7 @@ const recorder = createRecorder({
25
25
  // maskAllInputs: true, // off by default: field values are recorded
26
26
  // maskAllTyping: true, // the stricter switch: fields and rich-text editors
27
27
  // maxSessionsPerMonth: 5000, // optional cap for this project
28
- // appId: 'com.example.shop', // only inside an app shell (Capacitor, Electron); see below
28
+ // appId: 'com.example.shop', // only inside an app shell; see below
29
29
  });
30
30
 
31
31
  recorder.track('checkout_clicked', { plan: 'growth' });
@@ -34,13 +34,15 @@ recorder.identify({ userId: 'u_42', email: 'ada@example.com' });
34
34
 
35
35
  ### Inside an app
36
36
 
37
- A page running inside a Capacitor, Cordova or Electron app has no real web
38
- origin — its web view reports `capacitor://localhost`, `https://localhost` or
39
- `file://` — so a project that lists allowed domains refuses it. Add the app's
37
+ A page running inside a Capacitor, Cordova, Electron or Tauri app has no real
38
+ web origin — its web view reports `capacitor://localhost`, `https://localhost`
39
+ or `file://` — so a project that lists allowed domains refuses it. Add the app's
40
40
  bundle id or package name under **Allowed apps** in the project's settings,
41
41
  and pass the same value as `appId`. Leave `appId` out on a website: from a
42
42
  real web origin it is ignored, so it cannot open a web project's domain list.
43
- It is a filter, not a secret.
43
+ It is a filter, not a secret. The `@anyreplay/capacitor`, `@anyreplay/cordova`,
44
+ `@anyreplay/electron` and `@anyreplay/tauri` packages (a preview) wrap this SDK
45
+ and read the app id for you.
44
46
 
45
47
  Every session also reports `platform: 'web'` and this package's name and
46
48
  version, so the dashboard can say what recorded it.
@@ -55,8 +57,8 @@ from its setup page; it loads the same code from `cdn.anyreplay.com`.
55
57
  | `createRecorder(options)` | Starts recording (or waits for consent) and returns a handle |
56
58
  | `handle.track(name, properties?)` | Tags this moment; `name` is 1–64 chars of `[A-Za-z0-9_.:-]`, properties a JSON object ≤ 4 KB |
57
59
  | `handle.trackError(error)` | Records an error you caught yourself, redacted, and marks the session as having one |
58
- | `handle.identify({ userId?, email? })` | Attaches your user id to the session |
59
- | `handle.consent(true)` | Starts recording when `requireConsent: true`; `consent(false)` keeps it off, and a later `consent(true)` still starts it |
60
+ | `handle.identify({ userId?, email? })` | Attaches your user id to the session; held until the session's first chunk is accepted, then sent once |
61
+ | `handle.consent(true)` | Starts recording when `requireConsent: true`; `consent(false)` removes what was stored and stops (before recording started, a later `consent(true)` still starts it) |
60
62
  | `handle.status()` | `idle`, `awaiting-consent`, `recording`, `sampled-out` or `stopped` |
61
63
  | `handle.flush()` | Sends what is buffered now; resolves when delivered |
62
64
  | `handle.stop()` | Stops for this page |
@@ -79,8 +81,10 @@ one-time codes; card numbers, security codes, expiry dates and cardholder names
79
81
  one); IBANs, routing numbers and sort codes; any value of 13–19 digits that
80
82
  passes the card-number check — and, because a number is typed one key at a
81
83
  time, any value that could still become one: digits only, starting 2–6,
82
- grouped like a card, more than six digits long. At most the first six digits
83
- of a card number are ever recorded. Masking always happens in the page, before
84
+ grouped like a card, more than six digits long. A card number written inside
85
+ longer text ("my card is 4242 4242 …" in a note) has its digits starred and the
86
+ rest of the text kept. At most the first six digits of a card number are ever
87
+ recorded. Masking always happens in the page, before
84
88
  anything is sent.
85
89
 
86
90
  `type="email"` and `type="tel"` are not on that list. An address and a phone
package/dist/index.cjs CHANGED
@@ -4198,6 +4198,39 @@ function shouldMaskValue(text, node, policy) {
4198
4198
  return cardShaped(text);
4199
4199
  }
4200
4200
  var cardShaped = (text) => looksLikeCardNumber(text) || mayBecomeCardNumber(text);
4201
+ var DIGIT_RUN = /[0-9]+(?:[  -][0-9]+)*/g;
4202
+ function maskCardRuns(text) {
4203
+ if (!/[0-9]/.test(text)) return text;
4204
+ return text.replace(DIGIT_RUN, (run) => {
4205
+ const groups = run.split(/[  -]/);
4206
+ const starts = [];
4207
+ let at = 0;
4208
+ for (const group of groups) {
4209
+ starts.push(at);
4210
+ at += group.length + 1;
4211
+ }
4212
+ const chars2 = run.split("");
4213
+ let i = 0;
4214
+ while (i < groups.length) {
4215
+ let matched = -1;
4216
+ for (let j = groups.length - 1; j >= i; j -= 1) {
4217
+ const end2 = starts[j] + groups[j].length;
4218
+ if (cardShaped(run.slice(starts[i], end2).replace(/ /g, " "))) {
4219
+ matched = j;
4220
+ break;
4221
+ }
4222
+ }
4223
+ if (matched < 0) {
4224
+ i += 1;
4225
+ continue;
4226
+ }
4227
+ const end = starts[matched] + groups[matched].length;
4228
+ for (let k = starts[i]; k < end; k += 1) if (chars2[k] !== " " && chars2[k] !== "\xA0" && chars2[k] !== "-") chars2[k] = "*";
4229
+ i = matched + 1;
4230
+ }
4231
+ return chars2.join("");
4232
+ });
4233
+ }
4201
4234
  var EDITABLE = '[contenteditable]:not([contenteditable="false"])';
4202
4235
  function insideEditable(node) {
4203
4236
  var _a;
@@ -4208,6 +4241,7 @@ function insideEditable(node) {
4208
4241
  var isEditableSerialised = (attributes) => attributes.contenteditable !== void 0 && attributes.contenteditable !== "false";
4209
4242
  var typedCardShaped = (text) => cardShaped(text.replace(/\u00a0/g, " ").trim());
4210
4243
  var maskText = (text) => text.replace(/\S/g, "*");
4244
+ var maskTyped = (text) => typedCardShaped(text) ? maskText(text) : maskCardRuns(text);
4211
4245
  var MUTATION = 0;
4212
4246
  var INPUT = 5;
4213
4247
  var ELEMENT_NODE = 2;
@@ -4222,14 +4256,20 @@ function maskTextChildren(node) {
4222
4256
  maskTextChildren(child);
4223
4257
  }
4224
4258
  }
4259
+ function maskCardRunsInChildren(node) {
4260
+ var _a;
4261
+ for (const child of (_a = node.childNodes) != null ? _a : []) {
4262
+ if (child.type === TEXT_NODE && typeof child.textContent === "string") child.textContent = maskCardRuns(child.textContent);
4263
+ }
4264
+ }
4225
4265
  function scrubTree(node, policy, masked, editable = false) {
4226
4266
  var _a, _b;
4227
4267
  if (!node) return;
4228
4268
  const attributes = (_a = node.attributes) != null ? _a : {};
4229
4269
  const inheritedMask = masked || hasClass(attributes, policy.maskClass);
4230
4270
  const inheritedEditable = editable || node.type === ELEMENT_NODE && isEditableSerialised(attributes);
4231
- if (editable && node.type === TEXT_NODE && typeof node.textContent === "string" && typedCardShaped(node.textContent)) {
4232
- node.textContent = maskText(node.textContent);
4271
+ if (editable && node.type === TEXT_NODE && typeof node.textContent === "string") {
4272
+ node.textContent = maskTyped(node.textContent);
4233
4273
  }
4234
4274
  if (node.type === ELEMENT_NODE && node.tagName && FIELD_TAGS.has(node.tagName.toLowerCase())) {
4235
4275
  const value = attributes.value;
@@ -4237,6 +4277,9 @@ function scrubTree(node, policy, masked, editable = false) {
4237
4277
  if (shouldMask) {
4238
4278
  if (typeof value === "string" && value !== "") attributes.value = maskValue(value);
4239
4279
  if (node.tagName.toLowerCase() === "textarea") maskTextChildren(node);
4280
+ } else {
4281
+ if (typeof value === "string") attributes.value = maskCardRuns(value);
4282
+ if (node.tagName.toLowerCase() === "textarea") maskCardRunsInChildren(node);
4240
4283
  }
4241
4284
  }
4242
4285
  for (const child of (_b = node.childNodes) != null ? _b : []) scrubTree(child, policy, inheritedMask, inheritedEditable);
@@ -4255,7 +4298,7 @@ function scrubEvent(event, getNode, policy) {
4255
4298
  const node = typeof data.id === "number" ? getNode(data.id) : null;
4256
4299
  const type = ((_b = (_a = asElement(node)) == null ? void 0 : _a.type) != null ? _b : "").toLowerCase();
4257
4300
  if (type === "checkbox" || type === "radio") return;
4258
- if (shouldMaskValue(data.text, node, policy)) data.text = maskValue(data.text);
4301
+ data.text = shouldMaskValue(data.text, node, policy) ? maskValue(data.text) : maskCardRuns(data.text);
4259
4302
  return;
4260
4303
  }
4261
4304
  if (data.source !== MUTATION) return;
@@ -4263,15 +4306,15 @@ function scrubEvent(event, getNode, policy) {
4263
4306
  const value = (_d = entry == null ? void 0 : entry.attributes) == null ? void 0 : _d.value;
4264
4307
  if (typeof value !== "string" || value === "") continue;
4265
4308
  const node = typeof entry.id === "number" ? getNode(entry.id) : null;
4266
- if (shouldMaskValue(value, node, policy)) entry.attributes.value = maskValue(value);
4309
+ entry.attributes.value = shouldMaskValue(value, node, policy) ? maskValue(value) : maskCardRuns(value);
4267
4310
  }
4268
4311
  for (const add of (_e = data.adds) != null ? _e : []) {
4269
4312
  const parent = typeof (add == null ? void 0 : add.parentId) === "number" ? getNode(add.parentId) : null;
4270
4313
  scrubTree(add == null ? void 0 : add.node, policy, isMarkedByCustomer(parent, policy.maskClass), insideEditable(parent));
4271
4314
  }
4272
4315
  for (const change of (_f = data.texts) != null ? _f : []) {
4273
- if (typeof (change == null ? void 0 : change.value) !== "string" || !typedCardShaped(change.value)) continue;
4274
- if (typeof change.id === "number" && insideEditable(getNode(change.id))) change.value = maskText(change.value);
4316
+ if (typeof (change == null ? void 0 : change.value) !== "string" || !/[0-9]/.test(change.value)) continue;
4317
+ if (typeof change.id === "number" && insideEditable(getNode(change.id))) change.value = maskTyped(change.value);
4275
4318
  }
4276
4319
  }
4277
4320
 
@@ -5077,7 +5120,7 @@ var Transport = class {
5077
5120
 
5078
5121
  // src/version.ts
5079
5122
  var SDK_NAME = "@anyreplay/browser";
5080
- var SDK_VERSION = true ? "0.2.1" : "0.0.0-dev";
5123
+ var SDK_VERSION = true ? "0.2.3" : "0.0.0-dev";
5081
5124
  var PLATFORM = "web";
5082
5125
 
5083
5126
  // src/visitor.ts
@@ -5203,7 +5246,10 @@ function createRecorder(rawOptions, shell) {
5203
5246
  let assets;
5204
5247
  let pageCount = 0;
5205
5248
  let pendingTraits;
5206
- const sendIdentity = (who, traits) => {
5249
+ let traitsAwaitingSession;
5250
+ let traitsToRetry;
5251
+ let sessionDelivered = false;
5252
+ const postIdentity = (who, traits, retryIfUnapplied) => {
5207
5253
  void fetch(`${options.ingestUrl}/v1/ingest/identify`, {
5208
5254
  method: "POST",
5209
5255
  headers: { "Content-Type": "application/json" },
@@ -5214,9 +5260,33 @@ function createRecorder(rawOptions, shell) {
5214
5260
  ...traits,
5215
5261
  ...options.appId ? { appId: options.appId } : {}
5216
5262
  })
5263
+ }).then(async (response) => {
5264
+ if (!retryIfUnapplied || response.status !== 202) return;
5265
+ const reply = await response.json();
5266
+ if (reply.applied === false && identity === who && !traitsAwaitingSession && !traitsToRetry) {
5267
+ traitsToRetry = traits;
5268
+ }
5217
5269
  }).catch(() => {
5218
5270
  });
5219
5271
  };
5272
+ const sendIdentity = (who, traits) => {
5273
+ traitsToRetry = void 0;
5274
+ if (!sessionDelivered) {
5275
+ traitsAwaitingSession = { ...traitsAwaitingSession, ...traits };
5276
+ return;
5277
+ }
5278
+ postIdentity(who, traits, true);
5279
+ };
5280
+ const noteDelivered = (channel) => {
5281
+ if (channel !== transport || !identity) return;
5282
+ sessionDelivered = true;
5283
+ const waiting = traitsAwaitingSession;
5284
+ const retry = traitsToRetry;
5285
+ traitsAwaitingSession = void 0;
5286
+ traitsToRetry = void 0;
5287
+ if (waiting) postIdentity(identity, waiting, true);
5288
+ else if (retry) postIdentity(identity, retry, false);
5289
+ };
5220
5290
  const collectMeta = (who) => {
5221
5291
  var _a, _b;
5222
5292
  return {
@@ -5325,7 +5395,15 @@ function createRecorder(rawOptions, shell) {
5325
5395
  initialSeq: who.nextSeq
5326
5396
  },
5327
5397
  {
5328
- onSeqReserved: (next) => reserveSeq(session, next),
5398
+ onSent: () => noteDelivered(channel),
5399
+ onSeqReserved: (next) => {
5400
+ var _a;
5401
+ reserveSeq(session, next);
5402
+ try {
5403
+ (_a = shell == null ? void 0 : shell.onStateChanged) == null ? void 0 : _a.call(shell);
5404
+ } catch (e) {
5405
+ }
5406
+ },
5329
5407
  onStopped: (reason) => {
5330
5408
  status = "stopped";
5331
5409
  stopRecording == null ? void 0 : stopRecording();
@@ -5461,6 +5539,8 @@ function createRecorder(rawOptions, shell) {
5461
5539
  identity = void 0;
5462
5540
  }
5463
5541
  pendingTraits = void 0;
5542
+ traitsAwaitingSession = void 0;
5543
+ traitsToRetry = void 0;
5464
5544
  pending.length = 0;
5465
5545
  forgetVisitor(local, session);
5466
5546
  },