@anyreplay/browser 0.1.2 → 0.1.4

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/dist/index.d.cts CHANGED
@@ -5,8 +5,42 @@ interface AnyReplayOptions {
5
5
  ingestUrl?: string;
6
6
  /** Fraction of visitors recorded, 0–1. Decided once per visitor, not per page. */
7
7
  sampleRate?: number;
8
- /** Mask every text input. Leaving this on is the supported configuration. */
8
+ /**
9
+ * Mask the value of every form control.
10
+ *
11
+ * Off by default: a recording shows what a visitor typed into inputs,
12
+ * textareas and rich-text editors, because a replay of a form nobody could
13
+ * finish is worth watching only if you can see what they were trying to put
14
+ * in it. Turn this on for a site whose forms carry anything you would not
15
+ * want a teammate to read, and mark individual fields with the `maskClass`
16
+ * when it is only some of them.
17
+ *
18
+ * Some fields are masked whatever this says, in every mode: passwords,
19
+ * one-time codes, payment fields (card number, CVV, expiry, IBAN, routing
20
+ * number, sort code), anything inside an element with the mask class, and any
21
+ * value that looks like a card number. Email and telephone fields are not on
22
+ * that list — they are ordinary fields, recorded by default and masked by this
23
+ * option. See `isSensitiveField` in `masking.ts` for the exact list.
24
+ */
9
25
  maskAllInputs?: boolean;
26
+ /**
27
+ * Mask everything a person types, wherever they type it.
28
+ *
29
+ * `maskAllInputs` covers form controls. This covers those *and* the other
30
+ * place a person can type — an element with `contenteditable`, which is what
31
+ * a rich-text editor, a comment box or a chat composer is made of. Their text
32
+ * is not an input value but ordinary page text, so input masking does not
33
+ * reach it. With this on, the text of every editable region is replaced with
34
+ * asterisks in the first snapshot and in every change after it.
35
+ *
36
+ * The stricter of the two options, and a superset of it: `maskAllTyping: true`
37
+ * masks form fields too, whatever `maskAllInputs` says. It is separate
38
+ * because masking an editable region masks *all* of its text, including
39
+ * whatever the page put there before anyone typed — a loaded document, a
40
+ * quoted message — which is a bigger thing to ask for than hiding field
41
+ * values.
42
+ */
43
+ maskAllTyping?: boolean;
10
44
  /** Elements with this class have their text masked. */
11
45
  maskClass?: string;
12
46
  /** Elements with this class are not recorded at all. */
package/dist/index.d.ts CHANGED
@@ -5,8 +5,42 @@ interface AnyReplayOptions {
5
5
  ingestUrl?: string;
6
6
  /** Fraction of visitors recorded, 0–1. Decided once per visitor, not per page. */
7
7
  sampleRate?: number;
8
- /** Mask every text input. Leaving this on is the supported configuration. */
8
+ /**
9
+ * Mask the value of every form control.
10
+ *
11
+ * Off by default: a recording shows what a visitor typed into inputs,
12
+ * textareas and rich-text editors, because a replay of a form nobody could
13
+ * finish is worth watching only if you can see what they were trying to put
14
+ * in it. Turn this on for a site whose forms carry anything you would not
15
+ * want a teammate to read, and mark individual fields with the `maskClass`
16
+ * when it is only some of them.
17
+ *
18
+ * Some fields are masked whatever this says, in every mode: passwords,
19
+ * one-time codes, payment fields (card number, CVV, expiry, IBAN, routing
20
+ * number, sort code), anything inside an element with the mask class, and any
21
+ * value that looks like a card number. Email and telephone fields are not on
22
+ * that list — they are ordinary fields, recorded by default and masked by this
23
+ * option. See `isSensitiveField` in `masking.ts` for the exact list.
24
+ */
9
25
  maskAllInputs?: boolean;
26
+ /**
27
+ * Mask everything a person types, wherever they type it.
28
+ *
29
+ * `maskAllInputs` covers form controls. This covers those *and* the other
30
+ * place a person can type — an element with `contenteditable`, which is what
31
+ * a rich-text editor, a comment box or a chat composer is made of. Their text
32
+ * is not an input value but ordinary page text, so input masking does not
33
+ * reach it. With this on, the text of every editable region is replaced with
34
+ * asterisks in the first snapshot and in every change after it.
35
+ *
36
+ * The stricter of the two options, and a superset of it: `maskAllTyping: true`
37
+ * masks form fields too, whatever `maskAllInputs` says. It is separate
38
+ * because masking an editable region masks *all* of its text, including
39
+ * whatever the page put there before anyone typed — a loaded document, a
40
+ * quoted message — which is a bigger thing to ask for than hiding field
41
+ * values.
42
+ */
43
+ maskAllTyping?: boolean;
10
44
  /** Elements with this class have their text masked. */
11
45
  maskClass?: string;
12
46
  /** Elements with this class are not recorded at all. */
package/dist/index.js CHANGED
@@ -3641,10 +3641,12 @@ record.mirror = mirror;
3641
3641
  var DEFAULTS = {
3642
3642
  ingestUrl: "https://in.anyreplay.com",
3643
3643
  sampleRate: 1,
3644
- // Default-deny. Recording a customer's password field once is a breach; the
3645
- // cost of the safe default is a configuration line for the rare field that
3646
- // genuinely needs to be visible.
3647
- maskAllInputs: true,
3644
+ // Both masking switches are opt-in: a recording shows what people typed,
3645
+ // which is the point of watching one. What that never includes is the floor
3646
+ // in masking.ts — passwords, payment fields and the rest — which no option
3647
+ // can switch off.
3648
+ maskAllInputs: false,
3649
+ maskAllTyping: false,
3648
3650
  maskClass: "ar-mask",
3649
3651
  blockClass: "ar-block",
3650
3652
  requireConsent: false,
@@ -3677,11 +3679,18 @@ function resolveOptions(input) {
3677
3679
  projectKey: input.projectKey,
3678
3680
  sampleRate,
3679
3681
  ingestUrl: ((_c = input.ingestUrl) != null ? _c : DEFAULTS.ingestUrl).replace(/\/+$/, ""),
3680
- // A caller who explicitly passes false gets what they asked for; anything
3681
- // else (including undefined) keeps masking on.
3682
- maskAllInputs: input.maskAllInputs === false ? false : true
3682
+ maskAllInputs: readSwitch(input, "maskAllInputs"),
3683
+ maskAllTyping: readSwitch(input, "maskAllTyping")
3683
3684
  };
3684
3685
  }
3686
+ function readSwitch(input, name) {
3687
+ const value = input[name];
3688
+ if (typeof value === "boolean") return value;
3689
+ if (value !== void 0 && typeof console !== "undefined") {
3690
+ console.warn(`[anyreplay] ${name} ignored: expected true or false, got ${typeof value}. Nothing is masked by this option.`);
3691
+ }
3692
+ return DEFAULTS[name];
3693
+ }
3685
3694
 
3686
3695
  // src/events.ts
3687
3696
  var TRACK_TAG = "anyreplay.track";
@@ -3711,6 +3720,170 @@ function byteLength(text) {
3711
3720
  return typeof TextEncoder === "function" ? new TextEncoder().encode(text).length : text.length * 3;
3712
3721
  }
3713
3722
 
3723
+ // src/masking.ts
3724
+ var maskValue = (text) => "*".repeat(text.length);
3725
+ var MASK_EVERY_FIELD = {
3726
+ color: true,
3727
+ date: true,
3728
+ "datetime-local": true,
3729
+ email: true,
3730
+ month: true,
3731
+ number: true,
3732
+ range: true,
3733
+ search: true,
3734
+ tel: true,
3735
+ text: true,
3736
+ time: true,
3737
+ url: true,
3738
+ week: true,
3739
+ textarea: true,
3740
+ select: true,
3741
+ password: true,
3742
+ input: true
3743
+ };
3744
+ var SENSITIVE_AUTOCOMPLETE = /* @__PURE__ */ new Set([
3745
+ "cc-number",
3746
+ "cc-csc",
3747
+ "cc-exp",
3748
+ "cc-exp-month",
3749
+ "cc-exp-year",
3750
+ "cc-name",
3751
+ "cc-type",
3752
+ "cc-given-name",
3753
+ "cc-family-name",
3754
+ "cc-additional-name",
3755
+ "current-password",
3756
+ "new-password",
3757
+ "one-time-code"
3758
+ ]);
3759
+ var PAYMENT_FIELD = new RegExp(
3760
+ "(?:credit|debit|payment)[\\s_-]*card|card[\\s_-]*(?:number|num|no|nr|code|pin|cvv|cvc|csc)|(?:^|[^a-z])cc[\\s_-]*(?:number|num|no|nr|csc|cvv|cvc|exp)|(?:^|[^a-z])(?:cvv|cvc|cvn|csc)(?:[^a-z]|$)|security[\\s_-]*code|(?:^|[^a-z])iban(?:[^a-z]|$)|routing[\\s_-]*number|sort[\\s_-]*code",
3761
+ "i"
3762
+ );
3763
+ var CREDENTIAL_FIELD = /pass(?:word|wd|phrase)|(?:^|[^a-z])pwd(?:[^a-z]|$)|(?:^|[^a-z])otp(?:[^a-z]|$)|one[\s_-]*time[\s_-]*code/i;
3764
+ var SENSITIVE_TYPES = /* @__PURE__ */ new Set(["password"]);
3765
+ var MASK_SENSITIVE_FIELDS = Object.fromEntries(
3766
+ [...SENSITIVE_TYPES].map((type) => [type, true])
3767
+ );
3768
+ var NAMING_ATTRIBUTES = ["name", "id", "autocomplete", "placeholder", "aria-label", "data-testid"];
3769
+ function isSensitiveByAttributes(read, type) {
3770
+ var _a;
3771
+ if (read("data-rr-is-password") !== null) return true;
3772
+ if (SENSITIVE_TYPES.has(type)) return true;
3773
+ const autocomplete = ((_a = read("autocomplete")) != null ? _a : "").toLowerCase();
3774
+ for (const token of autocomplete.split(/\s+/)) {
3775
+ if (SENSITIVE_AUTOCOMPLETE.has(token)) return true;
3776
+ }
3777
+ for (const attribute of NAMING_ATTRIBUTES) {
3778
+ const value = read(attribute);
3779
+ if (value && (PAYMENT_FIELD.test(value) || CREDENTIAL_FIELD.test(value))) return true;
3780
+ }
3781
+ return false;
3782
+ }
3783
+ var asElement = (node) => node && typeof node.getAttribute === "function" ? node : null;
3784
+ function isSensitiveField(node) {
3785
+ const element = asElement(node);
3786
+ if (!element) return false;
3787
+ const type = (element.type || element.getAttribute("type") || "").toLowerCase();
3788
+ return isSensitiveByAttributes((name) => element.getAttribute(name), type);
3789
+ }
3790
+ function isSensitiveSerialised(attributes) {
3791
+ var _a;
3792
+ const read = (name) => typeof attributes[name] === "string" ? attributes[name] : null;
3793
+ return isSensitiveByAttributes(read, ((_a = read("type")) != null ? _a : "").toLowerCase());
3794
+ }
3795
+ function looksLikeCardNumber(text) {
3796
+ if (text.length < 13 || text.length > 24 || !/^[0-9][0-9 -]*[0-9]$/.test(text)) return false;
3797
+ const digits = text.replace(/[ -]/g, "");
3798
+ if (digits.length < 13 || digits.length > 19) return false;
3799
+ let sum = 0;
3800
+ let double = false;
3801
+ for (let i = digits.length - 1; i >= 0; i -= 1) {
3802
+ let digit = digits.charCodeAt(i) - 48;
3803
+ if (double) {
3804
+ digit *= 2;
3805
+ if (digit > 9) digit -= 9;
3806
+ }
3807
+ sum += digit;
3808
+ double = !double;
3809
+ }
3810
+ return sum % 10 === 0;
3811
+ }
3812
+ function isMarkedByCustomer(node, maskClass) {
3813
+ var _a;
3814
+ let element = asElement(node);
3815
+ while (element) {
3816
+ if ((_a = element.classList) == null ? void 0 : _a.contains(maskClass)) return true;
3817
+ element = element.parentElement;
3818
+ }
3819
+ return false;
3820
+ }
3821
+ var hasClass = (attributes, maskClass) => typeof attributes.class === "string" && attributes.class.split(/\s+/).includes(maskClass);
3822
+ function shouldMaskValue(text, node, policy) {
3823
+ if (policy.maskEveryField) return true;
3824
+ if (isSensitiveField(node)) return true;
3825
+ if (isMarkedByCustomer(node, policy.maskClass)) return true;
3826
+ return looksLikeCardNumber(text);
3827
+ }
3828
+ var MUTATION = 0;
3829
+ var INPUT = 5;
3830
+ var ELEMENT_NODE = 2;
3831
+ var TEXT_NODE = 3;
3832
+ var FIELD_TAGS = /* @__PURE__ */ new Set(["input", "textarea", "select"]);
3833
+ function maskTextChildren(node) {
3834
+ var _a;
3835
+ for (const child of (_a = node.childNodes) != null ? _a : []) {
3836
+ if (child.type === TEXT_NODE && typeof child.textContent === "string") {
3837
+ child.textContent = child.textContent.replace(/\S/g, "*");
3838
+ }
3839
+ maskTextChildren(child);
3840
+ }
3841
+ }
3842
+ function scrubTree(node, policy, masked) {
3843
+ var _a, _b;
3844
+ if (!node) return;
3845
+ const attributes = (_a = node.attributes) != null ? _a : {};
3846
+ const inheritedMask = masked || hasClass(attributes, policy.maskClass);
3847
+ if (node.type === ELEMENT_NODE && node.tagName && FIELD_TAGS.has(node.tagName.toLowerCase())) {
3848
+ const value = attributes.value;
3849
+ const shouldMask = policy.maskEveryField || inheritedMask || isSensitiveSerialised(attributes) || typeof value === "string" && looksLikeCardNumber(value);
3850
+ if (shouldMask) {
3851
+ if (typeof value === "string" && value !== "") attributes.value = maskValue(value);
3852
+ if (node.tagName.toLowerCase() === "textarea") maskTextChildren(node);
3853
+ }
3854
+ }
3855
+ for (const child of (_b = node.childNodes) != null ? _b : []) scrubTree(child, policy, inheritedMask);
3856
+ }
3857
+ function scrubEvent(event, getNode, policy) {
3858
+ var _a, _b, _c, _d, _e;
3859
+ if (!event.data) return;
3860
+ if (event.type === 2) {
3861
+ scrubTree(event.data.node, policy, false);
3862
+ return;
3863
+ }
3864
+ if (event.type !== 3) return;
3865
+ const data = event.data;
3866
+ if (data.source === INPUT) {
3867
+ if (typeof data.text !== "string" || data.text === "") return;
3868
+ const node = typeof data.id === "number" ? getNode(data.id) : null;
3869
+ const type = ((_b = (_a = asElement(node)) == null ? void 0 : _a.type) != null ? _b : "").toLowerCase();
3870
+ if (type === "checkbox" || type === "radio") return;
3871
+ if (shouldMaskValue(data.text, node, policy)) data.text = maskValue(data.text);
3872
+ return;
3873
+ }
3874
+ if (data.source !== MUTATION) return;
3875
+ for (const entry of (_c = data.attributes) != null ? _c : []) {
3876
+ const value = (_d = entry == null ? void 0 : entry.attributes) == null ? void 0 : _d.value;
3877
+ if (typeof value !== "string" || value === "") continue;
3878
+ const node = typeof entry.id === "number" ? getNode(entry.id) : null;
3879
+ if (shouldMaskValue(value, node, policy)) entry.attributes.value = maskValue(value);
3880
+ }
3881
+ for (const add of (_e = data.adds) != null ? _e : []) {
3882
+ const parent = typeof (add == null ? void 0 : add.parentId) === "number" ? getNode(add.parentId) : null;
3883
+ scrubTree(add == null ? void 0 : add.node, policy, isMarkedByCustomer(parent, policy.maskClass));
3884
+ }
3885
+ }
3886
+
3714
3887
  // src/sampling.ts
3715
3888
  function hashToUnitInterval(input) {
3716
3889
  let hash = 2166136261;
@@ -3806,7 +3979,14 @@ var Transport = class {
3806
3979
  seq: this.seq,
3807
3980
  events,
3808
3981
  ...this.pendingMeta ? { meta: this.pendingMeta } : {},
3809
- ...Object.keys(this.flags).length > 0 ? { flags: this.flags } : {}
3982
+ ...Object.keys(this.flags).length > 0 ? { flags: this.flags } : {},
3983
+ // This device's clock as the request leaves, on the same clock as the
3984
+ // event timestamps. Ingest compares it with its own to catch a device
3985
+ // that runs ahead — a test phone three weeks fast would otherwise list
3986
+ // its sessions as starting "in 3 weeks". Stamped here, when the body is
3987
+ // built, so a retry or a queue flushed after a spell offline carries
3988
+ // the time it was actually sent rather than when it was recorded.
3989
+ sentAt: Date.now()
3810
3990
  });
3811
3991
  }
3812
3992
  /** Sends whatever is buffered. Safe to call when there is nothing to send. */
@@ -4017,6 +4197,7 @@ var RAGE_CLICK_COUNT = 3;
4017
4197
  var RAGE_CLICK_WINDOW_MS = 1e3;
4018
4198
  var RAGE_CLICK_RADIUS_PX = 30;
4019
4199
  var MAX_PENDING_EVENTS = 32;
4200
+ var EDITABLE_SELECTOR = '[contenteditable]:not([contenteditable="false"])';
4020
4201
  function createRecorder(rawOptions) {
4021
4202
  const options = resolveOptions(rawOptions);
4022
4203
  const local = safeStorage(() => globalThis.localStorage);
@@ -4052,17 +4233,17 @@ function createRecorder(rawOptions) {
4052
4233
  const noteInteraction = (event, channel) => {
4053
4234
  var _a;
4054
4235
  const data = event.data;
4055
- if (event.type !== 3 || (data == null ? void 0 : data.source) !== 2 || typeof data.x !== "number") return;
4236
+ if (event.type !== 3 || (data == null ? void 0 : data.source) !== 2 || data.type !== 2 || typeof data.x !== "number") return;
4056
4237
  const now = event.timestamp;
4057
4238
  recentClicks.push({ x: data.x, y: (_a = data.y) != null ? _a : 0, t: now });
4058
4239
  while (recentClicks.length > 0 && now - recentClicks[0].t > RAGE_CLICK_WINDOW_MS) recentClicks.shift();
4059
- if (recentClicks.length >= RAGE_CLICK_COUNT) {
4060
- const first = recentClicks[0];
4061
- const clustered = recentClicks.every(
4062
- (c) => Math.abs(c.x - first.x) < RAGE_CLICK_RADIUS_PX && Math.abs(c.y - first.y) < RAGE_CLICK_RADIUS_PX
4063
- );
4064
- if (clustered) channel.mergeFlags({ hasRageClick: true });
4065
- }
4240
+ const near = recentClicks.filter(
4241
+ (c) => {
4242
+ var _a2;
4243
+ return Math.abs(c.x - data.x) < RAGE_CLICK_RADIUS_PX && Math.abs(c.y - ((_a2 = data.y) != null ? _a2 : 0)) < RAGE_CLICK_RADIUS_PX;
4244
+ }
4245
+ );
4246
+ if (near.length >= RAGE_CLICK_COUNT) channel.mergeFlags({ hasRageClick: true });
4066
4247
  };
4067
4248
  const pending = [];
4068
4249
  let draining = false;
@@ -4152,19 +4333,23 @@ function createRecorder(rawOptions) {
4152
4333
  channel.setMeta(collectMeta(who));
4153
4334
  channel.mergeFlags({ pageCount });
4154
4335
  channel.start();
4336
+ const policy = {
4337
+ maskEveryField: options.maskAllInputs || options.maskAllTyping,
4338
+ maskClass: options.maskClass
4339
+ };
4155
4340
  stopRecording = record({
4156
4341
  emit: (event) => {
4157
4342
  const typed = event;
4343
+ scrubEvent(typed, (id) => record.mirror.getNode(id), policy);
4158
4344
  noteInteraction(typed, channel);
4159
4345
  touchSession(session, typed.timestamp);
4160
4346
  channel.push(typed);
4161
4347
  drainPending();
4162
4348
  },
4163
- maskAllInputs: options.maskAllInputs,
4349
+ maskInputOptions: policy.maskEveryField ? MASK_EVERY_FIELD : MASK_SENSITIVE_FIELDS,
4164
4350
  maskTextClass: options.maskClass,
4351
+ maskTextSelector: options.maskAllTyping ? EDITABLE_SELECTOR : void 0,
4165
4352
  blockClass: options.blockClass,
4166
- // Passwords are never recorded, regardless of configuration.
4167
- maskInputOptions: { password: true, email: true, tel: true },
4168
4353
  // Canvas capture is expensive and off unless a customer opts in later.
4169
4354
  recordCanvas: false,
4170
4355
  collectFonts: false