@anyreplay/browser 0.2.0 → 0.2.2

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.
@@ -56,7 +58,7 @@ from its setup page; it loads the same code from `cdn.anyreplay.com`.
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
60
  | `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 |
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 |
@@ -77,7 +79,12 @@ Some fields are masked in every mode and no option records them: passwords and
77
79
  one-time codes; card numbers, security codes, expiry dates and cardholder names
78
80
  (from `autocomplete="cc-…"` or from a name, label or placeholder that reads like
79
81
  one); IBANs, routing numbers and sort codes; any value of 13–19 digits that
80
- passes the card-number check. Masking always happens in the page, before
82
+ passes the card-number check — and, because a number is typed one key at a
83
+ time, any value that could still become one: digits only, starting 2–6,
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
81
88
  anything is sent.
82
89
 
83
90
  `type="email"` and `type="tel"` are not on that list. An address and a phone
package/dist/index.cjs CHANGED
@@ -3864,10 +3864,12 @@ var AssetCollector = class {
3864
3864
  body: JSON.stringify(body)
3865
3865
  });
3866
3866
  const known = await post("/v1/ingest/assets/known", { projectKey, hashes: [sha256], ...app });
3867
- if (known.ok) {
3868
- const body = await known.json();
3869
- if (Array.isArray(body.known) && body.known.includes(sha256)) return true;
3867
+ if (!known.ok) {
3868
+ if (known.status === 401 || known.status === 403) this.stop();
3869
+ return false;
3870
3870
  }
3871
+ const answer = await known.json();
3872
+ if (Array.isArray(answer.known) && answer.known.includes(sha256)) return true;
3871
3873
  const upload = await post("/v1/ingest/assets", { projectKey, sha256, data: toBase64(bytes), ...app });
3872
3874
  return upload.ok;
3873
3875
  }
@@ -4172,6 +4174,13 @@ function looksLikeCardNumber(text) {
4172
4174
  }
4173
4175
  return sum % 10 === 0;
4174
4176
  }
4177
+ function mayBecomeCardNumber(text) {
4178
+ if (!/^[2-6][0-9 -]*$/.test(text)) return false;
4179
+ const groups = text.split(/[ -]+/);
4180
+ if (groups.slice(0, -1).some((group) => group.length < 4)) return false;
4181
+ const digits = groups.join("").length;
4182
+ return digits > 6 && digits <= 19;
4183
+ }
4175
4184
  function isMarkedByCustomer(node, maskClass) {
4176
4185
  var _a;
4177
4186
  let element = asElement(node);
@@ -4186,8 +4195,53 @@ function shouldMaskValue(text, node, policy) {
4186
4195
  if (policy.maskEveryField) return true;
4187
4196
  if (isSensitiveField(node)) return true;
4188
4197
  if (isMarkedByCustomer(node, policy.maskClass)) return true;
4189
- return looksLikeCardNumber(text);
4198
+ return cardShaped(text);
4199
+ }
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
+ });
4190
4233
  }
4234
+ var EDITABLE = '[contenteditable]:not([contenteditable="false"])';
4235
+ function insideEditable(node) {
4236
+ var _a;
4237
+ const live = node;
4238
+ const element = (live == null ? void 0 : live.nodeType) === 3 ? (_a = live.parentElement) != null ? _a : null : asElement(live);
4239
+ return typeof (element == null ? void 0 : element.closest) === "function" && element.closest(EDITABLE) !== null;
4240
+ }
4241
+ var isEditableSerialised = (attributes) => attributes.contenteditable !== void 0 && attributes.contenteditable !== "false";
4242
+ var typedCardShaped = (text) => cardShaped(text.replace(/\u00a0/g, " ").trim());
4243
+ var maskText = (text) => text.replace(/\S/g, "*");
4244
+ var maskTyped = (text) => typedCardShaped(text) ? maskText(text) : maskCardRuns(text);
4191
4245
  var MUTATION = 0;
4192
4246
  var INPUT = 5;
4193
4247
  var ELEMENT_NODE = 2;
@@ -4202,23 +4256,36 @@ function maskTextChildren(node) {
4202
4256
  maskTextChildren(child);
4203
4257
  }
4204
4258
  }
4205
- function scrubTree(node, policy, masked) {
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
+ }
4265
+ function scrubTree(node, policy, masked, editable = false) {
4206
4266
  var _a, _b;
4207
4267
  if (!node) return;
4208
4268
  const attributes = (_a = node.attributes) != null ? _a : {};
4209
4269
  const inheritedMask = masked || hasClass(attributes, policy.maskClass);
4270
+ const inheritedEditable = editable || node.type === ELEMENT_NODE && isEditableSerialised(attributes);
4271
+ if (editable && node.type === TEXT_NODE && typeof node.textContent === "string") {
4272
+ node.textContent = maskTyped(node.textContent);
4273
+ }
4210
4274
  if (node.type === ELEMENT_NODE && node.tagName && FIELD_TAGS.has(node.tagName.toLowerCase())) {
4211
4275
  const value = attributes.value;
4212
- const shouldMask = policy.maskEveryField || inheritedMask || isSensitiveSerialised(attributes) || typeof value === "string" && looksLikeCardNumber(value);
4276
+ const shouldMask = policy.maskEveryField || inheritedMask || isSensitiveSerialised(attributes) || typeof value === "string" && cardShaped(value);
4213
4277
  if (shouldMask) {
4214
4278
  if (typeof value === "string" && value !== "") attributes.value = maskValue(value);
4215
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);
4216
4283
  }
4217
4284
  }
4218
- for (const child of (_b = node.childNodes) != null ? _b : []) scrubTree(child, policy, inheritedMask);
4285
+ for (const child of (_b = node.childNodes) != null ? _b : []) scrubTree(child, policy, inheritedMask, inheritedEditable);
4219
4286
  }
4220
4287
  function scrubEvent(event, getNode, policy) {
4221
- var _a, _b, _c, _d, _e;
4288
+ var _a, _b, _c, _d, _e, _f;
4222
4289
  if (!event.data) return;
4223
4290
  if (event.type === 2) {
4224
4291
  scrubTree(event.data.node, policy, false);
@@ -4231,7 +4298,7 @@ function scrubEvent(event, getNode, policy) {
4231
4298
  const node = typeof data.id === "number" ? getNode(data.id) : null;
4232
4299
  const type = ((_b = (_a = asElement(node)) == null ? void 0 : _a.type) != null ? _b : "").toLowerCase();
4233
4300
  if (type === "checkbox" || type === "radio") return;
4234
- 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);
4235
4302
  return;
4236
4303
  }
4237
4304
  if (data.source !== MUTATION) return;
@@ -4239,11 +4306,15 @@ function scrubEvent(event, getNode, policy) {
4239
4306
  const value = (_d = entry == null ? void 0 : entry.attributes) == null ? void 0 : _d.value;
4240
4307
  if (typeof value !== "string" || value === "") continue;
4241
4308
  const node = typeof entry.id === "number" ? getNode(entry.id) : null;
4242
- if (shouldMaskValue(value, node, policy)) entry.attributes.value = maskValue(value);
4309
+ entry.attributes.value = shouldMaskValue(value, node, policy) ? maskValue(value) : maskCardRuns(value);
4243
4310
  }
4244
4311
  for (const add of (_e = data.adds) != null ? _e : []) {
4245
4312
  const parent = typeof (add == null ? void 0 : add.parentId) === "number" ? getNode(add.parentId) : null;
4246
- scrubTree(add == null ? void 0 : add.node, policy, isMarkedByCustomer(parent, policy.maskClass));
4313
+ scrubTree(add == null ? void 0 : add.node, policy, isMarkedByCustomer(parent, policy.maskClass), insideEditable(parent));
4314
+ }
4315
+ for (const change of (_f = data.texts) != null ? _f : []) {
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);
4247
4318
  }
4248
4319
  }
4249
4320
 
@@ -5049,7 +5120,7 @@ var Transport = class {
5049
5120
 
5050
5121
  // src/version.ts
5051
5122
  var SDK_NAME = "@anyreplay/browser";
5052
- var SDK_VERSION = true ? "0.2.0" : "0.0.0-dev";
5123
+ var SDK_VERSION = true ? "0.2.2" : "0.0.0-dev";
5053
5124
  var PLATFORM = "web";
5054
5125
 
5055
5126
  // src/visitor.ts
@@ -5156,6 +5227,8 @@ function forgetVisitor(local, session) {
5156
5227
  }
5157
5228
 
5158
5229
  // src/recorder.ts
5230
+ var RRWEB_DOM_CONTENT_LOADED = 0;
5231
+ var RRWEB_LOAD = 1;
5159
5232
  var RAGE_CLICK_COUNT = 3;
5160
5233
  var RAGE_CLICK_WINDOW_MS = 1e3;
5161
5234
  var RAGE_CLICK_RADIUS_PX = 30;
@@ -5295,7 +5368,14 @@ function createRecorder(rawOptions, shell) {
5295
5368
  initialSeq: who.nextSeq
5296
5369
  },
5297
5370
  {
5298
- onSeqReserved: (next) => reserveSeq(session, next),
5371
+ onSeqReserved: (next) => {
5372
+ var _a;
5373
+ reserveSeq(session, next);
5374
+ try {
5375
+ (_a = shell == null ? void 0 : shell.onStateChanged) == null ? void 0 : _a.call(shell);
5376
+ } catch (e) {
5377
+ }
5378
+ },
5299
5379
  onStopped: (reason) => {
5300
5380
  status = "stopped";
5301
5381
  stopRecording == null ? void 0 : stopRecording();
@@ -5353,6 +5433,7 @@ function createRecorder(rawOptions, shell) {
5353
5433
  stopRecording = record({
5354
5434
  emit: (event) => {
5355
5435
  const typed = event;
5436
+ if (typed.type === RRWEB_DOM_CONTENT_LOADED || typed.type === RRWEB_LOAD) return;
5356
5437
  scrubEvent(typed, (id) => record.mirror.getNode(id), policy);
5357
5438
  assets == null ? void 0 : assets.scan(typed);
5358
5439
  noteInteraction(typed, channel);