@anyreplay/browser 0.1.1 → 0.1.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,8 +3,8 @@
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 26 KB gzipped
7
- - Every input masked by default; opt elements out with `ar-mask` / `ar-block`
6
+ - DOM-based recording (built on rrweb), not video — about 28 KB gzipped
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
10
  - Deterministic sampling per visitor, a circuit breaker, and an optional consent gate
@@ -22,7 +22,8 @@ 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, // default; turn off deliberately
25
+ // maskAllInputs: true, // off by default: field values are recorded
26
+ // maskAllTyping: true, // the stricter switch: fields and rich-text editors
26
27
  // maxSessionsPerMonth: 5000, // optional cap for this project
27
28
  });
28
29
 
@@ -48,9 +49,25 @@ from its setup page; it loads the same code from `cdn.anyreplay.com`.
48
49
 
49
50
  ## Privacy
50
51
 
51
- Inputs are masked before they leave the page; `password` fields are never
52
- recorded at all. Add `class="ar-block"` to remove an element's contents from the
53
- recording entirely, or `class="ar-mask"` to keep layout but hide text.
52
+ A recording contains what visitors type into forms, because a replay of a form
53
+ nobody could finish is worth watching only if you can see what they were trying
54
+ to enter. Masking is a switch you turn on:
55
+
56
+ - `maskAllInputs: true` — every form field value becomes asterisks of the same length.
57
+ - `maskAllTyping: true` — the same, plus the text of every `contenteditable` editor.
58
+ - `class="ar-mask"` — one element, or one field, masked while the rest records.
59
+ - `class="ar-block"` — the element's contents are not recorded at all.
60
+
61
+ Some fields are masked in every mode and no option records them: passwords and
62
+ one-time codes; card numbers, security codes, expiry dates and cardholder names
63
+ (from `autocomplete="cc-…"` or from a name, label or placeholder that reads like
64
+ one); IBANs, routing numbers and sort codes; any value of 13–19 digits that
65
+ passes the card-number check. Masking always happens in the page, before
66
+ anything is sent.
67
+
68
+ `type="email"` and `type="tel"` are not on that list. An address and a phone
69
+ number are ordinary contact details, so they are recorded like any other field
70
+ and masked by `maskAllInputs`, `maskAllTyping` or `class="ar-mask"`.
54
71
 
55
72
  ## License
56
73
 
