@anyreplay/browser 0.2.0 → 0.2.2
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 +16 -9
- package/dist/index.cjs +94 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +94 -13
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
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
|
|
6
|
+
- DOM-based recording (built on rrweb), not video — about 35 KB gzipped
|
|
7
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
|
-
- Deterministic sampling per visitor,
|
|
10
|
+
- Deterministic sampling per visitor, retries that wait out an outage instead of giving up, and an optional consent gate
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
@@ -25,7 +25,7 @@ const recorder = createRecorder({
|
|
|
25
25
|
// maskAllInputs: true, // off by default: field values are recorded
|
|
26
26
|
// maskAllTyping: true, // the stricter switch: fields and rich-text editors
|
|
27
27
|
// maxSessionsPerMonth: 5000, // optional cap for this project
|
|
28
|
-
// appId: 'com.example.shop', // only inside an app shell
|
|
28
|
+
// appId: 'com.example.shop', // only inside an app shell; see below
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
recorder.track('checkout_clicked', { plan: 'growth' });
|
|
@@ -34,13 +34,15 @@ recorder.identify({ userId: 'u_42', email: 'ada@example.com' });
|
|
|
34
34
|
|
|
35
35
|
### Inside an app
|
|
36
36
|
|
|
37
|
-
A page running inside a Capacitor, Cordova or
|
|
38
|
-
origin — its web view reports `capacitor://localhost`, `https://localhost`
|
|
39
|
-
`file://` — so a project that lists allowed domains refuses it. Add the app's
|
|
37
|
+
A page running inside a Capacitor, Cordova, Electron or Tauri app has no real
|
|
38
|
+
web origin — its web view reports `capacitor://localhost`, `https://localhost`
|
|
39
|
+
or `file://` — so a project that lists allowed domains refuses it. Add the app's
|
|
40
40
|
bundle id or package name under **Allowed apps** in the project's settings,
|
|
41
41
|
and pass the same value as `appId`. Leave `appId` out on a website: from a
|
|
42
42
|
real web origin it is ignored, so it cannot open a web project's domain list.
|
|
43
|
-
It is a filter, not a secret.
|
|
43
|
+
It is a filter, not a secret. The `@anyreplay/capacitor`, `@anyreplay/cordova`,
|
|
44
|
+
`@anyreplay/electron` and `@anyreplay/tauri` packages (a preview) wrap this SDK
|
|
45
|
+
and read the app id for you.
|
|
44
46
|
|
|
45
47
|
Every session also reports `platform: 'web'` and this package's name and
|
|
46
48
|
version, so the dashboard can say what recorded it.
|
|
@@ -56,7 +58,7 @@ from its setup page; it loads the same code from `cdn.anyreplay.com`.
|
|
|
56
58
|
| `handle.track(name, properties?)` | Tags this moment; `name` is 1–64 chars of `[A-Za-z0-9_.:-]`, properties a JSON object ≤ 4 KB |
|
|
57
59
|
| `handle.trackError(error)` | Records an error you caught yourself, redacted, and marks the session as having one |
|
|
58
60
|
| `handle.identify({ userId?, email? })` | Attaches your user id to the session |
|
|
59
|
-
| `handle.consent(true)` | Starts recording when `requireConsent: true`; `consent(false)`
|
|
61
|
+
| `handle.consent(true)` | Starts recording when `requireConsent: true`; `consent(false)` removes what was stored and stops (before recording started, a later `consent(true)` still starts it) |
|
|
60
62
|
| `handle.status()` | `idle`, `awaiting-consent`, `recording`, `sampled-out` or `stopped` |
|
|
61
63
|
| `handle.flush()` | Sends what is buffered now; resolves when delivered |
|
|
62
64
|
| `handle.stop()` | Stops for this page |
|
|
@@ -77,7 +79,12 @@ Some fields are masked in every mode and no option records them: passwords and
|
|
|
77
79
|
one-time codes; card numbers, security codes, expiry dates and cardholder names
|
|
78
80
|
(from `autocomplete="cc-…"` or from a name, label or placeholder that reads like
|
|
79
81
|
one); IBANs, routing numbers and sort codes; any value of 13–19 digits that
|
|
80
|
-
passes the card-number check
|
|
82
|
+
passes the card-number check — and, because a number is typed one key at a
|
|
83
|
+
time, any value that could still become one: digits only, starting 2–6,
|
|
84
|
+
grouped like a card, more than six digits long. A card number written inside
|
|
85
|
+
longer text ("my card is 4242 4242 …" in a note) has its digits starred and the
|
|
86
|
+
rest of the text kept. At most the first six digits of a card number are ever
|
|
87
|
+
recorded. Masking always happens in the page, before
|
|
81
88
|
anything is sent.
|
|
82
89
|
|
|
83
90
|
`type="email"` and `type="tel"` are not on that list. An address and a phone
|
package/dist/index.cjs
CHANGED
|
@@ -3864,10 +3864,12 @@ var AssetCollector = class {
|
|
|
3864
3864
|
body: JSON.stringify(body)
|
|
3865
3865
|
});
|
|
3866
3866
|
const known = await post("/v1/ingest/assets/known", { projectKey, hashes: [sha256], ...app });
|
|
3867
|
-
if (known.ok) {
|
|
3868
|
-
|
|
3869
|
-
|
|
3867
|
+
if (!known.ok) {
|
|
3868
|
+
if (known.status === 401 || known.status === 403) this.stop();
|
|
3869
|
+
return false;
|
|
3870
3870
|
}
|
|
3871
|
+
const answer = await known.json();
|
|
3872
|
+
if (Array.isArray(answer.known) && answer.known.includes(sha256)) return true;
|
|
3871
3873
|
const upload = await post("/v1/ingest/assets", { projectKey, sha256, data: toBase64(bytes), ...app });
|
|
3872
3874
|
return upload.ok;
|
|
3873
3875
|
}
|
|
@@ -4172,6 +4174,13 @@ function looksLikeCardNumber(text) {
|
|
|
4172
4174
|
}
|
|
4173
4175
|
return sum % 10 === 0;
|
|
4174
4176
|
}
|
|
4177
|
+
function mayBecomeCardNumber(text) {
|
|
4178
|
+
if (!/^[2-6][0-9 -]*$/.test(text)) return false;
|
|
4179
|
+
const groups = text.split(/[ -]+/);
|
|
4180
|
+
if (groups.slice(0, -1).some((group) => group.length < 4)) return false;
|
|
4181
|
+
const digits = groups.join("").length;
|
|
4182
|
+
return digits > 6 && digits <= 19;
|
|
4183
|
+
}
|
|
4175
4184
|
function isMarkedByCustomer(node, maskClass) {
|
|
4176
4185
|
var _a;
|
|
4177
4186
|
let element = asElement(node);
|
|
@@ -4186,8 +4195,53 @@ function shouldMaskValue(text, node, policy) {
|
|
|
4186
4195
|
if (policy.maskEveryField) return true;
|
|
4187
4196
|
if (isSensitiveField(node)) return true;
|
|
4188
4197
|
if (isMarkedByCustomer(node, policy.maskClass)) return true;
|
|
4189
|
-
return
|
|
4198
|
+
return cardShaped(text);
|
|
4199
|
+
}
|
|
4200
|
+
var cardShaped = (text) => looksLikeCardNumber(text) || mayBecomeCardNumber(text);
|
|
4201
|
+
var DIGIT_RUN = /[0-9]+(?:[ -][0-9]+)*/g;
|
|
4202
|
+
function maskCardRuns(text) {
|
|
4203
|
+
if (!/[0-9]/.test(text)) return text;
|
|
4204
|
+
return text.replace(DIGIT_RUN, (run) => {
|
|
4205
|
+
const groups = run.split(/[ -]/);
|
|
4206
|
+
const starts = [];
|
|
4207
|
+
let at = 0;
|
|
4208
|
+
for (const group of groups) {
|
|
4209
|
+
starts.push(at);
|
|
4210
|
+
at += group.length + 1;
|
|
4211
|
+
}
|
|
4212
|
+
const chars2 = run.split("");
|
|
4213
|
+
let i = 0;
|
|
4214
|
+
while (i < groups.length) {
|
|
4215
|
+
let matched = -1;
|
|
4216
|
+
for (let j = groups.length - 1; j >= i; j -= 1) {
|
|
4217
|
+
const end2 = starts[j] + groups[j].length;
|
|
4218
|
+
if (cardShaped(run.slice(starts[i], end2).replace(/ /g, " "))) {
|
|
4219
|
+
matched = j;
|
|
4220
|
+
break;
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
if (matched < 0) {
|
|
4224
|
+
i += 1;
|
|
4225
|
+
continue;
|
|
4226
|
+
}
|
|
4227
|
+
const end = starts[matched] + groups[matched].length;
|
|
4228
|
+
for (let k = starts[i]; k < end; k += 1) if (chars2[k] !== " " && chars2[k] !== "\xA0" && chars2[k] !== "-") chars2[k] = "*";
|
|
4229
|
+
i = matched + 1;
|
|
4230
|
+
}
|
|
4231
|
+
return chars2.join("");
|
|
4232
|
+
});
|
|
4190
4233
|
}
|
|
4234
|
+
var EDITABLE = '[contenteditable]:not([contenteditable="false"])';
|
|
4235
|
+
function insideEditable(node) {
|
|
4236
|
+
var _a;
|
|
4237
|
+
const live = node;
|
|
4238
|
+
const element = (live == null ? void 0 : live.nodeType) === 3 ? (_a = live.parentElement) != null ? _a : null : asElement(live);
|
|
4239
|
+
return typeof (element == null ? void 0 : element.closest) === "function" && element.closest(EDITABLE) !== null;
|
|
4240
|
+
}
|
|
4241
|
+
var isEditableSerialised = (attributes) => attributes.contenteditable !== void 0 && attributes.contenteditable !== "false";
|
|
4242
|
+
var typedCardShaped = (text) => cardShaped(text.replace(/\u00a0/g, " ").trim());
|
|
4243
|
+
var maskText = (text) => text.replace(/\S/g, "*");
|
|
4244
|
+
var maskTyped = (text) => typedCardShaped(text) ? maskText(text) : maskCardRuns(text);
|
|
4191
4245
|
var MUTATION = 0;
|
|
4192
4246
|
var INPUT = 5;
|
|
4193
4247
|
var ELEMENT_NODE = 2;
|
|
@@ -4202,23 +4256,36 @@ function maskTextChildren(node) {
|
|
|
4202
4256
|
maskTextChildren(child);
|
|
4203
4257
|
}
|
|
4204
4258
|
}
|
|
4205
|
-
function
|
|
4259
|
+
function maskCardRunsInChildren(node) {
|
|
4260
|
+
var _a;
|
|
4261
|
+
for (const child of (_a = node.childNodes) != null ? _a : []) {
|
|
4262
|
+
if (child.type === TEXT_NODE && typeof child.textContent === "string") child.textContent = maskCardRuns(child.textContent);
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
function scrubTree(node, policy, masked, editable = false) {
|
|
4206
4266
|
var _a, _b;
|
|
4207
4267
|
if (!node) return;
|
|
4208
4268
|
const attributes = (_a = node.attributes) != null ? _a : {};
|
|
4209
4269
|
const inheritedMask = masked || hasClass(attributes, policy.maskClass);
|
|
4270
|
+
const inheritedEditable = editable || node.type === ELEMENT_NODE && isEditableSerialised(attributes);
|
|
4271
|
+
if (editable && node.type === TEXT_NODE && typeof node.textContent === "string") {
|
|
4272
|
+
node.textContent = maskTyped(node.textContent);
|
|
4273
|
+
}
|
|
4210
4274
|
if (node.type === ELEMENT_NODE && node.tagName && FIELD_TAGS.has(node.tagName.toLowerCase())) {
|
|
4211
4275
|
const value = attributes.value;
|
|
4212
|
-
const shouldMask = policy.maskEveryField || inheritedMask || isSensitiveSerialised(attributes) || typeof value === "string" &&
|
|
4276
|
+
const shouldMask = policy.maskEveryField || inheritedMask || isSensitiveSerialised(attributes) || typeof value === "string" && cardShaped(value);
|
|
4213
4277
|
if (shouldMask) {
|
|
4214
4278
|
if (typeof value === "string" && value !== "") attributes.value = maskValue(value);
|
|
4215
4279
|
if (node.tagName.toLowerCase() === "textarea") maskTextChildren(node);
|
|
4280
|
+
} else {
|
|
4281
|
+
if (typeof value === "string") attributes.value = maskCardRuns(value);
|
|
4282
|
+
if (node.tagName.toLowerCase() === "textarea") maskCardRunsInChildren(node);
|
|
4216
4283
|
}
|
|
4217
4284
|
}
|
|
4218
|
-
for (const child of (_b = node.childNodes) != null ? _b : []) scrubTree(child, policy, inheritedMask);
|
|
4285
|
+
for (const child of (_b = node.childNodes) != null ? _b : []) scrubTree(child, policy, inheritedMask, inheritedEditable);
|
|
4219
4286
|
}
|
|
4220
4287
|
function scrubEvent(event, getNode, policy) {
|
|
4221
|
-
var _a, _b, _c, _d, _e;
|
|
4288
|
+
var _a, _b, _c, _d, _e, _f;
|
|
4222
4289
|
if (!event.data) return;
|
|
4223
4290
|
if (event.type === 2) {
|
|
4224
4291
|
scrubTree(event.data.node, policy, false);
|
|
@@ -4231,7 +4298,7 @@ function scrubEvent(event, getNode, policy) {
|
|
|
4231
4298
|
const node = typeof data.id === "number" ? getNode(data.id) : null;
|
|
4232
4299
|
const type = ((_b = (_a = asElement(node)) == null ? void 0 : _a.type) != null ? _b : "").toLowerCase();
|
|
4233
4300
|
if (type === "checkbox" || type === "radio") return;
|
|
4234
|
-
|
|
4301
|
+
data.text = shouldMaskValue(data.text, node, policy) ? maskValue(data.text) : maskCardRuns(data.text);
|
|
4235
4302
|
return;
|
|
4236
4303
|
}
|
|
4237
4304
|
if (data.source !== MUTATION) return;
|
|
@@ -4239,11 +4306,15 @@ function scrubEvent(event, getNode, policy) {
|
|
|
4239
4306
|
const value = (_d = entry == null ? void 0 : entry.attributes) == null ? void 0 : _d.value;
|
|
4240
4307
|
if (typeof value !== "string" || value === "") continue;
|
|
4241
4308
|
const node = typeof entry.id === "number" ? getNode(entry.id) : null;
|
|
4242
|
-
|
|
4309
|
+
entry.attributes.value = shouldMaskValue(value, node, policy) ? maskValue(value) : maskCardRuns(value);
|
|
4243
4310
|
}
|
|
4244
4311
|
for (const add of (_e = data.adds) != null ? _e : []) {
|
|
4245
4312
|
const parent = typeof (add == null ? void 0 : add.parentId) === "number" ? getNode(add.parentId) : null;
|
|
4246
|
-
scrubTree(add == null ? void 0 : add.node, policy, isMarkedByCustomer(parent, policy.maskClass));
|
|
4313
|
+
scrubTree(add == null ? void 0 : add.node, policy, isMarkedByCustomer(parent, policy.maskClass), insideEditable(parent));
|
|
4314
|
+
}
|
|
4315
|
+
for (const change of (_f = data.texts) != null ? _f : []) {
|
|
4316
|
+
if (typeof (change == null ? void 0 : change.value) !== "string" || !/[0-9]/.test(change.value)) continue;
|
|
4317
|
+
if (typeof change.id === "number" && insideEditable(getNode(change.id))) change.value = maskTyped(change.value);
|
|
4247
4318
|
}
|
|
4248
4319
|
}
|
|
4249
4320
|
|
|
@@ -5049,7 +5120,7 @@ var Transport = class {
|
|
|
5049
5120
|
|
|
5050
5121
|
// src/version.ts
|
|
5051
5122
|
var SDK_NAME = "@anyreplay/browser";
|
|
5052
|
-
var SDK_VERSION = true ? "0.2.
|
|
5123
|
+
var SDK_VERSION = true ? "0.2.2" : "0.0.0-dev";
|
|
5053
5124
|
var PLATFORM = "web";
|
|
5054
5125
|
|
|
5055
5126
|
// src/visitor.ts
|
|
@@ -5156,6 +5227,8 @@ function forgetVisitor(local, session) {
|
|
|
5156
5227
|
}
|
|
5157
5228
|
|
|
5158
5229
|
// src/recorder.ts
|
|
5230
|
+
var RRWEB_DOM_CONTENT_LOADED = 0;
|
|
5231
|
+
var RRWEB_LOAD = 1;
|
|
5159
5232
|
var RAGE_CLICK_COUNT = 3;
|
|
5160
5233
|
var RAGE_CLICK_WINDOW_MS = 1e3;
|
|
5161
5234
|
var RAGE_CLICK_RADIUS_PX = 30;
|
|
@@ -5295,7 +5368,14 @@ function createRecorder(rawOptions, shell) {
|
|
|
5295
5368
|
initialSeq: who.nextSeq
|
|
5296
5369
|
},
|
|
5297
5370
|
{
|
|
5298
|
-
onSeqReserved: (next) =>
|
|
5371
|
+
onSeqReserved: (next) => {
|
|
5372
|
+
var _a;
|
|
5373
|
+
reserveSeq(session, next);
|
|
5374
|
+
try {
|
|
5375
|
+
(_a = shell == null ? void 0 : shell.onStateChanged) == null ? void 0 : _a.call(shell);
|
|
5376
|
+
} catch (e) {
|
|
5377
|
+
}
|
|
5378
|
+
},
|
|
5299
5379
|
onStopped: (reason) => {
|
|
5300
5380
|
status = "stopped";
|
|
5301
5381
|
stopRecording == null ? void 0 : stopRecording();
|
|
@@ -5353,6 +5433,7 @@ function createRecorder(rawOptions, shell) {
|
|
|
5353
5433
|
stopRecording = record({
|
|
5354
5434
|
emit: (event) => {
|
|
5355
5435
|
const typed = event;
|
|
5436
|
+
if (typed.type === RRWEB_DOM_CONTENT_LOADED || typed.type === RRWEB_LOAD) return;
|
|
5356
5437
|
scrubEvent(typed, (id) => record.mirror.getNode(id), policy);
|
|
5357
5438
|
assets == null ? void 0 : assets.scan(typed);
|
|
5358
5439
|
noteInteraction(typed, channel);
|