@anyreplay/browser 0.2.2 → 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/dist/index.d.cts CHANGED
@@ -50,6 +50,12 @@ interface TransportHooks {
50
50
  onResyncNeeded?: () => void;
51
51
  /** Debug-only explanations: an event dropped, a body limit lowered. */
52
52
  onWarning?: (message: string) => void;
53
+ /**
54
+ * What the project stored for masking typed text, from an accepted chunk's
55
+ * answer (`maskAllInputs` in the 202). Called on every answer that carries
56
+ * it; an ingest from before this field never calls it.
57
+ */
58
+ onProjectMasking?: (maskAllInputs: boolean) => void;
53
59
  }
54
60
  /**
55
61
  * Ships buffered events to ingest.
@@ -340,19 +346,25 @@ interface AnyReplayOptions {
340
346
  /**
341
347
  * Mask the value of every form control.
342
348
  *
343
- * Off by default: a recording shows what a visitor typed into inputs,
344
- * textareas and rich-text editors, because a replay of a form nobody could
345
- * finish is worth watching only if you can see what they were trying to put
346
- * in it. Turn this on for a site whose forms carry anything you would not
347
- * want a teammate to read, and mark individual fields with the `maskClass`
348
- * when it is only some of them.
349
+ * On by default: what a visitor types into an input or a textarea reaches a
350
+ * recording as asterisks, so a replay shows a field being filled in without
351
+ * showing what went into it. Set `maskAllInputs: false` to record typed
352
+ * values instead — for a site whose forms are worth reading in a replay —
353
+ * and mark the fields that should stay hidden with the `maskClass`.
354
+ *
355
+ * Some fields are masked whatever this says, in every mode, `false`
356
+ * included: passwords, one-time codes, payment fields (card number, CVV,
357
+ * expiry, IBAN, routing number, sort code), anything inside an element with
358
+ * the mask class, and any value that looks like a card number. Email and
359
+ * telephone fields are not on that list — they are ordinary fields, masked
360
+ * by default and recorded when this is `false`. See `isSensitiveField` in
361
+ * `masking.ts` for the exact list.
349
362
  *
350
- * Some fields are masked whatever this says, in every mode: passwords,
351
- * one-time codes, payment fields (card number, CVV, expiry, IBAN, routing
352
- * number, sort code), anything inside an element with the mask class, and any
353
- * value that looks like a card number. Email and telephone fields are not on
354
- * that list — they are ordinary fields, recorded by default and masked by this
355
- * option. See `isSensitiveField` in `masking.ts` for the exact list.
363
+ * Left out, the project decides: the page records masked until ingest
364
+ * answers the first chunk with the project's stored choice (the setup
365
+ * page's switch), and records typed values from then on if the project
366
+ * chose to. Set either value here and the project's choice is not asked
367
+ * for. See `applyProjectMasking` in `recorder.ts` and contract §6.2.
356
368
  */
357
369
  maskAllInputs?: boolean;
358
370
  /**
package/dist/index.d.ts CHANGED
@@ -50,6 +50,12 @@ interface TransportHooks {
50
50
  onResyncNeeded?: () => void;
51
51
  /** Debug-only explanations: an event dropped, a body limit lowered. */
52
52
  onWarning?: (message: string) => void;
53
+ /**
54
+ * What the project stored for masking typed text, from an accepted chunk's
55
+ * answer (`maskAllInputs` in the 202). Called on every answer that carries
56
+ * it; an ingest from before this field never calls it.
57
+ */
58
+ onProjectMasking?: (maskAllInputs: boolean) => void;
53
59
  }