package/dist/index.cjs CHANGED
@@ -30,6 +30,7 @@ __export(src_exports, {
30
30
  TRACK_TAG: () => TRACK_TAG,
31
31
  Transport: () => Transport,
32
32
  createRecorder: () => createRecorder,
33
+ forgetVisitor: () => forgetVisitor,
33
34
  hashToUnitInterval: () => hashToUnitInterval,
34
35
  isSampledIn: () => isSampledIn,
35
36
  resolveIdentity: () => resolveIdentity,
@@ -3673,10 +3674,12 @@ record.mirror = mirror;
3673
3674
  var DEFAULTS = {
3674
3675
  ingestUrl: "https://in.anyreplay.com",
3675
3676
  sampleRate: 1,
3676
- // Default-deny. Recording a customer's password field once is a breach; the
3677
- // cost of the safe default is a configuration line for the rare field that
3678
- // genuinely needs to be visible.
3679
- maskAllInputs: true,
3677
+ // Both masking switches are opt-in: a recording shows what people typed,
3678
+ // which is the point of watching one. What that never includes is the floor
3679
+ // in masking.ts — passwords, payment fields and the rest — which no option
3680
+ // can switch off.
3681
+ maskAllInputs: false,
3682
+ maskAllTyping: false,
3680
3683
  maskClass: "ar-mask",
3681
3684
  blockClass: "ar-block",
3682
3685
  requireConsent: false,
@@ -3709,11 +3712,18 @@ function resolveOptions(input) {
3709
3712
  projectKey: input.projectKey,
3710
3713
  sampleRate,
3711
3714
  ingestUrl: ((_c = input.ingestUrl) != null ? _c : DEFAULTS.ingestUrl).replace(/\/+$/, ""),
3712
- // A caller who explicitly passes false gets what they asked for; anything
3713
- // else (including undefined) keeps masking on.
3714
- maskAllInputs: input.maskAllInputs === false ? false : true
3715
+ maskAllInputs: readSwitch(input, "maskAllInputs"),
3716
+ maskAllTyping: readSwitch(input, "maskAllTyping")
3715
3717
  };
3716
3718
  }
3719
+ function readSwitch(input, name) {
3720
+ const value = input[name];
3721
+ if (typeof value === "boolean") return value;
3722
+ if (value !== void 0 && typeof console !== "undefined") {
3723
+ console.warn(`[anyreplay] ${name} ignored: expected true or false, got ${typeof value}. Nothing is masked by this option.`);
3724
+ }
3725
+ return DEFAULTS[name];
3726
+ }
3717
3727
 
3718
3728
  // src/events.ts
3719
3729
  var TRACK_TAG = "anyreplay.track";
@@ -3743,6 +3753,170 @@ function byteLength(text) {
3743
3753
  return typeof TextEncoder === "function" ? new TextEncoder().encode(text).length : text.length * 3;
3744
3754
  }
3745
3755
 
3756
+ // src/masking.ts
3757
+ var maskValue = (text) => "*".repeat(text.length);
3758
+ var MASK_EVERY_FIELD = {
3759
+ color: true,
3760
+ date: true,
3761
+ "datetime-local": true,
3762
+ email: true,
3763
+ month: true,
3764
+ number: true,
3765
+ range: true,
3766
+ search: true,
3767
+ tel: true,
3768
+ text: true,
3769
+ time: true,
3770
+ url: true,
3771
+ week: true,
3772
+ textarea: true,
3773
+ select: true,
3774
+ password: true,
3775
+ input: true
3776
+ };
3777
+ var SENSITIVE_AUTOCOMPLETE = /* @__PURE__ */ new Set([
3778
+ "cc-number",
3779
+ "cc-csc",
3780
+ "cc-exp",
3781
+ "cc-exp-month",
3782
+ "cc-exp-year",
3783
+ "cc-name",
3784
+ "cc-type",
3785
+ "cc-given-name",
3786
+ "cc-family-name",
3787
+ "cc-additional-name",
3788
+ "current-password",
3789
+ "new-password",
3790
+ "one-time-code"
3791
+ ]);
3792
+ var PAYMENT_FIELD = new RegExp(
3793
+ "(?: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",
3794
+ "i"
3795
+ );
3796
+ var CREDENTIAL_FIELD = /pass(?:word|wd|phrase)|(?:^|[^a-z])pwd(?:[^a-z]|$)|(?:^|[^a-z])otp(?:[^a-z]|$)|one[\s_-]*time[\s_-]*code/i;
3797
+ var SENSITIVE_TYPES = /* @__PURE__ */ new Set(["password"]);
3798
+ var MASK_SENSITIVE_FIELDS = Object.fromEntries(
3799
+ [...SENSITIVE_TYPES].map((type) => [type, true])
3800
+ );
3801
+ var NAMING_ATTRIBUTES = ["name", "id", "autocomplete", "placeholder", "aria-label", "data-testid"];
3802
+ function isSensitiveByAttributes(read, type) {
3803
+ var _a;
3804
+ if (read("data-rr-is-password") !== null) return true;
3805
+ if (SENSITIVE_TYPES.has(type)) return true;
3806
+ const autocomplete = ((_a = read("autocomplete")) != null ? _a : "").toLowerCase();
3807
+ for (const token of autocomplete.split(/\s+/)) {
3808
+ if (SENSITIVE_AUTOCOMPLETE.has(token)) return true;
3809
+ }
3810
+ for (const attribute of NAMING_ATTRIBUTES) {
3811
+ const value = read(attribute);
3812
+ if (value && (PAYMENT_FIELD.test(value) || CREDENTIAL_FIELD.test(value))) return true;
3813
+ }
3814
+ return false;
3815
+ }
3816
+ var asElement = (node) => node && typeof node.getAttribute === "function" ? node : null;
3817
+ function isSensitiveField(node) {
3818
+ const element = asElement(node);
3819
+ if (!element) return false;
3820
+ const type = (element.type || element.getAttribute("type") || "").toLowerCase();
3821
+ return isSensitiveByAttributes((name) => element.getAttribute(name), type);
3822
+ }
3823
+ function isSensitiveSerialised(attributes) {
3824
+ var _a;
3825
+ const read = (name) => typeof attributes[name] === "string" ? attributes[name] : null;
3826
+ return isSensitiveByAttributes(read, ((_a = read("type")) != null ? _a : "").toLowerCase());
3827
+ }
3828
+ function looksLikeCardNumber(text) {
3829
+ if (text.length < 13 || text.length > 24 || !/^[0-9][0-9 -]*[0-9]$/.test(text)) return false;
3830
+ const digits = text.replace(/[ -]/g, "");
3831
+ if (digits.length < 13 || digits.length > 19) return false;
3832
+ let sum = 0;
3833
+ let double = false;
3834
+ for (let i = digits.length - 1; i >= 0; i -= 1) {
3835
+ let digit = digits.charCodeAt(i) - 48;
3836
+ if (double) {
3837
+ digit *= 2;
3838
+ if (digit > 9) digit -= 9;
3839
+ }
3840
+ sum += digit;
3841
+ double = !double;
3842
+ }
3843
+ return sum % 10 === 0;
3844
+ }
3845
+ function isMarkedByCustomer(node, maskClass) {
3846
+ var _a;
3847
+ let element = asElement(node);
3848
+ while (element) {
3849
+ if ((_a = element.classList) == null ? void 0 : _a.contains(maskClass)) return true;
3850
+ element = element.parentElement;
3851
+ }
3852
+ return false;
3853
+ }
3854
+ var hasClass = (attributes, maskClass) => typeof attributes.class === "string" && attributes.class.split(/\s+/).includes(maskClass);
3855
+ function shouldMaskValue(text, node, policy) {
3856
+ if (policy.maskEveryField) return true;
3857
+ if (isSensitiveField(node)) return true;
3858
+ if (isMarkedByCustomer(node, policy.maskClass)) return true;
3859
+ return looksLikeCardNumber(text);
3860
+ }
3861
+ var MUTATION = 0;
3862
+ var INPUT = 5;
3863
+ var ELEMENT_NODE = 2;
3864
+ var TEXT_NODE = 3;
3865
+ var FIELD_TAGS = /* @__PURE__ */ new Set(["input", "textarea", "select"]);
3866
+ function maskTextChildren(node) {
3867
+ var _a;
3868
+ for (const child of (_a = node.childNodes) != null ? _a : []) {
3869
+ if (child.type === TEXT_NODE && typeof child.textContent === "string") {
3870
+ child.textContent = child.textContent.replace(/\S/g, "*");
3871
+ }
3872
+ maskTextChildren(child);
3873
+ }
3874
+ }
3875
+ function scrubTree(node, policy, masked) {
3876
+ var _a, _b;
3877
+ if (!node) return;
3878
+ const attributes = (_a = node.attributes) != null ? _a : {};
3879
+ const inheritedMask = masked || hasClass(attributes, policy.maskClass);
3880
+ if (node.type === ELEMENT_NODE && node.tagName && FIELD_TAGS.has(node.tagName.toLowerCase())) {
3881
+ const value = attributes.value;
3882
+ const shouldMask = policy.maskEveryField || inheritedMask || isSensitiveSerialised(attributes) || typeof value === "string" && looksLikeCardNumber(value);
3883
+ if (shouldMask) {
3884
+ if (typeof value === "string" && value !== "") attributes.value = maskValue(value);
3885
+ if (node.tagName.toLowerCase() === "textarea") maskTextChildren(node);
3886
+ }
3887
+ }
3888
+ for (const child of (_b = node.childNodes) != null ? _b : []) scrubTree(child, policy, inheritedMask);
3889
+ }
3890
+ function scrubEvent(event, getNode, policy) {
3891
+ var _a, _b, _c, _d, _e;
3892
+ if (!event.data) return;
3893
+ if (event.type === 2) {
3894
+ scrubTree(event.data.node, policy, false);
3895
+ return;
3896
+ }
3897
+ if (event.type !== 3) return;
3898
+ const data = event.data;
3899
+ if (data.source === INPUT) {
3900
+ if (typeof data.text !== "string" || data.text === "") return;
3901
+ const node = typeof data.id === "number" ? getNode(data.id) : null;
3902
+ const type = ((_b = (_a = asElement(node)) == null ? void 0 : _a.type) != null ? _b : "").toLowerCase();
3903
+ if (type === "checkbox" || type === "radio") return;
3904
+ if (shouldMaskValue(data.text, node, policy)) data.text = maskValue(data.text);
3905
+ return;
3906
+ }
3907
+ if (data.source !== MUTATION) return;
3908
+ for (const entry of (_c = data.attributes) != null ? _c : []) {
3909
+ const value = (_d = entry == null ? void 0 : entry.attributes) == null ? void 0 : _d.value;
3910
+ if (typeof value !== "string" || value === "") continue;
3911
+ const node = typeof entry.id === "number" ? getNode(entry.id) : null;
3912
+ if (shouldMaskValue(value, node, policy)) entry.attributes.value = maskValue(value);
3913
+ }
3914
+ for (const add of (_e = data.adds) != null ? _e : []) {
3915
+ const parent = typeof (add == null ? void 0 : add.parentId) === "number" ? getNode(add.parentId) : null;
3916
+ scrubTree(add == null ? void 0 : add.node, policy, isMarkedByCustomer(parent, policy.maskClass));
3917
+ }
3918
+ }
3919
+
3746
3920
  // src/sampling.ts
3747
3921
  function hashToUnitInterval(input) {
3748
3922
  let hash = 2166136261;
@@ -3838,7 +4012,14 @@ var Transport = class {
3838
4012
  seq: this.seq,
3839
4013
  events,
3840
4014
  ...this.pendingMeta ? { meta: this.pendingMeta } : {},
3841
- ...Object.keys(this.flags).length > 0 ? { flags: this.flags } : {}
4015
+ ...Object.keys(this.flags).length > 0 ? { flags: this.flags } : {},
4016
+ // This device's clock as the request leaves, on the same clock as the
4017
+ // event timestamps. Ingest compares it with its own to catch a device
4018
+ // that runs ahead — a test phone three weeks fast would otherwise list
4019
+ // its sessions as starting "in 3 weeks". Stamped here, when the body is
4020
+ // built, so a retry or a queue flushed after a spell offline carries
4021
+ // the time it was actually sent rather than when it was recorded.
4022
+ sentAt: Date.now()
3842
4023
  });
3843
4024
  }
3844
4025
  /** Sends whatever is buffered. Safe to call when there is nothing to send. */
@@ -4039,47 +4220,37 @@ function reserveSeq(session, next) {
4039
4220
  function touchSession(session, now = Date.now()) {
4040
4221
  session.set(SESSION_TS_KEY, String(now));
4041
4222
  }
4223
+ function forgetVisitor(local, session) {
4224
+ local.remove(VISITOR_KEY);
4225
+ for (const key of [SESSION_KEY, SESSION_TS_KEY, SEQ_KEY, PAGE_KEY, COOLDOWN_KEY]) session.remove(key);
4226
+ }
4042
4227
 
4043
4228
  // src/recorder.ts
4044
4229
  var RAGE_CLICK_COUNT = 3;
4045
4230
  var RAGE_CLICK_WINDOW_MS = 1e3;
4046
4231
  var RAGE_CLICK_RADIUS_PX = 30;
4047
4232
  var MAX_PENDING_EVENTS = 32;
4233
+ var EDITABLE_SELECTOR = '[contenteditable]:not([contenteditable="false"])';
4048
4234
  function createRecorder(rawOptions) {
4049
4235
  const options = resolveOptions(rawOptions);
4050
4236
  const local = safeStorage(() => globalThis.localStorage);
4051
4237
  const session = safeStorage(() => globalThis.sessionStorage);
4052
- const identity = resolveIdentity(local, session);
4053
4238
  let status = "idle";
4239
+ let identity;
4240
+ let transport;
4054
4241
  let stopRecording;
4055
- let pageCount = identity.pageCount;
4056
- if (!isSampledIn(identity.visitorId, options.sampleRate)) {
4057
- return inertHandle("sampled-out", identity.sessionId);
4058
- }
4059
- if (inCooldown(session)) {
4060
- if (options.debug) console.warn("[anyreplay] not recording: ingest refused a new session recently");
4061
- return inertHandle("stopped", identity.sessionId);
4062
- }
4063
- const transport = new Transport(
4064
- options,
4065
- {
4066
- projectKey: options.projectKey,
4067
- sessionId: identity.sessionId,
4068
- visitorId: identity.visitorId,
4069
- initialSeq: identity.nextSeq
4070
- },
4071
- {
4072
- onSeqReserved: (next) => reserveSeq(session, next),
4073
- onStopped: (reason) => {
4074
- status = "stopped";
4075
- stopRecording == null ? void 0 : stopRecording();
4076
- stopRecording = void 0;
4077
- if (reason === "rejected_402") startCooldown(session);
4078
- if (options.debug) console.warn(`[anyreplay] recording stopped: ${reason}`);
4079
- }
4080
- }
4081
- );
4082
- const collectMeta = () => ({
4242
+ let pageCount = 0;
4243
+ let pendingTraits;
4244
+ const sendIdentity = (who, traits) => {
4245
+ void fetch(`${options.ingestUrl}/v1/ingest/identify`, {
4246
+ method: "POST",
4247
+ headers: { "Content-Type": "application/json" },
4248
+ credentials: "omit",
4249
+ body: JSON.stringify({ projectKey: options.projectKey, sessionId: who.sessionId, ...traits })
4250
+ }).catch(() => {
4251
+ });
4252
+ };
4253
+ const collectMeta = (who) => ({
4083
4254
  startedAt: Date.now(),
4084
4255
  url: safeLocation(),
4085
4256
  lang: typeof navigator !== "undefined" ? navigator.language : void 0,
@@ -4089,10 +4260,10 @@ function createRecorder(rawOptions) {
4089
4260
  referrer: typeof document !== "undefined" ? document.referrer || void 0 : void 0,
4090
4261
  // The cap is a decision about starting a session, so it goes only where
4091
4262
  // ingest makes that decision: chunk 0. A resumed page starts later.
4092
- ...options.maxSessionsPerMonth !== void 0 && identity.nextSeq === 0 ? { sessionCap: options.maxSessionsPerMonth } : {}
4263
+ ...options.maxSessionsPerMonth !== void 0 && who.nextSeq === 0 ? { sessionCap: options.maxSessionsPerMonth } : {}
4093
4264
  });
4094
4265
  const recentClicks = [];
4095
- const noteInteraction = (event) => {
4266
+ const noteInteraction = (event, channel) => {
4096
4267
  var _a;
4097
4268
  const data = event.data;
4098
4269
  if (event.type !== 3 || (data == null ? void 0 : data.source) !== 2 || typeof data.x !== "number") return;
@@ -4104,7 +4275,7 @@ function createRecorder(rawOptions) {
4104
4275
  const clustered = recentClicks.every(
4105
4276
  (c) => Math.abs(c.x - first.x) < RAGE_CLICK_RADIUS_PX && Math.abs(c.y - first.y) < RAGE_CLICK_RADIUS_PX
4106
4277
  );
4107
- if (clustered) transport.mergeFlags({ hasRageClick: true });
4278
+ if (clustered) channel.mergeFlags({ hasRageClick: true });
4108
4279
  }
4109
4280
  };
4110
4281
  const pending = [];
@@ -4132,7 +4303,7 @@ function createRecorder(rawOptions) {
4132
4303
  }
4133
4304
  };
4134
4305
  const emitCustom = (tag, payload) => {
4135
- if (status === "stopped") return;
4306
+ if (status === "stopped" || status === "sampled-out") return;
4136
4307
  if (status === "recording") {
4137
4308
  try {
4138
4309
  record.addCustomEvent(tag, payload);
@@ -4146,63 +4317,104 @@ function createRecorder(rawOptions) {
4146
4317
  }
4147
4318
  pending.push({ tag, payload });
4148
4319
  };
4149
- let lastUrl = safeLocation();
4150
- const noteNavigation = () => {
4320
+ let lastUrl;
4321
+ const noteNavigation = (channel) => {
4151
4322
  const url = safeLocation();
4152
4323
  if (!url || url === lastUrl) return;
4153
4324
  lastUrl = url;
4154
4325
  pageCount += 1;
4155
4326
  notePageCount(session, pageCount);
4156
- transport.mergeFlags({ pageCount });
4327
+ channel.mergeFlags({ pageCount });
4157
4328
  emitCustom(NAVIGATE_TAG, { url, title: typeof document !== "undefined" ? document.title : "" });
4158
4329
  };
4159
4330
  const begin = () => {
4160
- if (status === "recording" || status === "stopped") return;
4331
+ if (status !== "idle" && status !== "awaiting-consent") return;
4332
+ const who = resolveIdentity(local, session);
4333
+ identity = who;
4334
+ pageCount = who.pageCount;
4335
+ if (!isSampledIn(who.visitorId, options.sampleRate)) {
4336
+ status = "sampled-out";
4337
+ return;
4338
+ }
4339
+ if (inCooldown(session)) {
4340
+ status = "stopped";
4341
+ if (options.debug) console.warn("[anyreplay] not recording: ingest refused a new session recently");
4342
+ return;
4343
+ }
4344
+ const channel = new Transport(
4345
+ options,
4346
+ {
4347
+ projectKey: options.projectKey,
4348
+ sessionId: who.sessionId,
4349
+ visitorId: who.visitorId,
4350
+ initialSeq: who.nextSeq
4351
+ },
4352
+ {
4353
+ onSeqReserved: (next) => reserveSeq(session, next),
4354
+ onStopped: (reason) => {
4355
+ status = "stopped";
4356
+ stopRecording == null ? void 0 : stopRecording();
4357
+ stopRecording = void 0;
4358
+ if (reason === "rejected_402") startCooldown(session);
4359
+ if (options.debug) console.warn(`[anyreplay] recording stopped: ${reason}`);
4360
+ }
4361
+ }
4362
+ );
4363
+ transport = channel;
4161
4364
  status = "recording";
4162
- transport.setMeta(collectMeta());
4163
- transport.mergeFlags({ pageCount });
4164
- transport.start();
4365
+ lastUrl = safeLocation();
4366
+ channel.setMeta(collectMeta(who));
4367
+ channel.mergeFlags({ pageCount });
4368
+ channel.start();
4369
+ const policy = {
4370
+ maskEveryField: options.maskAllInputs || options.maskAllTyping,
4371
+ maskClass: options.maskClass
4372
+ };
4165
4373
  stopRecording = record({
4166
4374
  emit: (event) => {
4167
4375
  const typed = event;
4168
- noteInteraction(typed);
4376
+ scrubEvent(typed, (id) => record.mirror.getNode(id), policy);
4377
+ noteInteraction(typed, channel);
4169
4378
  touchSession(session, typed.timestamp);
4170
- transport.push(typed);
4379
+ channel.push(typed);
4171
4380
  drainPending();
4172
4381
  },
4173
- maskAllInputs: options.maskAllInputs,
4382
+ maskInputOptions: policy.maskEveryField ? MASK_EVERY_FIELD : MASK_SENSITIVE_FIELDS,
4174
4383
  maskTextClass: options.maskClass,
4384
+ maskTextSelector: options.maskAllTyping ? EDITABLE_SELECTOR : void 0,
4175
4385
  blockClass: options.blockClass,
4176
- // Passwords are never recorded, regardless of configuration.
4177
- maskInputOptions: { password: true, email: true, tel: true },
4178
4386
  // Canvas capture is expensive and off unless a customer opts in later.
4179
4387
  recordCanvas: false,
4180
4388
  collectFonts: false
4181
4389
  });
4182
4390
  drainPending();
4183
- attachLifecycleHooks();
4391
+ if (pendingTraits) {
4392
+ sendIdentity(who, pendingTraits);
4393
+ pendingTraits = void 0;
4394
+ }
4395
+ attachLifecycleHooks(channel);
4184
4396
  };
4185
- const attachLifecycleHooks = () => {
4397
+ const attachLifecycleHooks = (channel) => {
4186
4398
  if (typeof window === "undefined") return;
4187
4399
  window.addEventListener("pagehide", () => {
4188
- transport.flushWithBeacon();
4400
+ channel.flushWithBeacon();
4189
4401
  }, { capture: true });
4190
4402
  document.addEventListener("visibilitychange", () => {
4191
- if (document.visibilityState === "hidden") transport.flushWithBeacon();
4403
+ if (document.visibilityState === "hidden") channel.flushWithBeacon();
4192
4404
  });
4193
- window.addEventListener("error", () => transport.mergeFlags({ hasError: true }));
4194
- window.addEventListener("unhandledrejection", () => transport.mergeFlags({ hasError: true }));
4405
+ window.addEventListener("error", () => channel.mergeFlags({ hasError: true }));
4406
+ window.addEventListener("unhandledrejection", () => channel.mergeFlags({ hasError: true }));
4195
4407
  const patch2 = (method) => {
4196
4408
  const original = history[method].bind(history);
4197
4409
  history[method] = function patched(...args) {
4198
4410
  const result = original(...args);
4199
- noteNavigation();
4411
+ noteNavigation(channel);
4200
4412
  return result;
4201
4413
  };
4202
4414
  };
4203
4415
  patch2("pushState");
4204
4416
  patch2("replaceState");
4205
- window.addEventListener("popstate", noteNavigation);
4417
+ window.addEventListener("popstate", () => noteNavigation(channel));
4206
4418
  };
4207
4419
  if (options.requireConsent) {
4208
4420
  status = "awaiting-consent";
@@ -4211,24 +4423,28 @@ function createRecorder(rawOptions) {
4211
4423
  }
4212
4424
  return {
4213
4425
  status: () => status,
4214
- sessionId: () => identity.sessionId,
4426
+ sessionId: () => {
4427
+ var _a;
4428
+ return (_a = identity == null ? void 0 : identity.sessionId) != null ? _a : null;
4429
+ },
4215
4430
  consent: (granted) => {
4216
- if (granted) begin();
4217
- else transport.stop("consent_withdrawn");
4431
+ if (granted) {
4432
+ begin();
4433
+ return;
4434
+ }
4435
+ transport == null ? void 0 : transport.stop("consent_withdrawn");
4436
+ if (status === "idle" || status === "awaiting-consent") status = "stopped";
4437
+ pendingTraits = void 0;
4438
+ forgetVisitor(local, session);
4218
4439
  },
4219
4440
  identify: (traits) => {
4220
4441
  if (!traits.userId && !traits.email) return;
4221
- void fetch(`${options.ingestUrl}/v1/ingest/identify`, {
4222
- method: "POST",
4223
- headers: { "Content-Type": "application/json" },
4224
- credentials: "omit",
4225
- body: JSON.stringify({
4226
- projectKey: options.projectKey,
4227
- sessionId: identity.sessionId,
4228
- ...traits
4229
- })
4230
- }).catch(() => {
4231
- });
4442
+ if (identity && transport) {
4443
+ sendIdentity(identity, traits);
4444
+ return;
4445
+ }
4446
+ if (status === "idle" || status === "awaiting-consent") pendingTraits = traits;
4447
+ else if (options.debug) console.warn("[anyreplay] identify() ignored: not recording");
4232
4448
  },
4233
4449
  track: (name, properties) => {
4234
4450
  const payload = validateTrack(name, properties);
@@ -4238,8 +4454,15 @@ function createRecorder(rawOptions) {
4238
4454
  }
4239
4455
  emitCustom(TRACK_TAG, payload);
4240
4456
  },
4241
- stop: () => transport.stop("stopped_by_page"),
4242
- flush: () => transport.flush()
4457
+ stop: () => {
4458
+ transport == null ? void 0 : transport.stop("stopped_by_page");
4459
+ if (status === "idle" || status === "awaiting-consent") status = "stopped";
4460
+ pendingTraits = void 0;
4461
+ },
4462
+ flush: () => {
4463
+ var _a;
4464
+ return (_a = transport == null ? void 0 : transport.flush()) != null ? _a : Promise.resolve();
4465
+ }
4243
4466
  };
4244
4467
  }
4245
4468
  function safeLocation() {
@@ -4249,22 +4472,6 @@ function safeLocation() {
4249
4472
  return void 0;
4250
4473
  }
4251
4474
  }
4252
- function inertHandle(status, sessionId) {
4253
- return {
4254
- status: () => status,
4255
- sessionId: () => sessionId,
4256
- consent: () => {
4257
- },
4258
- identify: () => {
4259
- },
4260
- track: () => {
4261
- },
4262
- stop: () => {
4263
- },
4264
- flush: async () => {
4265
- }
4266
- };
4267
- }
4268
4475
  // Annotate the CommonJS export names for ESM import in node:
4269
4476
  0 && (module.exports = {
4270
4477
  ConfigError,
@@ -4275,6 +4482,7 @@ function inertHandle(status, sessionId) {
4275
4482
  TRACK_TAG,
4276
4483
  Transport,
4277
4484
  createRecorder,
4485
+ forgetVisitor,
4278
4486
  hashToUnitInterval,
4279
4487
  isSampledIn,
4280
4488
  resolveIdentity,