@anyreplay/browser 0.2.3 → 0.3.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 CHANGED
@@ -4,7 +4,7 @@ Session replay recorder for the browser, made by [AnyReplay](https://anyreplay.c
4
4
  watch any user's session, translated into your own language.
5
5
 
6
6
  - DOM-based recording (built on rrweb), not video — about 35 KB gzipped
7
- - Records what people type; one line masks it, and passwords and payment fields never are
7
+ - Form fields are masked by default; one line records them, 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
10
  - Deterministic sampling per visitor, retries that wait out an outage instead of giving up, and an optional consent gate
@@ -22,7 +22,7 @@ const recorder = createRecorder({
22
22
  projectKey: 'ar_pk_live_…', // from your AnyReplay project
23
23
  // ingestUrl: 'https://in.anyreplay.com', // default; set for self-hosted
24
24
  // sampleRate: 1, // 0..1, decided per visitor
25
- // maskAllInputs: true, // off by default: field values are recorded
25
+ // maskAllInputs: false, // on by default; false records what people type into fields
26
26
  // maskAllTyping: true, // the stricter switch: fields and rich-text editors
27
27
  // maxSessionsPerMonth: 5000, // optional cap for this project
28
28
  // appId: 'com.example.shop', // only inside an app shell; see below
@@ -66,12 +66,15 @@ from its setup page; it loads the same code from `cdn.anyreplay.com`.
66
66
 
67
67
  ## Privacy
68
68
 
69
- A recording contains what visitors type into forms, because a replay of a form
70
- nobody could finish is worth watching only if you can see what they were trying
71
- to enter. Masking is a switch you turn on:
69
+ By default a recording does not contain what visitors type into forms: every
70
+ form field value becomes asterisks of the same length, so the replay shows the
71
+ field being filled in without showing what went into it. To see what people
72
+ typed — a form nobody could finish is easier to understand when you can read
73
+ it — opt out:
72
74
 
73
- - `maskAllInputs: true` — every form field value becomes asterisks of the same length.
74
- - `maskAllTyping: true` — the same, plus the text of every `contenteditable` editor.
75
+ - `maskAllInputs: false` — form field values are recorded (the fields below stay masked).
76
+ - `maskAllInputs` left out — the project's masking switch in the dashboard decides. The page records masked until ingest answers the first chunk with that choice, and stays masked if no answer comes.
77
+ - `maskAllTyping: true` — the stricter switch: form fields plus the text of every `contenteditable` editor, whatever `maskAllInputs` says.
75
78
  - `class="ar-mask"` — one element, or one field, masked while the rest records.
76
79
  - `class="ar-block"` — the element's contents are not recorded at all.
77
80
 
@@ -88,8 +91,9 @@ recorded. Masking always happens in the page, before
88
91
  anything is sent.
89
92
 
90
93
  `type="email"` and `type="tel"` are not on that list. An address and a phone
91
- number are ordinary contact details, so they are recorded like any other field
92
- and masked by `maskAllInputs`, `maskAllTyping` or `class="ar-mask"`.
94
+ number are ordinary contact details, so they follow `maskAllInputs` like any
95
+ other field: masked by default, recorded with `maskAllInputs: false` unless
96
+ `maskAllTyping` or `class="ar-mask"` covers them.
93
97
 
94
98
  ## License
95
99
 
package/dist/index.cjs CHANGED
@@ -4009,11 +4009,12 @@ function sha256HexSync(bytes) {
4009
4009
  var DEFAULTS = {
4010
4010
  ingestUrl: "https://in.anyreplay.com",
4011
4011
  sampleRate: 1,
4012
- // Both masking switches are opt-in: a recording shows what people typed,
4013
- // which is the point of watching one. What that never includes is the floor
4014
- // in masking.ts — passwords, payment fields and the rest — which no option
4015
- // can switch off.
4016
- maskAllInputs: false,
4012
+ // Form fields are masked unless the page opts out with `maskAllInputs:
4013
+ // false`. Editable regions are opt-in, because masking one hides all of its
4014
+ // text, not only what was typed. Neither option reaches the floor in
4015
+ // masking.ts — passwords, payment fields and the rest — which no option can
4016
+ // switch off.
4017
+ maskAllInputs: true,
4017
4018
  maskAllTyping: false,
4018
4019
  maskClass: "ar-mask",
4019
4020
  blockClass: "ar-block",
@@ -4079,7 +4080,7 @@ function readSwitch(input, name) {
4079
4080
  const value = input[name];
4080
4081
  if (typeof value === "boolean") return value;
4081
4082
  if (value !== void 0 && typeof console !== "undefined") {
4082
- const consequence = name.startsWith("mask") ? "Nothing is masked by this option." : `${name.slice("record".length)} are recorded, as by default.`;
4083
+ const consequence = name === "maskAllInputs" ? "Form fields stay masked, as by default." : name === "maskAllTyping" ? "Nothing more is masked by this option." : `${name.slice("record".length)} are recorded, as by default.`;
4083
4084
  console.warn(`[anyreplay] ${name} ignored: expected true or false, got ${typeof value}. ${consequence}`);
4084
4085
  }
4085
4086
  return DEFAULTS[name];
@@ -4636,17 +4637,20 @@ var BACKOFF_BASE_MS = 2e3;
4636
4637
  var BACKOFF_MAX_MS = 6e4;
4637
4638
  var MAX_RETRY_AFTER_MS = 10 * 6e4;
4638
4639
  var MAX_CONSECUTIVE_REFUSALS = 3;
4639
- async function staleSeqHint(response, sent) {
4640
+ async function readReply(response) {
4640
4641
  try {
4641
4642
  if (typeof response.json !== "function") return void 0;
4642
4643
  const reply = await response.json();
4643
- if (reply.duplicate !== true) return void 0;
4644
- const next = reply.nextSeq;
4645
- return typeof next === "number" && Number.isInteger(next) && next > sent ? next : void 0;
4644
+ return reply && typeof reply === "object" ? reply : void 0;
4646
4645
  } catch (e) {
4647
4646
  return void 0;
4648
4647
  }
4649
4648
  }
4649
+ function staleSeqHint(reply, sent) {
4650
+ if ((reply == null ? void 0 : reply.duplicate) !== true) return void 0;
4651
+ const next = reply.nextSeq;
4652
+ return typeof next === "number" && Number.isInteger(next) && next > sent ? next : void 0;
4653
+ }
4650
4654
  function retryAfterMs(response, now = Date.now()) {
4651
4655
  var _a, _b;
4652
4656
  let raw;
@@ -4898,7 +4902,7 @@ var Transport = class {
4898
4902
  }
4899
4903
  /** One request, and what to do next. */
4900
4904
  async send(chunk) {
4901
- var _a, _b, _c, _d, _e, _f;
4905
+ var _a, _b, _c, _d, _e, _f, _g, _h;
4902
4906
  const retry = chunk.attempts > 0;
4903
4907
  chunk.attempts += 1;
4904
4908
  (_b = (_a = this.hooks).onSeqReserved) == null ? void 0 : _b.call(_a, chunk.seq + 1);
@@ -4919,7 +4923,14 @@ var Transport = class {
4919
4923
  }
4920
4924
  if (this.state === "stopped" || this.pending !== chunk) return "stopped";
4921
4925
  if (response.ok) {
4922
- const nextSeq = retry ? void 0 : await staleSeqHint(response, chunk.seq);
4926
+ const reply = await readReply(response);
4927
+ if (typeof (reply == null ? void 0 : reply.maskAllInputs) === "boolean") {
4928
+ try {
4929
+ (_d = (_c = this.hooks).onProjectMasking) == null ? void 0 : _d.call(_c, reply.maskAllInputs);
4930
+ } catch (e) {
4931
+ }
4932
+ }
4933
+ const nextSeq = retry ? void 0 : staleSeqHint(reply, chunk.seq);
4923
4934
  if (nextSeq !== void 0 && this.staleRecoveries < MAX_STALE_RECOVERIES) {
4924
4935
  this.staleRecoveries += 1;
4925
4936
  chunk.seq = nextSeq;
@@ -4933,7 +4944,7 @@ var Transport = class {
4933
4944
  this.consecutiveFailures = 0;
4934
4945
  this.consecutiveRefusals = 0;
4935
4946
  this.backoffUntil = 0;
4936
- (_d = (_c = this.hooks).onSent) == null ? void 0 : _d.call(_c, chunk.seq, chunk.events.length);
4947
+ (_f = (_e = this.hooks).onSent) == null ? void 0 : _f.call(_e, chunk.seq, chunk.events.length);
4937
4948
  return "sent";
4938
4949
  }
4939
4950
  const status = response.status;
@@ -4945,7 +4956,7 @@ var Transport = class {
4945
4956
  this.stop(`rejected_${status}`);
4946
4957
  return "stopped";
4947
4958
  }
4948
- (_f = (_e = this.hooks).onWarning) == null ? void 0 : _f.call(_e, `ingest refused a chunk (${status}); its ${chunk.events.length} events are dropped`);
4959
+ (_h = (_g = this.hooks).onWarning) == null ? void 0 : _h.call(_g, `ingest refused a chunk (${status}); its ${chunk.events.length} events are dropped`);
4949
4960
  this.afterGap();
4950
4961
  return "again";
4951
4962
  }
@@ -5120,7 +5131,7 @@ var Transport = class {
5120
5131
 
5121
5132
  // src/version.ts
5122
5133
  var SDK_NAME = "@anyreplay/browser";
5123
- var SDK_VERSION = true ? "0.2.3" : "0.0.0-dev";
5134
+ var SDK_VERSION = true ? "0.3.0" : "0.0.0-dev";
5124
5135
  var PLATFORM = "web";
5125
5136
 
5126
5137
  // src/visitor.ts
@@ -5229,6 +5240,7 @@ function forgetVisitor(local, session) {
5229
5240
  // src/recorder.ts
5230
5241
  var RRWEB_DOM_CONTENT_LOADED = 0;
5231
5242
  var RRWEB_LOAD = 1;
5243
+ var EVENT_FULL_SNAPSHOT3 = 2;
5232
5244
  var RAGE_CLICK_COUNT = 3;
5233
5245
  var RAGE_CLICK_WINDOW_MS = 1e3;
5234
5246
  var RAGE_CLICK_RADIUS_PX = 30;
@@ -5236,6 +5248,7 @@ var MAX_PENDING_EVENTS = 32;
5236
5248
  var EDITABLE_SELECTOR = '[contenteditable]:not([contenteditable="false"])';
5237
5249
  function createRecorder(rawOptions, shell) {
5238
5250
  const options = resolveOptions(rawOptions);
5251
+ let awaitingProjectMasking = typeof rawOptions.maskAllInputs !== "boolean" && !options.maskAllTyping;
5239
5252
  const local = safeStorage(() => globalThis.localStorage);
5240
5253
  const session = safeStorage(() => globalThis.sessionStorage);
5241
5254
  let status = "idle";
@@ -5245,6 +5258,8 @@ function createRecorder(rawOptions, shell) {
5245
5258
  let diagnostics;
5246
5259
  let assets;
5247
5260
  let pageCount = 0;
5261
+ let applyProjectMasking = () => {
5262
+ };
5248
5263
  let pendingTraits;
5249
5264
  let traitsAwaitingSession;
5250
5265
  let traitsToRetry;
@@ -5418,6 +5433,9 @@ function createRecorder(rawOptions, shell) {
5418
5433
  onWarning: (message) => {
5419
5434
  if (options.debug) console.warn(`[anyreplay] ${message}`);
5420
5435
  },
5436
+ // Out of the transport's own call stack, like a resync: restarting
5437
+ // rrweb emits a snapshot into the channel that is answering.
5438
+ onProjectMasking: (masked) => setTimeout(() => applyProjectMasking(channel, masked), 0),
5421
5439
  // Events were dropped (a long outage, or one too large to send), so
5422
5440
  // the DOM changes after the gap describe nodes ingest never got. A
5423
5441
  // fresh full snapshot starts the page over. Taken on the next turn,
@@ -5458,7 +5476,8 @@ function createRecorder(rawOptions, shell) {
5458
5476
  maskEveryField: options.maskAllInputs || options.maskAllTyping,
5459
5477
  maskClass: options.maskClass
5460
5478
  };
5461
- stopRecording = record({
5479
+ let flushedEarly = false;
5480
+ const capture = () => record({
5462
5481
  emit: (event) => {
5463
5482
  const typed = event;
5464
5483
  if (typed.type === RRWEB_DOM_CONTENT_LOADED || typed.type === RRWEB_LOAD) return;
@@ -5468,6 +5487,12 @@ function createRecorder(rawOptions, shell) {
5468
5487
  touchSession(session, typed.timestamp);
5469
5488
  channel.push(typed);
5470
5489
  drainPending();
5490
+ if (awaitingProjectMasking && !flushedEarly && typed.type === EVENT_FULL_SNAPSHOT3) {
5491
+ flushedEarly = true;
5492
+ setTimeout(() => {
5493
+ void channel.flush();
5494
+ }, 0);
5495
+ }
5471
5496
  },
5472
5497
  maskInputOptions: policy.maskEveryField ? MASK_EVERY_FIELD : MASK_SENSITIVE_FIELDS,
5473
5498
  maskTextClass: options.maskClass,
@@ -5480,6 +5505,23 @@ function createRecorder(rawOptions, shell) {
5480
5505
  recordCanvas: false,
5481
5506
  collectFonts: false
5482
5507
  });
5508
+ applyProjectMasking = (from, masked) => {
5509
+ if (!awaitingProjectMasking || from !== transport) return;
5510
+ awaitingProjectMasking = false;
5511
+ if (masked || status !== "recording" || !stopRecording) return;
5512
+ policy.maskEveryField = false;
5513
+ try {
5514
+ stopRecording();
5515
+ stopRecording = capture();
5516
+ if (!stopRecording) throw new Error("rrweb did not start");
5517
+ } catch (e) {
5518
+ stopRecording = void 0;
5519
+ from.stop("restart_failed");
5520
+ return;
5521
+ }
5522
+ if (options.debug) console.warn("[anyreplay] recording typed text: the project chose to (maskAllInputs: false)");
5523
+ };
5524
+ stopRecording = capture();
5483
5525
  drainPending();
5484
5526
  if (pendingTraits) {
5485
5527
  sendIdentity(who, pendingTraits);