54
60
  /**
55
61
  * Ships buffered events to ingest.
@@ -340,19 +346,25 @@ interface AnyReplayOptions {
340
346
  /**
341
347
  * Mask the value of every form control.
342
348
  *
343
- * Off by default: a recording shows what a visitor typed into inputs,
344
- * textareas and rich-text editors, because a replay of a form nobody could
345
- * finish is worth watching only if you can see what they were trying to put
346
- * in it. Turn this on for a site whose forms carry anything you would not
347
- * want a teammate to read, and mark individual fields with the `maskClass`
348
- * when it is only some of them.
349
+ * On by default: what a visitor types into an input or a textarea reaches a
350
+ * recording as asterisks, so a replay shows a field being filled in without
351
+ * showing what went into it. Set `maskAllInputs: false` to record typed
352
+ * values instead — for a site whose forms are worth reading in a replay —
353
+ * and mark the fields that should stay hidden with the `maskClass`.
354
+ *
355
+ * Some fields are masked whatever this says, in every mode, `false`
356
+ * included: passwords, one-time codes, payment fields (card number, CVV,
357
+ * expiry, IBAN, routing number, sort code), anything inside an element with
358
+ * the mask class, and any value that looks like a card number. Email and
359
+ * telephone fields are not on that list — they are ordinary fields, masked
360
+ * by default and recorded when this is `false`. See `isSensitiveField` in
361
+ * `masking.ts` for the exact list.
349
362
  *
350
- * Some fields are masked whatever this says, in every mode: passwords,
351
- * one-time codes, payment fields (card number, CVV, expiry, IBAN, routing
352
- * number, sort code), anything inside an element with the mask class, and any
353
- * value that looks like a card number. Email and telephone fields are not on
354
- * that list — they are ordinary fields, recorded by default and masked by this
355
- * option. See `isSensitiveField` in `masking.ts` for the exact list.
363
+ * Left out, the project decides: the page records masked until ingest
364
+ * answers the first chunk with the project's stored choice (the setup
365
+ * page's switch), and records typed values from then on if the project
366
+ * chose to. Set either value here and the project's choice is not asked
367
+ * for. See `applyProjectMasking` in `recorder.ts` and contract §6.2.
356
368
  */
357
369
  maskAllInputs?: boolean;
358
370
  /**
package/dist/index.js CHANGED
@@ -3960,11 +3960,12 @@ function sha256HexSync(bytes) {
3960
3960
  var DEFAULTS = {
3961
3961
  ingestUrl: "https://in.anyreplay.com",
3962
3962
  sampleRate: 1,
3963
- // Both masking switches are opt-in: a recording shows what people typed,
3964
- // which is the point of watching one. What that never includes is the floor
3965
- // in masking.ts — passwords, payment fields and the rest — which no option
3966
- // can switch off.
3967
- maskAllInputs: false,
3963
+ // Form fields are masked unless the page opts out with `maskAllInputs:
3964
+ // false`. Editable regions are opt-in, because masking one hides all of its
3965
+ // text, not only what was typed. Neither option reaches the floor in
3966
+ // masking.ts — passwords, payment fields and the rest — which no option can
3967
+ // switch off.
3968
+ maskAllInputs: true,
3968
3969
  maskAllTyping: false,
3969
3970
  maskClass: "ar-mask",
3970
3971
  blockClass: "ar-block",
@@ -4030,7 +4031,7 @@ function readSwitch(input, name) {
4030
4031
  const value = input[name];
4031
4032
  if (typeof value === "boolean") return value;
4032
4033
  if (value !== void 0 && typeof console !== "undefined") {
4033
- const consequence = name.startsWith("mask") ? "Nothing is masked by this option." : `${name.slice("record".length)} are recorded, as by default.`;
4034
+ 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.`;
4034
4035
  console.warn(`[anyreplay] ${name} ignored: expected true or false, got ${typeof value}. ${consequence}`);
4035
4036
  }
4036
4037
  return DEFAULTS[name];
@@ -4587,17 +4588,20 @@ var BACKOFF_BASE_MS = 2e3;
4587
4588
  var BACKOFF_MAX_MS = 6e4;
4588
4589
  var MAX_RETRY_AFTER_MS = 10 * 6e4;
4589
4590
  var MAX_CONSECUTIVE_REFUSALS = 3;
4590
- async function staleSeqHint(response, sent) {
4591
+ async function readReply(response) {
4591
4592
  try {
4592
4593
  if (typeof response.json !== "function") return void 0;
4593
4594
  const reply = await response.json();
4594
- if (reply.duplicate !== true) return void 0;
4595
- const next = reply.nextSeq;
4596
- return typeof next === "number" && Number.isInteger(next) && next > sent ? next : void 0;
4595
+ return reply && typeof reply === "object" ? reply : void 0;
4597
4596
  } catch (e) {
4598
4597
  return void 0;
4599
4598
  }
4600
4599
  }
4600
+ function staleSeqHint(reply, sent) {
4601
+ if ((reply == null ? void 0 : reply.duplicate) !== true) return void 0;
4602
+ const next = reply.nextSeq;
4603
+ return typeof next === "number" && Number.isInteger(next) && next > sent ? next : void 0;
4604
+ }
4601
4605
  function retryAfterMs(response, now = Date.now()) {
4602
4606
  var _a, _b;
4603
4607
  let raw;
@@ -4849,7 +4853,7 @@ var Transport = class {
4849
4853
  }
4850
4854
  /** One request, and what to do next. */
