@anyreplay/browser 0.1.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/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/index.cjs +660 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +228 -0
- package/dist/index.d.ts +228 -0
- package/dist/index.js +621 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
21
|
+
|
|
22
|
+
// src/index.ts
|
|
23
|
+
var src_exports = {};
|
|
24
|
+
__export(src_exports, {
|
|
25
|
+
ConfigError: () => ConfigError,
|
|
26
|
+
DEFAULTS: () => DEFAULTS,
|
|
27
|
+
EVENT_NAME_PATTERN: () => EVENT_NAME_PATTERN,
|
|
28
|
+
MAX_PROPERTIES_BYTES: () => MAX_PROPERTIES_BYTES,
|
|
29
|
+
NAVIGATE_TAG: () => NAVIGATE_TAG,
|
|
30
|
+
TRACK_TAG: () => TRACK_TAG,
|
|
31
|
+
Transport: () => Transport,
|
|
32
|
+
createRecorder: () => createRecorder,
|
|
33
|
+
hashToUnitInterval: () => hashToUnitInterval,
|
|
34
|
+
isSampledIn: () => isSampledIn,
|
|
35
|
+
resolveIdentity: () => resolveIdentity,
|
|
36
|
+
resolveOptions: () => resolveOptions,
|
|
37
|
+
safeStorage: () => safeStorage,
|
|
38
|
+
uuidv4: () => uuidv4,
|
|
39
|
+
validateTrack: () => validateTrack
|
|
40
|
+
});
|
|
41
|
+
module.exports = __toCommonJS(src_exports);
|
|
42
|
+
|
|
43
|
+
// src/recorder.ts
|
|
44
|
+
var import_rrweb = require("rrweb");
|
|
45
|
+
|
|
46
|
+
// src/config.ts
|
|
47
|
+
var DEFAULTS = {
|
|
48
|
+
ingestUrl: "https://in.anyreplay.com",
|
|
49
|
+
sampleRate: 1,
|
|
50
|
+
// Default-deny. Recording a customer's password field once is a breach; the
|
|
51
|
+
// cost of the safe default is a configuration line for the rare field that
|
|
52
|
+
// genuinely needs to be visible.
|
|
53
|
+
maskAllInputs: true,
|
|
54
|
+
maskClass: "ar-mask",
|
|
55
|
+
blockClass: "ar-block",
|
|
56
|
+
requireConsent: false,
|
|
57
|
+
maxEventsPerChunk: 200,
|
|
58
|
+
flushIntervalMs: 5e3,
|
|
59
|
+
maxConsecutiveFailures: 5,
|
|
60
|
+
debug: false
|
|
61
|
+
};
|
|
62
|
+
var ConfigError = class extends Error {
|
|
63
|
+
};
|
|
64
|
+
var KEY_PATTERN = /^ar_pk_(live|test)_[0-9a-f]{24}$/;
|
|
65
|
+
var MAX_SESSION_CAP = 1e6;
|
|
66
|
+
function resolveOptions(input) {
|
|
67
|
+
var _a, _b, _c;
|
|
68
|
+
if (!input || typeof input !== "object") throw new ConfigError("anyreplay: options object required");
|
|
69
|
+
if (!KEY_PATTERN.test((_a = input.projectKey) != null ? _a : "")) {
|
|
70
|
+
throw new ConfigError("anyreplay: projectKey looks wrong \u2014 copy it from the dashboard");
|
|
71
|
+
}
|
|
72
|
+
const sampleRate = (_b = input.sampleRate) != null ? _b : DEFAULTS.sampleRate;
|
|
73
|
+
if (!(sampleRate >= 0 && sampleRate <= 1)) {
|
|
74
|
+
throw new ConfigError("anyreplay: sampleRate must be between 0 and 1");
|
|
75
|
+
}
|
|
76
|
+
const cap = input.maxSessionsPerMonth;
|
|
77
|
+
if (cap !== void 0 && !(Number.isInteger(cap) && cap >= 1 && cap <= MAX_SESSION_CAP)) {
|
|
78
|
+
throw new ConfigError(`anyreplay: maxSessionsPerMonth must be a whole number between 1 and ${MAX_SESSION_CAP}`);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
...DEFAULTS,
|
|
82
|
+
...input,
|
|
83
|
+
projectKey: input.projectKey,
|
|
84
|
+
sampleRate,
|
|
85
|
+
ingestUrl: ((_c = input.ingestUrl) != null ? _c : DEFAULTS.ingestUrl).replace(/\/+$/, ""),
|
|
86
|
+
// A caller who explicitly passes false gets what they asked for; anything
|
|
87
|
+
// else (including undefined) keeps masking on.
|
|
88
|
+
maskAllInputs: input.maskAllInputs === false ? false : true
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/events.ts
|
|
93
|
+
var TRACK_TAG = "anyreplay.track";
|
|
94
|
+
var NAVIGATE_TAG = "anyreplay.navigate";
|
|
95
|
+
var EVENT_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
96
|
+
var MAX_PROPERTIES_BYTES = 4 * 1024;
|
|
97
|
+
function validateTrack(name, properties) {
|
|
98
|
+
if (typeof name !== "string" || !EVENT_NAME_PATTERN.test(name)) {
|
|
99
|
+
return `event name must be 1-64 characters of [A-Za-z0-9_.:-], got ${JSON.stringify(name)}`;
|
|
100
|
+
}
|
|
101
|
+
if (properties === void 0 || properties === null) return { name, properties: {} };
|
|
102
|
+
if (typeof properties !== "object" || Array.isArray(properties)) {
|
|
103
|
+
return `properties for "${name}" must be a plain object`;
|
|
104
|
+
}
|
|
105
|
+
let serialised;
|
|
106
|
+
try {
|
|
107
|
+
serialised = JSON.stringify(properties);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return `properties for "${name}" are not JSON-serialisable`;
|
|
110
|
+
}
|
|
111
|
+
if (serialised === void 0 || byteLength(serialised) > MAX_PROPERTIES_BYTES) {
|
|
112
|
+
return `properties for "${name}" exceed ${MAX_PROPERTIES_BYTES} bytes once serialised`;
|
|
113
|
+
}
|
|
114
|
+
return { name, properties: JSON.parse(serialised) };
|
|
115
|
+
}
|
|
116
|
+
function byteLength(text) {
|
|
117
|
+
return typeof TextEncoder === "function" ? new TextEncoder().encode(text).length : text.length * 3;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/sampling.ts
|
|
121
|
+
function hashToUnitInterval(input) {
|
|
122
|
+
let hash = 2166136261;
|
|
123
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
124
|
+
hash ^= input.charCodeAt(i);
|
|
125
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
126
|
+
}
|
|
127
|
+
return hash / 4294967295;
|
|
128
|
+
}
|
|
129
|
+
function isSampledIn(visitorId, sampleRate) {
|
|
130
|
+
if (sampleRate >= 1) return true;
|
|
131
|
+
if (sampleRate <= 0) return false;
|
|
132
|
+
return hashToUnitInterval(visitorId) < sampleRate;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/transport.ts
|
|
136
|
+
var MAX_STALE_RECOVERIES = 3;
|
|
137
|
+
async function staleSeqHint(response, sent) {
|
|
138
|
+
try {
|
|
139
|
+
if (typeof response.json !== "function") return void 0;
|
|
140
|
+
const reply = await response.json();
|
|
141
|
+
if (reply.duplicate !== true) return void 0;
|
|
142
|
+
const next = reply.nextSeq;
|
|
143
|
+
return typeof next === "number" && Number.isInteger(next) && next > sent ? next : void 0;
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return void 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
var KEEPALIVE_BODY_LIMIT_BYTES = 56 * 1024;
|
|
149
|
+
function withinKeepaliveLimit(payload) {
|
|
150
|
+
const bytes = typeof TextEncoder === "function" ? new TextEncoder().encode(payload).length : payload.length * 3;
|
|
151
|
+
return bytes <= KEEPALIVE_BODY_LIMIT_BYTES;
|
|
152
|
+
}
|
|
153
|
+
var Transport = class {
|
|
154
|
+
constructor(options, context, hooks = {}) {
|
|
155
|
+
this.options = options;
|
|
156
|
+
this.context = context;
|
|
157
|
+
this.hooks = hooks;
|
|
158
|
+
__publicField(this, "buffer", []);
|
|
159
|
+
__publicField(this, "seq", 0);
|
|
160
|
+
__publicField(this, "consecutiveFailures", 0);
|
|
161
|
+
__publicField(this, "state", "open");
|
|
162
|
+
__publicField(this, "timer");
|
|
163
|
+
__publicField(this, "pendingMeta");
|
|
164
|
+
__publicField(this, "flags", {});
|
|
165
|
+
__publicField(this, "inFlight", false);
|
|
166
|
+
/** The chunk number of the last request that left, to tell a retry from a first attempt. */
|
|
167
|
+
__publicField(this, "lastAttemptedSeq", -1);
|
|
168
|
+
__publicField(this, "staleRecoveries", 0);
|
|
169
|
+
var _a;
|
|
170
|
+
this.seq = (_a = context.initialSeq) != null ? _a : 0;
|
|
171
|
+
}
|
|
172
|
+
get isStopped() {
|
|
173
|
+
return this.state === "stopped";
|
|
174
|
+
}
|
|
175
|
+
get bufferedCount() {
|
|
176
|
+
return this.buffer.length;
|
|
177
|
+
}
|
|
178
|
+
get sentChunks() {
|
|
179
|
+
return this.seq;
|
|
180
|
+
}
|
|
181
|
+
/** Attaches metadata to the next chunk. Sent once, with the session's first chunk. */
|
|
182
|
+
setMeta(meta) {
|
|
183
|
+
this.pendingMeta = meta;
|
|
184
|
+
}
|
|
185
|
+
mergeFlags(flags) {
|
|
186
|
+
this.flags = { ...this.flags, ...flags };
|
|
187
|
+
}
|
|
188
|
+
start() {
|
|
189
|
+
var _a, _b;
|
|
190
|
+
if (this.timer) return;
|
|
191
|
+
this.timer = setInterval(() => void this.flush(), this.options.flushIntervalMs);
|
|
192
|
+
(_b = (_a = this.timer).unref) == null ? void 0 : _b.call(_a);
|
|
193
|
+
}
|
|
194
|
+
stop(reason) {
|
|
195
|
+
var _a, _b;
|
|
196
|
+
this.state = "stopped";
|
|
197
|
+
if (this.timer) clearInterval(this.timer);
|
|
198
|
+
this.timer = void 0;
|
|
199
|
+
this.buffer = [];
|
|
200
|
+
(_b = (_a = this.hooks).onStopped) == null ? void 0 : _b.call(_a, reason);
|
|
201
|
+
}
|
|
202
|
+
push(event) {
|
|
203
|
+
if (this.state === "stopped") return;
|
|
204
|
+
this.buffer.push(event);
|
|
205
|
+
if (this.buffer.length >= this.options.maxEventsPerChunk) void this.flush();
|
|
206
|
+
}
|
|
207
|
+
body(events) {
|
|
208
|
+
return JSON.stringify({
|
|
209
|
+
projectKey: this.context.projectKey,
|
|
210
|
+
sessionId: this.context.sessionId,
|
|
211
|
+
visitorId: this.context.visitorId,
|
|
212
|
+
seq: this.seq,
|
|
213
|
+
events,
|
|
214
|
+
...this.pendingMeta ? { meta: this.pendingMeta } : {},
|
|
215
|
+
...Object.keys(this.flags).length > 0 ? { flags: this.flags } : {}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** Sends whatever is buffered. Safe to call when there is nothing to send. */
|
|
219
|
+
async flush() {
|
|
220
|
+
var _a, _b, _c, _d;
|
|
221
|
+
if (this.state === "stopped" || this.inFlight || this.buffer.length === 0) return;
|
|
222
|
+
this.inFlight = true;
|
|
223
|
+
const events = this.buffer;
|
|
224
|
+
this.buffer = [];
|
|
225
|
+
let resend = false;
|
|
226
|
+
try {
|
|
227
|
+
const retry = this.lastAttemptedSeq === this.seq;
|
|
228
|
+
this.lastAttemptedSeq = this.seq;
|
|
229
|
+
(_b = (_a = this.hooks).onSeqReserved) == null ? void 0 : _b.call(_a, this.seq + 1);
|
|
230
|
+
const payload = this.body(events);
|
|
231
|
+
const response = await fetch(`${this.options.ingestUrl}/v1/ingest/events`, {
|
|
232
|
+
method: "POST",
|
|
233
|
+
headers: { "Content-Type": "application/json" },
|
|
234
|
+
body: payload,
|
|
235
|
+
// Ingest is unauthenticated by design; sending credentials would be a
|
|
236
|
+
// cross-site cookie leak for no benefit.
|
|
237
|
+
credentials: "omit",
|
|
238
|
+
keepalive: withinKeepaliveLimit(payload)
|
|
239
|
+
});
|
|
240
|
+
if (response.ok) {
|
|
241
|
+
const nextSeq = retry ? void 0 : await staleSeqHint(response, this.seq);
|
|
242
|
+
if (nextSeq !== void 0 && this.staleRecoveries < MAX_STALE_RECOVERIES) {
|
|
243
|
+
this.staleRecoveries += 1;
|
|
244
|
+
this.seq = nextSeq;
|
|
245
|
+
this.requeue(events);
|
|
246
|
+
resend = true;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this.staleRecoveries = 0;
|
|
250
|
+
this.seq += 1;
|
|
251
|
+
this.pendingMeta = void 0;
|
|
252
|
+
this.consecutiveFailures = 0;
|
|
253
|
+
(_d = (_c = this.hooks).onSent) == null ? void 0 : _d.call(_c, this.seq - 1, events.length);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
|
257
|
+
this.stop(`rejected_${response.status}`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
this.requeue(events);
|
|
261
|
+
this.registerFailure();
|
|
262
|
+
} catch (e) {
|
|
263
|
+
this.requeue(events);
|
|
264
|
+
this.registerFailure();
|
|
265
|
+
} finally {
|
|
266
|
+
this.inFlight = false;
|
|
267
|
+
if (resend) void this.flush();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Puts a failed batch back at the front of the queue.
|
|
272
|
+
*
|
|
273
|
+
* Bounded on purpose: a long offline period must not grow the buffer until
|
|
274
|
+
* the tab runs out of memory. Older events are dropped first — a replay
|
|
275
|
+
* missing its beginning is still useful; a crashed page is not.
|
|
276
|
+
*/
|
|
277
|
+
requeue(events) {
|
|
278
|
+
const limit = this.options.maxEventsPerChunk * 10;
|
|
279
|
+
this.buffer = [...events, ...this.buffer].slice(-limit);
|
|
280
|
+
}
|
|
281
|
+
registerFailure() {
|
|
282
|
+
this.consecutiveFailures += 1;
|
|
283
|
+
if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
|
|
284
|
+
this.stop("too_many_failures");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Last-gasp send when the page is going away.
|
|
289
|
+
*
|
|
290
|
+
* `fetch` is cancelled once the document unloads, so the tail of every
|
|
291
|
+
* session would be lost. `sendBeacon` hands the payload to the browser, which
|
|
292
|
+
* delivers it after the page is gone.
|
|
293
|
+
*/
|
|
294
|
+
flushWithBeacon() {
|
|
295
|
+
var _a, _b;
|
|
296
|
+
if (this.state === "stopped" || this.buffer.length === 0) return false;
|
|
297
|
+
const events = this.buffer;
|
|
298
|
+
const payload = this.body(events);
|
|
299
|
+
this.buffer = [];
|
|
300
|
+
try {
|
|
301
|
+
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
|
|
302
|
+
(_b = (_a = this.hooks).onSeqReserved) == null ? void 0 : _b.call(_a, this.seq + 1);
|
|
303
|
+
const blob = new Blob([payload], { type: "text/plain;charset=UTF-8" });
|
|
304
|
+
const queued = navigator.sendBeacon(`${this.options.ingestUrl}/v1/ingest/events`, blob);
|
|
305
|
+
if (queued) {
|
|
306
|
+
this.seq += 1;
|
|
307
|
+
this.pendingMeta = void 0;
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} catch (e) {
|
|
312
|
+
}
|
|
313
|
+
this.buffer = events;
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// src/visitor.ts
|
|
319
|
+
var VISITOR_KEY = "anyreplay.vid";
|
|
320
|
+
var SESSION_KEY = "anyreplay.sid";
|
|
321
|
+
var SESSION_TS_KEY = "anyreplay.sts";
|
|
322
|
+
var SEQ_KEY = "anyreplay.seq";
|
|
323
|
+
var PAGE_KEY = "anyreplay.pc";
|
|
324
|
+
var COOLDOWN_KEY = "anyreplay.cd";
|
|
325
|
+
var QUOTA_COOLDOWN_MS = 2 * 60 * 1e3;
|
|
326
|
+
var SESSION_IDLE_MS = 30 * 60 * 1e3;
|
|
327
|
+
function randomId(bytes = 16) {
|
|
328
|
+
const array = new Uint8Array(bytes);
|
|
329
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
330
|
+
crypto.getRandomValues(array);
|
|
331
|
+
} else {
|
|
332
|
+
for (let i = 0; i < bytes; i += 1) array[i] = Math.floor(Math.random() * 256);
|
|
333
|
+
}
|
|
334
|
+
return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
335
|
+
}
|
|
336
|
+
function uuidv4() {
|
|
337
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
338
|
+
return crypto.randomUUID();
|
|
339
|
+
}
|
|
340
|
+
const hex = randomId(16).split("");
|
|
341
|
+
hex[12] = "4";
|
|
342
|
+
hex[16] = (parseInt(hex[16], 16) & 3 | 8).toString(16);
|
|
343
|
+
const s = hex.join("");
|
|
344
|
+
return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`;
|
|
345
|
+
}
|
|
346
|
+
function safeStorage(source) {
|
|
347
|
+
const read = () => {
|
|
348
|
+
try {
|
|
349
|
+
return source();
|
|
350
|
+
} catch (e) {
|
|
351
|
+
return void 0;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
return {
|
|
355
|
+
get: (key) => {
|
|
356
|
+
var _a, _b;
|
|
357
|
+
try {
|
|
358
|
+
return (_b = (_a = read()) == null ? void 0 : _a.getItem(key)) != null ? _b : null;
|
|
359
|
+
} catch (e) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
set: (key, value) => {
|
|
364
|
+
var _a;
|
|
365
|
+
try {
|
|
366
|
+
(_a = read()) == null ? void 0 : _a.setItem(key, value);
|
|
367
|
+
} catch (e) {
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
remove: (key) => {
|
|
371
|
+
var _a;
|
|
372
|
+
try {
|
|
373
|
+
(_a = read()) == null ? void 0 : _a.removeItem(key);
|
|
374
|
+
} catch (e) {
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function resolveIdentity(local, session, now = Date.now()) {
|
|
380
|
+
var _a;
|
|
381
|
+
let visitorId = local.get(VISITOR_KEY);
|
|
382
|
+
if (!visitorId || !/^[A-Za-z0-9_-]{8,64}$/.test(visitorId)) {
|
|
383
|
+
visitorId = `v${randomId(12)}`;
|
|
384
|
+
local.set(VISITOR_KEY, visitorId);
|
|
385
|
+
}
|
|
386
|
+
const previousId = session.get(SESSION_KEY);
|
|
387
|
+
const previousTs = Number((_a = session.get(SESSION_TS_KEY)) != null ? _a : 0);
|
|
388
|
+
const expired = !previousId || !previousTs || now - previousTs > SESSION_IDLE_MS;
|
|
389
|
+
const sessionId = expired ? uuidv4() : previousId;
|
|
390
|
+
session.set(SESSION_KEY, sessionId);
|
|
391
|
+
session.set(SESSION_TS_KEY, String(now));
|
|
392
|
+
const storedSeq = Number(session.get(SEQ_KEY));
|
|
393
|
+
const nextSeq = !expired && Number.isInteger(storedSeq) && storedSeq > 0 ? storedSeq : 0;
|
|
394
|
+
session.set(SEQ_KEY, String(nextSeq));
|
|
395
|
+
const storedPages = Number(session.get(PAGE_KEY));
|
|
396
|
+
const pageCount = !expired && Number.isInteger(storedPages) && storedPages > 0 ? storedPages + 1 : 1;
|
|
397
|
+
session.set(PAGE_KEY, String(pageCount));
|
|
398
|
+
return { visitorId, sessionId, isNewSession: expired, nextSeq, pageCount };
|
|
399
|
+
}
|
|
400
|
+
function notePageCount(session, count) {
|
|
401
|
+
session.set(PAGE_KEY, String(count));
|
|
402
|
+
}
|
|
403
|
+
function inCooldown(session, now = Date.now()) {
|
|
404
|
+
const until = Number(session.get(COOLDOWN_KEY));
|
|
405
|
+
return Number.isFinite(until) && until > now;
|
|
406
|
+
}
|
|
407
|
+
function startCooldown(session, now = Date.now()) {
|
|
408
|
+
session.set(COOLDOWN_KEY, String(now + QUOTA_COOLDOWN_MS));
|
|
409
|
+
}
|
|
410
|
+
function reserveSeq(session, next) {
|
|
411
|
+
session.set(SEQ_KEY, String(next));
|
|
412
|
+
}
|
|
413
|
+
function touchSession(session, now = Date.now()) {
|
|
414
|
+
session.set(SESSION_TS_KEY, String(now));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/recorder.ts
|
|
418
|
+
var RAGE_CLICK_COUNT = 3;
|
|
419
|
+
var RAGE_CLICK_WINDOW_MS = 1e3;
|
|
420
|
+
var RAGE_CLICK_RADIUS_PX = 30;
|
|
421
|
+
var MAX_PENDING_EVENTS = 32;
|
|
422
|
+
function createRecorder(rawOptions) {
|
|
423
|
+
const options = resolveOptions(rawOptions);
|
|
424
|
+
const local = safeStorage(() => globalThis.localStorage);
|
|
425
|
+
const session = safeStorage(() => globalThis.sessionStorage);
|
|
426
|
+
const identity = resolveIdentity(local, session);
|
|
427
|
+
let status = "idle";
|
|
428
|
+
let stopRecording;
|
|
429
|
+
let pageCount = identity.pageCount;
|
|
430
|
+
if (!isSampledIn(identity.visitorId, options.sampleRate)) {
|
|
431
|
+
return inertHandle("sampled-out", identity.sessionId);
|
|
432
|
+
}
|
|
433
|
+
if (inCooldown(session)) {
|
|
434
|
+
if (options.debug) console.warn("[anyreplay] not recording: ingest refused a new session recently");
|
|
435
|
+
return inertHandle("stopped", identity.sessionId);
|
|
436
|
+
}
|
|
437
|
+
const transport = new Transport(
|
|
438
|
+
options,
|
|
439
|
+
{
|
|
440
|
+
projectKey: options.projectKey,
|
|
441
|
+
sessionId: identity.sessionId,
|
|
442
|
+
visitorId: identity.visitorId,
|
|
443
|
+
initialSeq: identity.nextSeq
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
onSeqReserved: (next) => reserveSeq(session, next),
|
|
447
|
+
onStopped: (reason) => {
|
|
448
|
+
status = "stopped";
|
|
449
|
+
stopRecording == null ? void 0 : stopRecording();
|
|
450
|
+
stopRecording = void 0;
|
|
451
|
+
if (reason === "rejected_402") startCooldown(session);
|
|
452
|
+
if (options.debug) console.warn(`[anyreplay] recording stopped: ${reason}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
);
|
|
456
|
+
const collectMeta = () => ({
|
|
457
|
+
startedAt: Date.now(),
|
|
458
|
+
url: safeLocation(),
|
|
459
|
+
lang: typeof navigator !== "undefined" ? navigator.language : void 0,
|
|
460
|
+
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : void 0,
|
|
461
|
+
screenWidth: typeof window !== "undefined" ? window.innerWidth : void 0,
|
|
462
|
+
screenHeight: typeof window !== "undefined" ? window.innerHeight : void 0,
|
|
463
|
+
referrer: typeof document !== "undefined" ? document.referrer || void 0 : void 0,
|
|
464
|
+
// The cap is a decision about starting a session, so it goes only where
|
|
465
|
+
// ingest makes that decision: chunk 0. A resumed page starts later.
|
|
466
|
+
...options.maxSessionsPerMonth !== void 0 && identity.nextSeq === 0 ? { sessionCap: options.maxSessionsPerMonth } : {}
|
|
467
|
+
});
|
|
468
|
+
const recentClicks = [];
|
|
469
|
+
const noteInteraction = (event) => {
|
|
470
|
+
var _a;
|
|
471
|
+
const data = event.data;
|
|
472
|
+
if (event.type !== 3 || (data == null ? void 0 : data.source) !== 2 || typeof data.x !== "number") return;
|
|
473
|
+
const now = event.timestamp;
|
|
474
|
+
recentClicks.push({ x: data.x, y: (_a = data.y) != null ? _a : 0, t: now });
|
|
475
|
+
while (recentClicks.length > 0 && now - recentClicks[0].t > RAGE_CLICK_WINDOW_MS) recentClicks.shift();
|
|
476
|
+
if (recentClicks.length >= RAGE_CLICK_COUNT) {
|
|
477
|
+
const first = recentClicks[0];
|
|
478
|
+
const clustered = recentClicks.every(
|
|
479
|
+
(c) => Math.abs(c.x - first.x) < RAGE_CLICK_RADIUS_PX && Math.abs(c.y - first.y) < RAGE_CLICK_RADIUS_PX
|
|
480
|
+
);
|
|
481
|
+
if (clustered) transport.mergeFlags({ hasRageClick: true });
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
const pending = [];
|
|
485
|
+
let draining = false;
|
|
486
|
+
let drainScheduled = false;
|
|
487
|
+
const drainPending = () => {
|
|
488
|
+
if (draining || pending.length === 0) return;
|
|
489
|
+
draining = true;
|
|
490
|
+
try {
|
|
491
|
+
while (pending.length > 0) {
|
|
492
|
+
const next = pending[0];
|
|
493
|
+
import_rrweb.record.addCustomEvent(next.tag, next.payload);
|
|
494
|
+
pending.shift();
|
|
495
|
+
}
|
|
496
|
+
} catch (e) {
|
|
497
|
+
if (!drainScheduled && status === "recording") {
|
|
498
|
+
drainScheduled = true;
|
|
499
|
+
setTimeout(() => {
|
|
500
|
+
drainScheduled = false;
|
|
501
|
+
drainPending();
|
|
502
|
+
}, 50);
|
|
503
|
+
}
|
|
504
|
+
} finally {
|
|
505
|
+
draining = false;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
const emitCustom = (tag, payload) => {
|
|
509
|
+
if (status === "stopped") return;
|
|
510
|
+
if (status === "recording") {
|
|
511
|
+
try {
|
|
512
|
+
import_rrweb.record.addCustomEvent(tag, payload);
|
|
513
|
+
return;
|
|
514
|
+
} catch (e) {
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (pending.length >= MAX_PENDING_EVENTS) {
|
|
518
|
+
if (options.debug) console.warn(`[anyreplay] dropped ${tag}: too many events before recording started`);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
pending.push({ tag, payload });
|
|
522
|
+
};
|
|
523
|
+
let lastUrl = safeLocation();
|
|
524
|
+
const noteNavigation = () => {
|
|
525
|
+
const url = safeLocation();
|
|
526
|
+
if (!url || url === lastUrl) return;
|
|
527
|
+
lastUrl = url;
|
|
528
|
+
pageCount += 1;
|
|
529
|
+
notePageCount(session, pageCount);
|
|
530
|
+
transport.mergeFlags({ pageCount });
|
|
531
|
+
emitCustom(NAVIGATE_TAG, { url, title: typeof document !== "undefined" ? document.title : "" });
|
|
532
|
+
};
|
|
533
|
+
const begin = () => {
|
|
534
|
+
if (status === "recording" || status === "stopped") return;
|
|
535
|
+
status = "recording";
|
|
536
|
+
transport.setMeta(collectMeta());
|
|
537
|
+
transport.mergeFlags({ pageCount });
|
|
538
|
+
transport.start();
|
|
539
|
+
stopRecording = (0, import_rrweb.record)({
|
|
540
|
+
emit: (event) => {
|
|
541
|
+
const typed = event;
|
|
542
|
+
noteInteraction(typed);
|
|
543
|
+
touchSession(session, typed.timestamp);
|
|
544
|
+
transport.push(typed);
|
|
545
|
+
drainPending();
|
|
546
|
+
},
|
|
547
|
+
maskAllInputs: options.maskAllInputs,
|
|
548
|
+
maskTextClass: options.maskClass,
|
|
549
|
+
blockClass: options.blockClass,
|
|
550
|
+
// Passwords are never recorded, regardless of configuration.
|
|
551
|
+
maskInputOptions: { password: true, email: true, tel: true },
|
|
552
|
+
// Canvas capture is expensive and off unless a customer opts in later.
|
|
553
|
+
recordCanvas: false,
|
|
554
|
+
collectFonts: false
|
|
555
|
+
});
|
|
556
|
+
drainPending();
|
|
557
|
+
attachLifecycleHooks();
|
|
558
|
+
};
|
|
559
|
+
const attachLifecycleHooks = () => {
|
|
560
|
+
if (typeof window === "undefined") return;
|
|
561
|
+
window.addEventListener("pagehide", () => {
|
|
562
|
+
transport.flushWithBeacon();
|
|
563
|
+
}, { capture: true });
|
|
564
|
+
document.addEventListener("visibilitychange", () => {
|
|
565
|
+
if (document.visibilityState === "hidden") transport.flushWithBeacon();
|
|
566
|
+
});
|
|
567
|
+
window.addEventListener("error", () => transport.mergeFlags({ hasError: true }));
|
|
568
|
+
window.addEventListener("unhandledrejection", () => transport.mergeFlags({ hasError: true }));
|
|
569
|
+
const patch = (method) => {
|
|
570
|
+
const original = history[method].bind(history);
|
|
571
|
+
history[method] = function patched(...args) {
|
|
572
|
+
const result = original(...args);
|
|
573
|
+
noteNavigation();
|
|
574
|
+
return result;
|
|
575
|
+
};
|
|
576
|
+
};
|
|
577
|
+
patch("pushState");
|
|
578
|
+
patch("replaceState");
|
|
579
|
+
window.addEventListener("popstate", noteNavigation);
|
|
580
|
+
};
|
|
581
|
+
if (options.requireConsent) {
|
|
582
|
+
status = "awaiting-consent";
|
|
583
|
+
} else {
|
|
584
|
+
begin();
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
status: () => status,
|
|
588
|
+
sessionId: () => identity.sessionId,
|
|
589
|
+
consent: (granted) => {
|
|
590
|
+
if (granted) begin();
|
|
591
|
+
else transport.stop("consent_withdrawn");
|
|
592
|
+
},
|
|
593
|
+
identify: (traits) => {
|
|
594
|
+
if (!traits.userId && !traits.email) return;
|
|
595
|
+
void fetch(`${options.ingestUrl}/v1/ingest/identify`, {
|
|
596
|
+
method: "POST",
|
|
597
|
+
headers: { "Content-Type": "application/json" },
|
|
598
|
+
credentials: "omit",
|
|
599
|
+
body: JSON.stringify({
|
|
600
|
+
projectKey: options.projectKey,
|
|
601
|
+
sessionId: identity.sessionId,
|
|
602
|
+
...traits
|
|
603
|
+
})
|
|
604
|
+
}).catch(() => {
|
|
605
|
+
});
|
|
606
|
+
},
|
|
607
|
+
track: (name, properties) => {
|
|
608
|
+
const payload = validateTrack(name, properties);
|
|
609
|
+
if (typeof payload === "string") {
|
|
610
|
+
if (options.debug) console.warn(`[anyreplay] track() ignored: ${payload}`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
emitCustom(TRACK_TAG, payload);
|
|
614
|
+
},
|
|
615
|
+
stop: () => transport.stop("stopped_by_page"),
|
|
616
|
+
flush: () => transport.flush()
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function safeLocation() {
|
|
620
|
+
try {
|
|
621
|
+
return typeof location !== "undefined" ? location.href : void 0;
|
|
622
|
+
} catch (e) {
|
|
623
|
+
return void 0;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function inertHandle(status, sessionId) {
|
|
627
|
+
return {
|
|
628
|
+
status: () => status,
|
|
629
|
+
sessionId: () => sessionId,
|
|
630
|
+
consent: () => {
|
|
631
|
+
},
|
|
632
|
+
identify: () => {
|
|
633
|
+
},
|
|
634
|
+
track: () => {
|
|
635
|
+
},
|
|
636
|
+
stop: () => {
|
|
637
|
+
},
|
|
638
|
+
flush: async () => {
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
643
|
+
0 && (module.exports = {
|
|
644
|
+
ConfigError,
|
|
645
|
+
DEFAULTS,
|
|
646
|
+
EVENT_NAME_PATTERN,
|
|
647
|
+
MAX_PROPERTIES_BYTES,
|
|
648
|
+
NAVIGATE_TAG,
|
|
649
|
+
TRACK_TAG,
|
|
650
|
+
Transport,
|
|
651
|
+
createRecorder,
|
|
652
|
+
hashToUnitInterval,
|
|
653
|
+
isSampledIn,
|
|
654
|
+
resolveIdentity,
|
|
655
|
+
resolveOptions,
|
|
656
|
+
safeStorage,
|
|
657
|
+
uuidv4,
|
|
658
|
+
validateTrack
|
|
659
|
+
});
|
|
660
|
+
//# sourceMappingURL=index.cjs.map
|