4851
4855
  async send(chunk) {
4852
- var _a, _b, _c, _d, _e, _f;
4856
+ var _a, _b, _c, _d, _e, _f, _g, _h;
4853
4857
  const retry = chunk.attempts > 0;
4854
4858
  chunk.attempts += 1;
4855
4859
  (_b = (_a = this.hooks).onSeqReserved) == null ? void 0 : _b.call(_a, chunk.seq + 1);
@@ -4870,7 +4874,14 @@ var Transport = class {
4870
4874
  }
4871
4875
  if (this.state === "stopped" || this.pending !== chunk) return "stopped";
4872
4876
  if (response.ok) {
4873
- const nextSeq = retry ? void 0 : await staleSeqHint(response, chunk.seq);
4877
+ const reply = await readReply(response);
4878
+ if (typeof (reply == null ? void 0 : reply.maskAllInputs) === "boolean") {
4879
+ try {
4880
+ (_d = (_c = this.hooks).onProjectMasking) == null ? void 0 : _d.call(_c, reply.maskAllInputs);
4881
+ } catch (e) {
4882
+ }
4883
+ }
4884
+ const nextSeq = retry ? void 0 : staleSeqHint(reply, chunk.seq);
4874
4885
  if (nextSeq !== void 0 && this.staleRecoveries < MAX_STALE_RECOVERIES) {
4875
4886
  this.staleRecoveries += 1;
4876
4887
  chunk.seq = nextSeq;
@@ -4884,7 +4895,7 @@ var Transport = class {
4884
4895
  this.consecutiveFailures = 0;
4885
4896
  this.consecutiveRefusals = 0;
4886
4897
  this.backoffUntil = 0;
4887
- (_d = (_c = this.hooks).onSent) == null ? void 0 : _d.call(_c, chunk.seq, chunk.events.length);
4898
+ (_f = (_e = this.hooks).onSent) == null ? void 0 : _f.call(_e, chunk.seq, chunk.events.length);
4888
4899
  return "sent";
4889
4900
  }
4890
4901
  const status = response.status;
@@ -4896,7 +4907,7 @@ var Transport = class {
4896
4907
  this.stop(`rejected_${status}`);
4897
4908
  return "stopped";
4898
4909
  }
4899
- (_f = (_e = this.hooks).onWarning) == null ? void 0 : _f.call(_e, `ingest refused a chunk (${status}); its ${chunk.events.length} events are dropped`);
4910
+ (_h = (_g = this.hooks).onWarning) == null ? void 0 : _h.call(_g, `ingest refused a chunk (${status}); its ${chunk.events.length} events are dropped`);
4900
4911
  this.afterGap();
4901
4912
  return "again";
4902
4913
  }
@@ -5071,7 +5082,7 @@ var Transport = class {
5071
5082
 
5072
5083
  // src/version.ts
5073
5084
  var SDK_NAME = "@anyreplay/browser";
5074
- var SDK_VERSION = true ? "0.2.2" : "0.0.0-dev";
5085
+ var SDK_VERSION = true ? "0.3.0" : "0.0.0-dev";
5075
5086
  var PLATFORM = "web";
5076
5087
 
5077
5088
  // src/visitor.ts
@@ -5180,6 +5191,7 @@ function forgetVisitor(local, session) {
5180
5191
  // src/recorder.ts
5181
5192
  var RRWEB_DOM_CONTENT_LOADED = 0;
5182
5193
  var RRWEB_LOAD = 1;
5194
+ var EVENT_FULL_SNAPSHOT3 = 2;
5183
5195
  var RAGE_CLICK_COUNT = 3;
5184
5196
  var RAGE_CLICK_WINDOW_MS = 1e3;
5185
5197
  var RAGE_CLICK_RADIUS_PX = 30;
@@ -5187,6 +5199,7 @@ var MAX_PENDING_EVENTS = 32;
5187
5199
  var EDITABLE_SELECTOR = '[contenteditable]:not([contenteditable="false"])';
5188
5200
  function createRecorder(rawOptions, shell) {
5189
5201
  const options = resolveOptions(rawOptions);
5202
+ let awaitingProjectMasking = typeof rawOptions.maskAllInputs !== "boolean" && !options.maskAllTyping;
5190
5203
  const local = safeStorage(() => globalThis.localStorage);
5191
5204
  const session = safeStorage(() => globalThis.sessionStorage);
5192
5205
  let status = "idle";
@@ -5196,8 +5209,13 @@ function createRecorder(rawOptions, shell) {
5196
5209
  let diagnostics;
5197
5210
  let assets;
5198
5211
  let pageCount = 0;
5212
+ let applyProjectMasking = () => {
5213
+ };
5199
5214
  let pendingTraits;
5200
- const sendIdentity = (who, traits) => {
5215
+ let traitsAwaitingSession;
5216
+ let traitsToRetry;
5217
+ let sessionDelivered = false;
5218
+ const postIdentity = (who, traits, retryIfUnapplied) => {
5201
5219
  void fetch(`${options.ingestUrl}/v1/ingest/identify`, {
5202
5220
  method: "POST",
5203
5221
  headers: { "Content-Type": "application/json" },
@@ -5208,9 +5226,33 @@ function createRecorder(rawOptions, shell) {
5208
5226
  ...traits,
5209
5227
  ...options.appId ? { appId: options.appId } : {}
5210
5228
  })
5229
+ }).then(async (response) => {
5230
+ if (!retryIfUnapplied || response.status !== 202) return;
5231
+ const reply = await response.json();
5232
+ if (reply.applied === false && identity === who && !traitsAwaitingSession && !traitsToRetry) {
5233
+ traitsToRetry = traits;
5234
+ }
5211
5235
  }).catch(() => {
5212
5236
  });
5213
5237
  };
5238
+ const sendIdentity = (who, traits) => {
5239
+ traitsToRetry = void 0;
5240
+ if (!sessionDelivered) {
5241
+ traitsAwaitingSession = { ...traitsAwaitingSession, ...traits };
5242
+ return;
5243
+ }
5244
+ postIdentity(who, traits, true);
5245
+ };
5246
+ const noteDelivered = (channel) => {
5247
+ if (channel !== transport || !identity) return;
5248
+ sessionDelivered = true;
5249
+ const waiting = traitsAwaitingSession;
5250
+ const retry = traitsToRetry;
5251
+ traitsAwaitingSession = void 0;
5252
+ traitsToRetry = void 0;
5253
+ if (waiting) postIdentity(identity, waiting, true);
5254
+ else if (retry) postIdentity(identity, retry, false);
5255
+ };
5214
5256
  const collectMeta = (who) => {
5215
5257
  var _a, _b;
5216
5258
  return {
@@ -5319,6 +5361,7 @@ function createRecorder(rawOptions, shell) {
5319
5361
  initialSeq: who.nextSeq
5320
5362
  },
5321
5363
  {
5364
+ onSent: () => noteDelivered(channel),
5322
5365
  onSeqReserved: (next) => {
5323
5366
  var _a;
5324
5367
  reserveSeq(session, next);
@@ -5341,6 +5384,9 @@ function createRecorder(rawOptions, shell) {
5341
5384
  onWarning: (message) => {
5342
5385
  if (options.debug) console.warn(`[anyreplay] ${message}`);
5343
5386
  },
5387
+ // Out of the transport's own call stack, like a resync: restarting
5388
+ // rrweb emits a snapshot into the channel that is answering.
5389
+ onProjectMasking: (masked) => setTimeout(() => applyProjectMasking(channel, masked), 0),
5344
5390
  // Events were dropped (a long outage, or one too large to send), so
5345
5391
  // the DOM changes after the gap describe nodes ingest never got. A
5346
5392
  // fresh full snapshot starts the page over. Taken on the next turn,
@@ -5381,7 +5427,8 @@ function createRecorder(rawOptions, shell) {
5381
5427
  maskEveryField: options.maskAllInputs || options.maskAllTyping,
5382
5428
  maskClass: options.maskClass
5383
5429
  };
5384
- stopRecording = record({
5430
+ let flushedEarly = false;
5431
+ const capture = () => record({
5385
5432
  emit: (event) => {
5386
5433
  const typed = event;
5387
5434
  if (typed.type === RRWEB_DOM_CONTENT_LOADED || typed.type === RRWEB_LOAD) return;
@@ -5391,6 +5438,12 @@ function createRecorder(rawOptions, shell) {
5391
5438
  touchSession(session, typed.timestamp);
5392
5439
  channel.push(typed);
5393
5440
  drainPending();
5441
+ if (awaitingProjectMasking && !flushedEarly && typed.type === EVENT_FULL_SNAPSHOT3) {
5442
+ flushedEarly = true;
5443
+ setTimeout(() => {
5444
+ void channel.flush();
5445
+ }, 0);
5446
+ }
5394
5447
  },
5395
5448
  maskInputOptions: policy.maskEveryField ? MASK_EVERY_FIELD : MASK_SENSITIVE_FIELDS,
5396
5449
  maskTextClass: options.maskClass,
@@ -5403,6 +5456,23 @@ function createRecorder(rawOptions, shell) {
5403
5456
  recordCanvas: false,
5404
5457
  collectFonts: false
5405
5458
  });
5459
+ applyProjectMasking = (from, masked) => {
5460
+ if (!awaitingProjectMasking || from !== transport) return;
5461
+ awaitingProjectMasking = false;
5462
+ if (masked || status !== "recording" || !stopRecording) return;
5463
+ policy.maskEveryField = false;
5464
+ try {
5465
+ stopRecording();
5466
+ stopRecording = capture();
5467
+ if (!stopRecording) throw new Error("rrweb did not start");
5468
+ } catch (e) {
5469
+ stopRecording = void 0;
5470
+ from.stop("restart_failed");
5471
+ return;
5472
+ }
5473
+ if (options.debug) console.warn("[anyreplay] recording typed text: the project chose to (maskAllInputs: false)");
5474
+ };
5475
+ stopRecording = capture();
5406
5476
  drainPending();
5407
5477
  if (pendingTraits) {
5408
5478
  sendIdentity(who, pendingTraits);
@@ -5462,6 +5532,8 @@ function createRecorder(rawOptions, shell) {
5462
5532
  identity = void 0;
5463
5533
  }
5464
5534
  pendingTraits = void 0;
5535
+ traitsAwaitingSession = void 0;
5536
+ traitsToRetry = void 0;
5465
5537
  pending.length = 0;
5466
5538
  forgetVisitor(local, session);
5467
5539
  },