@openreplay/tracker 17.2.9 → 17.2.11
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/cjs/entry.js +678 -117
- package/dist/cjs/entry.js.map +1 -1
- package/dist/cjs/index.js +615 -100
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/main/app/canvas.d.ts +6 -0
- package/dist/cjs/main/app/index.d.ts +67 -1
- package/dist/cjs/main/app/observer/observer.d.ts +10 -0
- package/dist/cjs/main/app/sanitizer.d.ts +5 -3
- package/dist/cjs/main/index.d.ts +17 -2
- package/dist/cjs/main/singleton.d.ts +25 -1
- package/dist/lib/entry.js +678 -117
- package/dist/lib/entry.js.map +1 -1
- package/dist/lib/index.js +615 -100
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/main/app/canvas.d.ts +6 -0
- package/dist/lib/main/app/index.d.ts +67 -1
- package/dist/lib/main/app/observer/observer.d.ts +10 -0
- package/dist/lib/main/app/sanitizer.d.ts +5 -3
- package/dist/lib/main/index.d.ts +17 -2
- package/dist/lib/main/singleton.d.ts +25 -1
- package/dist/types/main/app/canvas.d.ts +6 -0
- package/dist/types/main/app/index.d.ts +67 -1
- package/dist/types/main/app/observer/observer.d.ts +10 -0
- package/dist/types/main/app/sanitizer.d.ts +5 -3
- package/dist/types/main/index.d.ts +17 -2
- package/dist/types/main/singleton.d.ts +25 -1
- package/package.json +1 -1
package/dist/lib/index.js
CHANGED
|
@@ -1784,6 +1784,30 @@ class CanvasRecorder {
|
|
|
1784
1784
|
this.MAX_QUEUE_SIZE = 50; // ~500 images max (50 batches × 10 images)
|
|
1785
1785
|
this.pendingBatches = [];
|
|
1786
1786
|
this.isProcessingQueue = false;
|
|
1787
|
+
/**
|
|
1788
|
+
* Reacts to a runtime sanitization change on a canvas: stop capturing if it
|
|
1789
|
+
* just became masked, start if it just became visible. (Already-sent frames
|
|
1790
|
+
* can't be retracted — escalation only stops future capture.)
|
|
1791
|
+
*/
|
|
1792
|
+
this.resanitizeCanvas = (node, id) => {
|
|
1793
|
+
if (!hasTag(node, 'canvas')) {
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
const isIgnored = this.app.sanitizer.isObscured(id) || this.app.sanitizer.isHidden(id);
|
|
1797
|
+
if (isIgnored) {
|
|
1798
|
+
if (this.snapshots[id] || this.observers.has(id)) {
|
|
1799
|
+
const observer = this.observers.get(id);
|
|
1800
|
+
if (observer) {
|
|
1801
|
+
observer.disconnect();
|
|
1802
|
+
this.observers.delete(id);
|
|
1803
|
+
}
|
|
1804
|
+
this.cleanupCanvas(id);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
else if (!this.snapshots[id] && !this.observers.has(id)) {
|
|
1808
|
+
this.captureCanvas(node);
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1787
1811
|
this.restartTracking = () => {
|
|
1788
1812
|
this.clear();
|
|
1789
1813
|
this.app.nodes.scanTree(this.captureCanvas);
|
|
@@ -1887,6 +1911,7 @@ class CanvasRecorder {
|
|
|
1887
1911
|
setTimeout(() => {
|
|
1888
1912
|
this.app.nodes.scanTree(this.captureCanvas);
|
|
1889
1913
|
this.app.nodes.attachNodeCallback(this.captureCanvas);
|
|
1914
|
+
this.app.attachResanitizeCallback(this.resanitizeCanvas);
|
|
1890
1915
|
}, 125);
|
|
1891
1916
|
}
|
|
1892
1917
|
sendSnaps(images, canvasId, createdAt) {
|
|
@@ -2758,6 +2783,120 @@ function ConstructedStyleSheets (app) {
|
|
|
2758
2783
|
});
|
|
2759
2784
|
}
|
|
2760
2785
|
|
|
2786
|
+
var SanitizeLevel;
|
|
2787
|
+
(function (SanitizeLevel) {
|
|
2788
|
+
SanitizeLevel[SanitizeLevel["Plain"] = 0] = "Plain";
|
|
2789
|
+
SanitizeLevel[SanitizeLevel["Obscured"] = 1] = "Obscured";
|
|
2790
|
+
SanitizeLevel[SanitizeLevel["Hidden"] = 2] = "Hidden";
|
|
2791
|
+
})(SanitizeLevel || (SanitizeLevel = {}));
|
|
2792
|
+
const stringWiper = (input) => input
|
|
2793
|
+
.trim()
|
|
2794
|
+
.replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*');
|
|
2795
|
+
class Sanitizer {
|
|
2796
|
+
constructor(params) {
|
|
2797
|
+
// Node id -> level. Plain (0) is never stored; a missing entry means Plain.
|
|
2798
|
+
// A map (not the old grow-only Sets) so levels can be raised and lowered.
|
|
2799
|
+
this.levels = new Map();
|
|
2800
|
+
this.app = params.app;
|
|
2801
|
+
const defaultOptions = {
|
|
2802
|
+
obscureTextEmails: true,
|
|
2803
|
+
obscureTextNumbers: false,
|
|
2804
|
+
privateMode: false,
|
|
2805
|
+
domSanitizer: undefined,
|
|
2806
|
+
};
|
|
2807
|
+
this.privateMode = params.options?.privateMode ?? false;
|
|
2808
|
+
this.options = Object.assign(defaultOptions, params.options);
|
|
2809
|
+
}
|
|
2810
|
+
// Pure recomputation of a node's level from the live DOM + parent level.
|
|
2811
|
+
// Reading current state on every call is what lets resanitize() pick up
|
|
2812
|
+
// runtime attribute/domSanitizer changes.
|
|
2813
|
+
computeLevel(node, parentLevel) {
|
|
2814
|
+
if (this.options.privateMode) {
|
|
2815
|
+
if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) {
|
|
2816
|
+
return SanitizeLevel.Obscured;
|
|
2817
|
+
}
|
|
2818
|
+
if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode, 'unmask')) {
|
|
2819
|
+
return SanitizeLevel.Obscured;
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
let level = SanitizeLevel.Plain;
|
|
2823
|
+
if (parentLevel >= SanitizeLevel.Obscured ||
|
|
2824
|
+
(isElementNode(node) &&
|
|
2825
|
+
(hasOpenreplayAttribute(node, 'masked') || hasOpenreplayAttribute(node, 'obscured')))) {
|
|
2826
|
+
level = SanitizeLevel.Obscured;
|
|
2827
|
+
}
|
|
2828
|
+
if (parentLevel === SanitizeLevel.Hidden ||
|
|
2829
|
+
(isElementNode(node) &&
|
|
2830
|
+
(hasOpenreplayAttribute(node, 'htmlmasked') || hasOpenreplayAttribute(node, 'hidden')))) {
|
|
2831
|
+
level = SanitizeLevel.Hidden;
|
|
2832
|
+
}
|
|
2833
|
+
if (this.options.domSanitizer !== undefined && isElementNode(node)) {
|
|
2834
|
+
const sanitizeLevel = this.options.domSanitizer(node);
|
|
2835
|
+
if (sanitizeLevel === SanitizeLevel.Obscured && level < SanitizeLevel.Obscured) {
|
|
2836
|
+
level = SanitizeLevel.Obscured;
|
|
2837
|
+
}
|
|
2838
|
+
if (sanitizeLevel === SanitizeLevel.Hidden) {
|
|
2839
|
+
level = SanitizeLevel.Hidden;
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
return level;
|
|
2843
|
+
}
|
|
2844
|
+
getLevel(id) {
|
|
2845
|
+
return this.levels.get(id) ?? SanitizeLevel.Plain;
|
|
2846
|
+
}
|
|
2847
|
+
// Sets a node's level (either direction) and returns the previous one.
|
|
2848
|
+
setLevel(id, level) {
|
|
2849
|
+
const prev = this.getLevel(id);
|
|
2850
|
+
if (level === SanitizeLevel.Plain) {
|
|
2851
|
+
this.levels.delete(id);
|
|
2852
|
+
}
|
|
2853
|
+
else {
|
|
2854
|
+
this.levels.set(id, level);
|
|
2855
|
+
}
|
|
2856
|
+
return prev;
|
|
2857
|
+
}
|
|
2858
|
+
handleNode(id, parentID, node) {
|
|
2859
|
+
const level = this.computeLevel(node, this.getLevel(parentID));
|
|
2860
|
+
// Escalate-only: commits never lower a level, only resanitize/setLevel do.
|
|
2861
|
+
if (level > this.getLevel(id)) {
|
|
2862
|
+
this.setLevel(id, level);
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
sanitize(id, data) {
|
|
2866
|
+
if (this.getLevel(id) >= SanitizeLevel.Obscured) {
|
|
2867
|
+
// TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
|
|
2868
|
+
return stringWiper(data);
|
|
2869
|
+
}
|
|
2870
|
+
if (this.options.obscureTextNumbers) {
|
|
2871
|
+
data = data.replace(/\d/g, '0');
|
|
2872
|
+
}
|
|
2873
|
+
if (this.options.obscureTextEmails) {
|
|
2874
|
+
data = data.replace(/^\w+([+.-]\w+)*@\w+([.-]\w+)*\.\w{2,3}$/g, (email) => {
|
|
2875
|
+
const [name, domain] = email.split('@');
|
|
2876
|
+
const [domainName, host] = domain.split('.');
|
|
2877
|
+
return `${stars(name)}@${stars(domainName)}.${stars(host)}`;
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
return data;
|
|
2881
|
+
}
|
|
2882
|
+
isObscured(id) {
|
|
2883
|
+
return this.getLevel(id) >= SanitizeLevel.Obscured;
|
|
2884
|
+
}
|
|
2885
|
+
isHidden(id) {
|
|
2886
|
+
return this.getLevel(id) === SanitizeLevel.Hidden;
|
|
2887
|
+
}
|
|
2888
|
+
getInnerTextSecure(el) {
|
|
2889
|
+
const id = this.app.nodes.getID(el);
|
|
2890
|
+
if (!id) {
|
|
2891
|
+
return '';
|
|
2892
|
+
}
|
|
2893
|
+
return this.sanitize(id, el.innerText);
|
|
2894
|
+
}
|
|
2895
|
+
clear() {
|
|
2896
|
+
this.levels.clear();
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2761
2900
|
const iconCache = {};
|
|
2762
2901
|
const svgUrlCache = {};
|
|
2763
2902
|
async function parseUseEl(useElement, mode, domParser) {
|
|
@@ -3337,6 +3476,100 @@ class Observer {
|
|
|
3337
3476
|
beforeCommit(this.app.nodes.getID(node));
|
|
3338
3477
|
this.commitNodes(true);
|
|
3339
3478
|
}
|
|
3479
|
+
/**
|
|
3480
|
+
* Re-evaluates sanitization for every tracked node in `root`'s subtree against
|
|
3481
|
+
* the current DOM and re-emits whatever changed. Pass the highest node you
|
|
3482
|
+
* changed (or the document root) so inherited levels propagate correctly.
|
|
3483
|
+
*/
|
|
3484
|
+
resanitizeSubtree(root) {
|
|
3485
|
+
if (!isObservable(root)) {
|
|
3486
|
+
return;
|
|
3487
|
+
}
|
|
3488
|
+
const parent = root.parentNode;
|
|
3489
|
+
const parentId = parent !== null ? this.app.nodes.getID(parent) : undefined;
|
|
3490
|
+
const parentLevel = parentId !== undefined ? this.app.sanitizer.getLevel(parentId) : SanitizeLevel.Plain;
|
|
3491
|
+
this.resanitizeNode(root, parentLevel);
|
|
3492
|
+
}
|
|
3493
|
+
resanitizeNode(node, parentLevel) {
|
|
3494
|
+
if (isIgnored(node)) {
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
const id = this.app.nodes.getID(node);
|
|
3498
|
+
if (id === undefined) {
|
|
3499
|
+
// Untracked (new, or under a hidden ancestor): the live observer handles it.
|
|
3500
|
+
return;
|
|
3501
|
+
}
|
|
3502
|
+
const newLevel = this.app.sanitizer.computeLevel(node, parentLevel);
|
|
3503
|
+
const prevLevel = this.app.sanitizer.getLevel(id);
|
|
3504
|
+
const wasHidden = prevLevel === SanitizeLevel.Hidden;
|
|
3505
|
+
const willHidden = newLevel === SanitizeLevel.Hidden;
|
|
3506
|
+
// Crossing the hidden boundary changes the rendered structure (placeholder vs
|
|
3507
|
+
// real subtree), so rebuild rather than re-emit.
|
|
3508
|
+
if (wasHidden !== willHidden) {
|
|
3509
|
+
this.recreateSubtree(node);
|
|
3510
|
+
return;
|
|
3511
|
+
}
|
|
3512
|
+
if (willHidden) {
|
|
3513
|
+
return;
|
|
3514
|
+
}
|
|
3515
|
+
// Plain <-> Obscured: same structure, only leaf content changes.
|
|
3516
|
+
if (prevLevel !== newLevel) {
|
|
3517
|
+
this.app.sanitizer.setLevel(id, newLevel);
|
|
3518
|
+
this.reemitNode(id, node);
|
|
3519
|
+
}
|
|
3520
|
+
for (let child = node.firstChild; child !== null; child = child.nextSibling) {
|
|
3521
|
+
this.resanitizeNode(child, newLevel);
|
|
3522
|
+
}
|
|
3523
|
+
}
|
|
3524
|
+
// Destroys the node player-side and re-emits its subtree from scratch (new ids)
|
|
3525
|
+
// so it materializes at the freshly-computed level.
|
|
3526
|
+
recreateSubtree(node) {
|
|
3527
|
+
const id = this.app.nodes.getID(node);
|
|
3528
|
+
if (id === undefined) {
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
3531
|
+
this.app.send(RemoveNode(id));
|
|
3532
|
+
this.clearSubtreeRegistration(node);
|
|
3533
|
+
this.bindTree(node);
|
|
3534
|
+
this.commitNodes();
|
|
3535
|
+
}
|
|
3536
|
+
clearSubtreeRegistration(node) {
|
|
3537
|
+
const clearOne = (n) => {
|
|
3538
|
+
const oldId = this.app.nodes.getID(n);
|
|
3539
|
+
if (oldId !== undefined) {
|
|
3540
|
+
this.app.sanitizer.setLevel(oldId, SanitizeLevel.Plain);
|
|
3541
|
+
}
|
|
3542
|
+
this.app.nodes.unregisterNode(n);
|
|
3543
|
+
};
|
|
3544
|
+
const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT + NodeFilter.SHOW_TEXT, {
|
|
3545
|
+
acceptNode: (n) => isIgnored(n) || this.app.nodes.getID(n) === undefined
|
|
3546
|
+
? NodeFilter.FILTER_REJECT
|
|
3547
|
+
: NodeFilter.FILTER_ACCEPT,
|
|
3548
|
+
},
|
|
3549
|
+
// @ts-ignore
|
|
3550
|
+
false);
|
|
3551
|
+
// Collect first, then clear: unregistering mutates the ids the walker reads.
|
|
3552
|
+
const subtree = [];
|
|
3553
|
+
while (walker.nextNode()) {
|
|
3554
|
+
subtree.push(walker.currentNode);
|
|
3555
|
+
}
|
|
3556
|
+
clearOne(node);
|
|
3557
|
+
subtree.forEach(clearOne);
|
|
3558
|
+
}
|
|
3559
|
+
reemitNode(id, node) {
|
|
3560
|
+
if (isTextNode(node)) {
|
|
3561
|
+
const parent = node.parentNode;
|
|
3562
|
+
if (parent !== null && isElementNode(parent)) {
|
|
3563
|
+
// re-runs sanitize() at the level we just set
|
|
3564
|
+
this.sendNodeData(id, parent, node.data);
|
|
3565
|
+
}
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
if (isElementNode(node)) {
|
|
3569
|
+
// inputs/images/canvas re-emit their own payload via registered callbacks
|
|
3570
|
+
this.app.callResanitizeCallbacks(node, id);
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3340
3573
|
disconnect() {
|
|
3341
3574
|
this.observer.disconnect();
|
|
3342
3575
|
this.clear();
|
|
@@ -3659,94 +3892,6 @@ class TopObserver extends Observer {
|
|
|
3659
3892
|
}
|
|
3660
3893
|
}
|
|
3661
3894
|
|
|
3662
|
-
var SanitizeLevel;
|
|
3663
|
-
(function (SanitizeLevel) {
|
|
3664
|
-
SanitizeLevel[SanitizeLevel["Plain"] = 0] = "Plain";
|
|
3665
|
-
SanitizeLevel[SanitizeLevel["Obscured"] = 1] = "Obscured";
|
|
3666
|
-
SanitizeLevel[SanitizeLevel["Hidden"] = 2] = "Hidden";
|
|
3667
|
-
})(SanitizeLevel || (SanitizeLevel = {}));
|
|
3668
|
-
const stringWiper = (input) => input
|
|
3669
|
-
.trim()
|
|
3670
|
-
.replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*');
|
|
3671
|
-
class Sanitizer {
|
|
3672
|
-
constructor(params) {
|
|
3673
|
-
this.obscured = new Set();
|
|
3674
|
-
this.hidden = new Set();
|
|
3675
|
-
this.app = params.app;
|
|
3676
|
-
const defaultOptions = {
|
|
3677
|
-
obscureTextEmails: true,
|
|
3678
|
-
obscureTextNumbers: false,
|
|
3679
|
-
privateMode: false,
|
|
3680
|
-
domSanitizer: undefined,
|
|
3681
|
-
};
|
|
3682
|
-
this.privateMode = params.options?.privateMode ?? false;
|
|
3683
|
-
this.options = Object.assign(defaultOptions, params.options);
|
|
3684
|
-
}
|
|
3685
|
-
handleNode(id, parentID, node) {
|
|
3686
|
-
if (this.options.privateMode) {
|
|
3687
|
-
if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) {
|
|
3688
|
-
return this.obscured.add(id);
|
|
3689
|
-
}
|
|
3690
|
-
if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode, 'unmask')) {
|
|
3691
|
-
return this.obscured.add(id);
|
|
3692
|
-
}
|
|
3693
|
-
}
|
|
3694
|
-
if (this.obscured.has(parentID) ||
|
|
3695
|
-
(isElementNode(node) &&
|
|
3696
|
-
(hasOpenreplayAttribute(node, 'masked') || hasOpenreplayAttribute(node, 'obscured')))) {
|
|
3697
|
-
this.obscured.add(id);
|
|
3698
|
-
}
|
|
3699
|
-
if (this.hidden.has(parentID) ||
|
|
3700
|
-
(isElementNode(node) &&
|
|
3701
|
-
(hasOpenreplayAttribute(node, 'htmlmasked') || hasOpenreplayAttribute(node, 'hidden')))) {
|
|
3702
|
-
this.hidden.add(id);
|
|
3703
|
-
}
|
|
3704
|
-
if (this.options.domSanitizer !== undefined && isElementNode(node)) {
|
|
3705
|
-
const sanitizeLevel = this.options.domSanitizer(node);
|
|
3706
|
-
if (sanitizeLevel === SanitizeLevel.Obscured) {
|
|
3707
|
-
this.obscured.add(id);
|
|
3708
|
-
}
|
|
3709
|
-
if (sanitizeLevel === SanitizeLevel.Hidden) {
|
|
3710
|
-
this.hidden.add(id);
|
|
3711
|
-
}
|
|
3712
|
-
}
|
|
3713
|
-
}
|
|
3714
|
-
sanitize(id, data) {
|
|
3715
|
-
if (this.obscured.has(id)) {
|
|
3716
|
-
// TODO: is it the best place to put trim() ? Might trimmed spaces be considered in layout in certain cases?
|
|
3717
|
-
return stringWiper(data);
|
|
3718
|
-
}
|
|
3719
|
-
if (this.options.obscureTextNumbers) {
|
|
3720
|
-
data = data.replace(/\d/g, '0');
|
|
3721
|
-
}
|
|
3722
|
-
if (this.options.obscureTextEmails) {
|
|
3723
|
-
data = data.replace(/^\w+([+.-]\w+)*@\w+([.-]\w+)*\.\w{2,3}$/g, (email) => {
|
|
3724
|
-
const [name, domain] = email.split('@');
|
|
3725
|
-
const [domainName, host] = domain.split('.');
|
|
3726
|
-
return `${stars(name)}@${stars(domainName)}.${stars(host)}`;
|
|
3727
|
-
});
|
|
3728
|
-
}
|
|
3729
|
-
return data;
|
|
3730
|
-
}
|
|
3731
|
-
isObscured(id) {
|
|
3732
|
-
return this.obscured.has(id);
|
|
3733
|
-
}
|
|
3734
|
-
isHidden(id) {
|
|
3735
|
-
return this.hidden.has(id);
|
|
3736
|
-
}
|
|
3737
|
-
getInnerTextSecure(el) {
|
|
3738
|
-
const id = this.app.nodes.getID(el);
|
|
3739
|
-
if (!id) {
|
|
3740
|
-
return '';
|
|
3741
|
-
}
|
|
3742
|
-
return this.sanitize(id, el.innerText);
|
|
3743
|
-
}
|
|
3744
|
-
clear() {
|
|
3745
|
-
this.obscured.clear();
|
|
3746
|
-
this.hidden.clear();
|
|
3747
|
-
}
|
|
3748
|
-
}
|
|
3749
|
-
|
|
3750
3895
|
const tokenSeparator = '_$_';
|
|
3751
3896
|
class Session {
|
|
3752
3897
|
constructor(params) {
|
|
@@ -3981,12 +4126,16 @@ const proto = {
|
|
|
3981
4126
|
parentAlive: 'signal that parent is live',
|
|
3982
4127
|
killIframe: 'stop tracker inside frame',
|
|
3983
4128
|
startIframe: 'start tracker inside frame',
|
|
4129
|
+
// child -> parent: once-per-minute encoded debug snapshot from inside an iframe
|
|
4130
|
+
iframeDebug: 'iframe debug snapshot',
|
|
3984
4131
|
// checking updates
|
|
3985
4132
|
polling: 'hello-how-are-you-im-under-the-water-please-help-me',
|
|
3986
4133
|
// happens if tab is old and has outdated token but
|
|
3987
4134
|
// not communicating with backend to update it (for whatever reason)
|
|
3988
4135
|
reset: 'reset-your-session-please',
|
|
3989
4136
|
};
|
|
4137
|
+
/** reverse map proto value -> short readable key, for the crossdomain debug log */
|
|
4138
|
+
const protoLabel = Object.fromEntries(Object.entries(proto).map(([k, v]) => [v, k]));
|
|
3990
4139
|
class App {
|
|
3991
4140
|
get tagMatcher() {
|
|
3992
4141
|
return this.tagWatcher.matcher;
|
|
@@ -3994,6 +4143,8 @@ class App {
|
|
|
3994
4143
|
constructor(projectKey, sessionToken, options, signalError, insideIframe) {
|
|
3995
4144
|
this.signalError = signalError;
|
|
3996
4145
|
this.insideIframe = insideIframe;
|
|
4146
|
+
// Registered by input/img/canvas to re-emit a node when its level changes.
|
|
4147
|
+
this.resanitizeCallbacks = [];
|
|
3997
4148
|
this.messages = [];
|
|
3998
4149
|
/**
|
|
3999
4150
|
* we need 2 buffers, so we don't lose anything
|
|
@@ -4005,7 +4156,7 @@ class App {
|
|
|
4005
4156
|
this.stopCallbacks = [];
|
|
4006
4157
|
this.commitCallbacks = [];
|
|
4007
4158
|
this.activityState = ActivityState.NotActive;
|
|
4008
|
-
this.version = '17.2.
|
|
4159
|
+
this.version = '17.2.11'; // TODO: version compatability check inside each plugin.
|
|
4009
4160
|
this.socketMode = false;
|
|
4010
4161
|
this.compressionThreshold = 24 * 1000;
|
|
4011
4162
|
this.bc = null;
|
|
@@ -4022,10 +4173,64 @@ class App {
|
|
|
4022
4173
|
this.checkStatus = () => {
|
|
4023
4174
|
return this.parentActive;
|
|
4024
4175
|
};
|
|
4176
|
+
/** child-side crossdomain debug state (only meaningful when insideIframe) */
|
|
4177
|
+
this.lastTokenReceived = null;
|
|
4178
|
+
this.lastParentMsgAt = 0;
|
|
4179
|
+
this.lastSentToParentAt = 0;
|
|
4180
|
+
this.iframeDebugInterval = null;
|
|
4181
|
+
/**
|
|
4182
|
+
* Child-side counterpart of emitCrossdomainDebug: once per minute an iframe posts an
|
|
4183
|
+
* encoded snapshot of its own tracking state up to the parent, which records it as a
|
|
4184
|
+
* console log. Posted directly (not via this.send) so it is reported even when the
|
|
4185
|
+
* child is NOT active — an inactive/orphaned child is exactly what we want to catch.
|
|
4186
|
+
*/
|
|
4187
|
+
this.emitIframeDebug = () => {
|
|
4188
|
+
if (!this.insideIframe || !this.options.crossdomain?.enabled)
|
|
4189
|
+
return;
|
|
4190
|
+
const now = Date.now();
|
|
4191
|
+
const rel = (t) => (t ? now - t : null);
|
|
4192
|
+
const payload = {
|
|
4193
|
+
ctx: this.contextId,
|
|
4194
|
+
active: this.active(),
|
|
4195
|
+
state: ActivityState[this.activityState],
|
|
4196
|
+
parentActive: this.parentActive,
|
|
4197
|
+
rootId: this.rootId,
|
|
4198
|
+
frameOrder: this.frameOderNumber,
|
|
4199
|
+
// when and what token we last received from the parent (token truncated)
|
|
4200
|
+
token: this.lastTokenReceived
|
|
4201
|
+
? { tok: this.lastTokenReceived.tok, agoMs: now - this.lastTokenReceived.at }
|
|
4202
|
+
: null,
|
|
4203
|
+
// last two-way communication with the parent
|
|
4204
|
+
lastParentMsgAgoMs: rel(this.lastParentMsgAt),
|
|
4205
|
+
lastSentToParentAgoMs: rel(this.lastSentToParentAt),
|
|
4206
|
+
};
|
|
4207
|
+
const json = JSON.stringify(payload);
|
|
4208
|
+
let encoded;
|
|
4209
|
+
try {
|
|
4210
|
+
encoded = btoa(json);
|
|
4211
|
+
}
|
|
4212
|
+
catch {
|
|
4213
|
+
encoded = json;
|
|
4214
|
+
}
|
|
4215
|
+
try {
|
|
4216
|
+
window.parent.postMessage({ line: proto.iframeDebug, context: this.contextId, debug: encoded }, this.options.crossdomain?.parentDomain ?? '*');
|
|
4217
|
+
this.markSentToParent();
|
|
4218
|
+
}
|
|
4219
|
+
catch (e) {
|
|
4220
|
+
this.debug.error('iframe debug post failed', e);
|
|
4221
|
+
}
|
|
4222
|
+
};
|
|
4025
4223
|
this.parentCrossDomainFrameListener = (event) => {
|
|
4026
4224
|
const { data } = event;
|
|
4027
4225
|
if (!data || event.source === window)
|
|
4028
4226
|
return;
|
|
4227
|
+
// Debug: remember the last time the parent talked to us.
|
|
4228
|
+
if (data.line === proto.startIframe ||
|
|
4229
|
+
data.line === proto.parentAlive ||
|
|
4230
|
+
data.line === proto.iframeId ||
|
|
4231
|
+
data.line === proto.killIframe) {
|
|
4232
|
+
this.lastParentMsgAt = Date.now();
|
|
4233
|
+
}
|
|
4029
4234
|
if (data.line === proto.startIframe) {
|
|
4030
4235
|
// Avoid corrupting an in-flight start; let it complete.
|
|
4031
4236
|
if (this.activityState === ActivityState.Starting)
|
|
@@ -4036,6 +4241,7 @@ class App {
|
|
|
4036
4241
|
}
|
|
4037
4242
|
if (data.token) {
|
|
4038
4243
|
this.session.setSessionToken(data.token, this.projectKey);
|
|
4244
|
+
this.lastTokenReceived = { tok: String(data.token).slice(-8), at: Date.now() };
|
|
4039
4245
|
}
|
|
4040
4246
|
if (data.id !== undefined) {
|
|
4041
4247
|
this.rootId = data.id;
|
|
@@ -4054,6 +4260,7 @@ class App {
|
|
|
4054
4260
|
this.parentActive = true;
|
|
4055
4261
|
this.rootId = data.id;
|
|
4056
4262
|
this.session.setSessionToken(data.token, this.projectKey);
|
|
4263
|
+
this.lastTokenReceived = { tok: String(data.token).slice(-8), at: Date.now() };
|
|
4057
4264
|
this.frameOderNumber = data.frameOrderNumber;
|
|
4058
4265
|
this.frameLevel = data.frameLevel;
|
|
4059
4266
|
this.debug.log('starting iframe tracking', data);
|
|
@@ -4066,14 +4273,110 @@ class App {
|
|
|
4066
4273
|
}
|
|
4067
4274
|
};
|
|
4068
4275
|
this.trackedFrames = [];
|
|
4276
|
+
/** every context that has been enrolled at least once, to tell an orphan (re-adopt) apart
|
|
4277
|
+
* from a brand-new child still mid-enrollment (leave alone). */
|
|
4278
|
+
this.everTrackedFrames = new Set();
|
|
4069
4279
|
this.frameLastSeen = new Map();
|
|
4280
|
+
/** crossdomain debug diagnostics, reported once per minute as an encoded console log */
|
|
4281
|
+
this.frameOrigin = new Map();
|
|
4282
|
+
this.frameAnyLastSeen = new Map();
|
|
4283
|
+
this.frameBatchLastSeen = new Map();
|
|
4284
|
+
this.frameLastSent = new Map();
|
|
4285
|
+
this.xdomainDebugInterval = null;
|
|
4286
|
+
/** last time we re-adopted a given orphaned context, to avoid restart spam */
|
|
4287
|
+
this.reAdoptCooldown = new Map();
|
|
4288
|
+
this.RE_ADOPT_COOLDOWN_MS = 2000;
|
|
4289
|
+
/**
|
|
4290
|
+
* Stable, collision-free frame-order allocation. Node ids are partitioned by
|
|
4291
|
+
* (frameLevel, frameOrder) via pack() — every (level, order) owns its own id block, so
|
|
4292
|
+
* two simultaneously-live frames sharing an order at the same level corrupt each other's
|
|
4293
|
+
* node trees and one stops rendering. The previous `trackedFrames.findIndex+1` derived
|
|
4294
|
+
* order from a mutable array index, and pruneStaleFrames()'s .filter() shifts those
|
|
4295
|
+
* indices, so a newly enrolled frame could be handed an order still in use by a live
|
|
4296
|
+
* (but pruned) frame. We instead assign each context a persistent order, unique among all
|
|
4297
|
+
* non-recycled contexts at its level, freed only when the context is GC'd (truly gone).
|
|
4298
|
+
*/
|
|
4299
|
+
this.frameAlloc = new Map();
|
|
4300
|
+
this.usedOrdersByLevel = new Map();
|
|
4070
4301
|
this.FRAME_STALE_MS = 1500;
|
|
4302
|
+
/**
|
|
4303
|
+
* Once per minute: emit an encoded console log from the parent tracker describing every
|
|
4304
|
+
* tracked child iframe and the freshness of our two-way communication with it. Lets us
|
|
4305
|
+
* see in replay which crossdomain iframe went silent and on which leg of the handshake.
|
|
4306
|
+
*/
|
|
4307
|
+
/** drop debug entries for contexts we have neither heard from nor messaged in this long */
|
|
4308
|
+
this.XDOMAIN_DEBUG_RETENTION_MS = 10 * 60000;
|
|
4309
|
+
this.emitCrossdomainDebug = () => {
|
|
4310
|
+
if (this.insideIframe || !this.options.crossdomain?.enabled || !this.active())
|
|
4311
|
+
return;
|
|
4312
|
+
const now = Date.now();
|
|
4313
|
+
const rel = (t) => (t === undefined ? null : now - t);
|
|
4314
|
+
// Report the union of currently-tracked frames and every context we have any debug
|
|
4315
|
+
// record for: a frame that broke and stopped polling gets pruned from trackedFrames,
|
|
4316
|
+
// but it is exactly the one we want to surface (with a large lastAnyMsgAgoMs).
|
|
4317
|
+
const tracked = new Set(this.trackedFrames);
|
|
4318
|
+
const contexts = new Set([
|
|
4319
|
+
...this.trackedFrames,
|
|
4320
|
+
...this.frameAnyLastSeen.keys(),
|
|
4321
|
+
...this.frameLastSent.keys(),
|
|
4322
|
+
]);
|
|
4323
|
+
const frames = Array.from(contexts).map((ctx, i) => {
|
|
4324
|
+
const sent = this.frameLastSent.get(ctx);
|
|
4325
|
+
const alloc = this.frameAlloc.get(ctx);
|
|
4326
|
+
return {
|
|
4327
|
+
// the actual allocated (level, order) node-id partition, else an enumeration index
|
|
4328
|
+
n: alloc ? alloc.order : i + 1,
|
|
4329
|
+
level: alloc ? alloc.level : null,
|
|
4330
|
+
// identify by domain if we have it, otherwise the context id, otherwise the number
|
|
4331
|
+
id: this.frameOrigin.get(ctx) || ctx || `#${i + 1}`,
|
|
4332
|
+
tracked: tracked.has(ctx),
|
|
4333
|
+
lastAnyMsgAgoMs: rel(this.frameAnyLastSeen.get(ctx)),
|
|
4334
|
+
lastBatchAgoMs: rel(this.frameBatchLastSeen.get(ctx)),
|
|
4335
|
+
lastSent: sent ? { line: sent.line, agoMs: now - sent.t } : null,
|
|
4336
|
+
};
|
|
4337
|
+
});
|
|
4338
|
+
// GC: forget contexts that have been silent and un-messaged past the retention window.
|
|
4339
|
+
const cutoff = now - this.XDOMAIN_DEBUG_RETENTION_MS;
|
|
4340
|
+
for (const ctx of contexts) {
|
|
4341
|
+
if (tracked.has(ctx))
|
|
4342
|
+
continue;
|
|
4343
|
+
const seen = this.frameAnyLastSeen.get(ctx) ?? 0;
|
|
4344
|
+
const sentT = this.frameLastSent.get(ctx)?.t ?? 0;
|
|
4345
|
+
if (Math.max(seen, sentT) < cutoff) {
|
|
4346
|
+
this.frameOrigin.delete(ctx);
|
|
4347
|
+
this.frameAnyLastSeen.delete(ctx);
|
|
4348
|
+
this.frameBatchLastSeen.delete(ctx);
|
|
4349
|
+
this.frameLastSent.delete(ctx);
|
|
4350
|
+
this.reAdoptCooldown.delete(ctx);
|
|
4351
|
+
this.everTrackedFrames.delete(ctx);
|
|
4352
|
+
this.freeFrameOrder(ctx);
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
const payload = { t: now, count: frames.length, frames };
|
|
4356
|
+
const json = JSON.stringify(payload);
|
|
4357
|
+
let encoded;
|
|
4358
|
+
try {
|
|
4359
|
+
// payload is ASCII (base36 contexts, URL origins, numbers), so plain base64 is safe
|
|
4360
|
+
encoded = btoa(json);
|
|
4361
|
+
}
|
|
4362
|
+
catch {
|
|
4363
|
+
encoded = json;
|
|
4364
|
+
}
|
|
4365
|
+
this.send(ConsoleLog('info', `[OR_XDOMAIN_DEBUG] ${encoded}`));
|
|
4366
|
+
};
|
|
4071
4367
|
this.crossDomainIframeListener = (event) => {
|
|
4072
4368
|
if (event.source === window)
|
|
4073
4369
|
return;
|
|
4074
4370
|
const { data } = event;
|
|
4075
4371
|
if (!data)
|
|
4076
4372
|
return;
|
|
4373
|
+
// Debug: remember when we last heard *anything* from this context, and its domain.
|
|
4374
|
+
if (data.context) {
|
|
4375
|
+
this.frameAnyLastSeen.set(data.context, Date.now());
|
|
4376
|
+
if (event.origin && !this.frameOrigin.has(data.context)) {
|
|
4377
|
+
this.frameOrigin.set(data.context, event.origin);
|
|
4378
|
+
}
|
|
4379
|
+
}
|
|
4077
4380
|
// Record liveness regardless of our own active state so the queue can prune
|
|
4078
4381
|
// stale contexts reliably once we resume dispatching commands after a cycle.
|
|
4079
4382
|
if ((data.line === proto.polling || data.line === proto.iframeSignal) && data.context) {
|
|
@@ -4081,9 +4384,15 @@ class App {
|
|
|
4081
4384
|
}
|
|
4082
4385
|
if (!this.active())
|
|
4083
4386
|
return;
|
|
4387
|
+
if (data.line === proto.iframeDebug) {
|
|
4388
|
+
// A child posted its once-per-minute snapshot; surface it in our recorded console.
|
|
4389
|
+
this.send(ConsoleLog('info', `[OR_XDOMAIN_IFRAME_DEBUG] ${data.debug}`));
|
|
4390
|
+
return;
|
|
4391
|
+
}
|
|
4084
4392
|
if (data.line === proto.iframeSignal) {
|
|
4085
4393
|
// @ts-ignore
|
|
4086
4394
|
event.source?.postMessage({ ping: true, line: proto.parentAlive }, '*');
|
|
4395
|
+
this.recordSentToFrame(data.context, proto.parentAlive);
|
|
4087
4396
|
const signalId = async () => {
|
|
4088
4397
|
if (event.source === null) {
|
|
4089
4398
|
return console.error('Couldnt connect to event.source for child iframe tracking');
|
|
@@ -4100,22 +4409,25 @@ class App {
|
|
|
4100
4409
|
else {
|
|
4101
4410
|
this.trackedFrames.push(data.context);
|
|
4102
4411
|
}
|
|
4412
|
+
this.everTrackedFrames.add(data.context);
|
|
4103
4413
|
await this.waitStarted();
|
|
4104
4414
|
const token = this.session.getSessionToken(this.projectKey);
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4415
|
+
// Persistent, collision-free order (NOT the shifting array index). A restart of the
|
|
4416
|
+
// same context keeps its order/id-block for continuity; distinct live frames at the
|
|
4417
|
+
// same level never share one.
|
|
4418
|
+
const frameLevel = this.frameLevel + 1;
|
|
4419
|
+
const order = this.allocateFrameOrder(data.context, frameLevel);
|
|
4109
4420
|
const iframeData = {
|
|
4110
4421
|
line: proto.iframeId,
|
|
4111
4422
|
id,
|
|
4112
4423
|
token,
|
|
4113
4424
|
frameOrderNumber: order,
|
|
4114
|
-
frameLevel
|
|
4425
|
+
frameLevel,
|
|
4115
4426
|
};
|
|
4116
4427
|
this.debug.log('Got child frame signal; nodeId', id, event.source, iframeData);
|
|
4117
4428
|
// @ts-ignore
|
|
4118
4429
|
event.source?.postMessage(iframeData, '*');
|
|
4430
|
+
this.recordSentToFrame(data.context, proto.iframeId);
|
|
4119
4431
|
}
|
|
4120
4432
|
catch (e) {
|
|
4121
4433
|
console.error(e);
|
|
@@ -4128,6 +4440,9 @@ class App {
|
|
|
4128
4440
|
* plus we rewrite some of the messages to be relative to the main context/window
|
|
4129
4441
|
* */
|
|
4130
4442
|
if (data.line === proto.iframeBatch) {
|
|
4443
|
+
if (data.context) {
|
|
4444
|
+
this.frameBatchLastSeen.set(data.context, Date.now());
|
|
4445
|
+
}
|
|
4131
4446
|
const msgBatch = data.messages;
|
|
4132
4447
|
const mappedMessages = [];
|
|
4133
4448
|
msgBatch.forEach((msg) => {
|
|
@@ -4175,6 +4490,16 @@ class App {
|
|
|
4175
4490
|
this.messages.push(...mappedMessages);
|
|
4176
4491
|
}
|
|
4177
4492
|
if (data.line === proto.polling) {
|
|
4493
|
+
// Self-heal: a live child that was enrolled before but fell out of trackedFrames
|
|
4494
|
+
// (pruned during a stop/start gap) keeps polling yet never re-signals. Re-adopt it
|
|
4495
|
+
// so it restarts and re-enrolls. We require everTrackedFrames so a brand-new child
|
|
4496
|
+
// still mid-enrollment (iframeSignal/checkNodeId in flight) is left alone.
|
|
4497
|
+
if (data.context &&
|
|
4498
|
+
this.everTrackedFrames.has(data.context) &&
|
|
4499
|
+
!this.trackedFrames.includes(data.context)) {
|
|
4500
|
+
this.reAdoptOrphanFrame(event, data.context);
|
|
4501
|
+
return;
|
|
4502
|
+
}
|
|
4178
4503
|
if (!this.pollingQueue.order.length) {
|
|
4179
4504
|
return;
|
|
4180
4505
|
}
|
|
@@ -4211,6 +4536,7 @@ class App {
|
|
|
4211
4536
|
}
|
|
4212
4537
|
// @ts-ignore
|
|
4213
4538
|
event.source?.postMessage(message, '*');
|
|
4539
|
+
this.recordSentToFrame(data.context, nextCommand);
|
|
4214
4540
|
if (this.pollingQueue[nextCommand].length === 0) {
|
|
4215
4541
|
delete this.pollingQueue[nextCommand];
|
|
4216
4542
|
this.pollingQueue.order.shift();
|
|
@@ -4248,6 +4574,7 @@ class App {
|
|
|
4248
4574
|
source: thisTab,
|
|
4249
4575
|
context: this.contextId,
|
|
4250
4576
|
}, this.options.crossdomain?.parentDomain ?? '*');
|
|
4577
|
+
this.markSentToParent();
|
|
4251
4578
|
/**
|
|
4252
4579
|
* since we need to wait uncertain amount of time
|
|
4253
4580
|
* and I don't want to have recursion going on,
|
|
@@ -4268,6 +4595,7 @@ class App {
|
|
|
4268
4595
|
source: thisTab,
|
|
4269
4596
|
context: this.contextId,
|
|
4270
4597
|
}, this.options.crossdomain?.parentDomain ?? '*');
|
|
4598
|
+
this.markSentToParent();
|
|
4271
4599
|
this.debug.info('Trying to signal to parent, attempt:', retries + 1);
|
|
4272
4600
|
retries++;
|
|
4273
4601
|
};
|
|
@@ -4359,6 +4687,26 @@ class App {
|
|
|
4359
4687
|
this.restartCanvasTracking = () => {
|
|
4360
4688
|
this.canvasRecorder?.restartTracking();
|
|
4361
4689
|
};
|
|
4690
|
+
this.attachResanitizeCallback = (cb) => {
|
|
4691
|
+
this.resanitizeCallbacks.push(cb);
|
|
4692
|
+
};
|
|
4693
|
+
this.callResanitizeCallbacks = (node, id) => {
|
|
4694
|
+
this.resanitizeCallbacks.forEach((cb) => cb(node, id));
|
|
4695
|
+
};
|
|
4696
|
+
this.resanitize = (el) => {
|
|
4697
|
+
const root = el ?? (IN_BROWSER ? document.documentElement : undefined);
|
|
4698
|
+
if (!root) {
|
|
4699
|
+
return;
|
|
4700
|
+
}
|
|
4701
|
+
this.observer.resanitizeSubtree(root);
|
|
4702
|
+
};
|
|
4703
|
+
this.checkSanitization = (el) => {
|
|
4704
|
+
const id = this.nodes.getID(el);
|
|
4705
|
+
if (id === undefined) {
|
|
4706
|
+
return undefined;
|
|
4707
|
+
}
|
|
4708
|
+
return this.sanitizer.getLevel(id);
|
|
4709
|
+
};
|
|
4362
4710
|
this.flushBuffer = async (buffer) => {
|
|
4363
4711
|
return new Promise((res, reject) => {
|
|
4364
4712
|
if (buffer.length === 0) {
|
|
@@ -4484,7 +4832,13 @@ class App {
|
|
|
4484
4832
|
line: proto.polling,
|
|
4485
4833
|
context: this.contextId,
|
|
4486
4834
|
}, options.crossdomain?.parentDomain ?? '*');
|
|
4835
|
+
this.markSentToParent();
|
|
4487
4836
|
}, 250);
|
|
4837
|
+
// Child-only: once per minute, post an encoded snapshot of our own tracking state
|
|
4838
|
+
// (active?, token received, last comms) up to the parent so it lands in the replay.
|
|
4839
|
+
if (this.iframeDebugInterval)
|
|
4840
|
+
clearInterval(this.iframeDebugInterval);
|
|
4841
|
+
this.iframeDebugInterval = setInterval(this.emitIframeDebug, 60000);
|
|
4488
4842
|
}
|
|
4489
4843
|
else {
|
|
4490
4844
|
this.initWorker();
|
|
@@ -4493,6 +4847,13 @@ class App {
|
|
|
4493
4847
|
* so they can act as if it was just a same-domain iframe
|
|
4494
4848
|
* */
|
|
4495
4849
|
window.addEventListener('message', this.crossDomainIframeListener);
|
|
4850
|
+
// Parent-only: once per minute, log an encoded snapshot of every tracked child
|
|
4851
|
+
// iframe and the freshness of our two-way comms, to debug iframes that go silent.
|
|
4852
|
+
if (this.options.crossdomain?.enabled) {
|
|
4853
|
+
if (this.xdomainDebugInterval)
|
|
4854
|
+
clearInterval(this.xdomainDebugInterval);
|
|
4855
|
+
this.xdomainDebugInterval = setInterval(this.emitCrossdomainDebug, 60000);
|
|
4856
|
+
}
|
|
4496
4857
|
}
|
|
4497
4858
|
if (this.bc !== null) {
|
|
4498
4859
|
this.bc.postMessage({
|
|
@@ -4542,6 +4903,62 @@ class App {
|
|
|
4542
4903
|
};
|
|
4543
4904
|
}
|
|
4544
4905
|
}
|
|
4906
|
+
/** stamp every outbound post to the parent window, for the child debug snapshot */
|
|
4907
|
+
markSentToParent() {
|
|
4908
|
+
this.lastSentToParentAt = Date.now();
|
|
4909
|
+
}
|
|
4910
|
+
allocateFrameOrder(ctx, level) {
|
|
4911
|
+
const existing = this.frameAlloc.get(ctx);
|
|
4912
|
+
if (existing !== undefined)
|
|
4913
|
+
return existing.order;
|
|
4914
|
+
let used = this.usedOrdersByLevel.get(level);
|
|
4915
|
+
if (!used) {
|
|
4916
|
+
used = new Set();
|
|
4917
|
+
this.usedOrdersByLevel.set(level, used);
|
|
4918
|
+
}
|
|
4919
|
+
let order = -1;
|
|
4920
|
+
for (let n = 1; n <= MASK_ORDER; n++) {
|
|
4921
|
+
if (!used.has(n)) {
|
|
4922
|
+
order = n;
|
|
4923
|
+
break;
|
|
4924
|
+
}
|
|
4925
|
+
}
|
|
4926
|
+
if (order === -1) {
|
|
4927
|
+
// Overflow (>127 live frames at one level): evict the least-recently-seen context at
|
|
4928
|
+
// this level that is not currently tracked, and reuse its slot rather than failing.
|
|
4929
|
+
let lru = null;
|
|
4930
|
+
let lruSeen = Infinity;
|
|
4931
|
+
const trackedSet = new Set(this.trackedFrames);
|
|
4932
|
+
this.frameAlloc.forEach((alloc, c) => {
|
|
4933
|
+
if (alloc.level !== level || trackedSet.has(c))
|
|
4934
|
+
return;
|
|
4935
|
+
const seen = this.frameAnyLastSeen.get(c) ?? 0;
|
|
4936
|
+
if (seen < lruSeen) {
|
|
4937
|
+
lruSeen = seen;
|
|
4938
|
+
lru = c;
|
|
4939
|
+
}
|
|
4940
|
+
});
|
|
4941
|
+
if (lru !== null) {
|
|
4942
|
+
order = this.frameAlloc.get(lru).order;
|
|
4943
|
+
this.frameAlloc.delete(lru);
|
|
4944
|
+
this.debug.error('OR: frame order space exhausted, evicting', lru, 'for', ctx);
|
|
4945
|
+
}
|
|
4946
|
+
else {
|
|
4947
|
+
order = MASK_ORDER;
|
|
4948
|
+
this.debug.error('OR: frame order overflow, reusing max order for', ctx);
|
|
4949
|
+
}
|
|
4950
|
+
}
|
|
4951
|
+
used.add(order);
|
|
4952
|
+
this.frameAlloc.set(ctx, { order, level });
|
|
4953
|
+
return order;
|
|
4954
|
+
}
|
|
4955
|
+
freeFrameOrder(ctx) {
|
|
4956
|
+
const alloc = this.frameAlloc.get(ctx);
|
|
4957
|
+
if (!alloc)
|
|
4958
|
+
return;
|
|
4959
|
+
this.frameAlloc.delete(ctx);
|
|
4960
|
+
this.usedOrdersByLevel.get(alloc.level)?.delete(alloc.order);
|
|
4961
|
+
}
|
|
4545
4962
|
pruneStaleFrames() {
|
|
4546
4963
|
const staleAfter = Date.now() - this.FRAME_STALE_MS;
|
|
4547
4964
|
this.trackedFrames = this.trackedFrames.filter((ctx) => {
|
|
@@ -4552,6 +4969,45 @@ class App {
|
|
|
4552
4969
|
return false;
|
|
4553
4970
|
});
|
|
4554
4971
|
}
|
|
4972
|
+
/** records the last command/signal we posted to a given child iframe context (debug) */
|
|
4973
|
+
recordSentToFrame(ctx, line) {
|
|
4974
|
+
if (!ctx)
|
|
4975
|
+
return;
|
|
4976
|
+
this.frameLastSent.set(ctx, { line: protoLabel[line] ?? line, t: Date.now() });
|
|
4977
|
+
}
|
|
4978
|
+
/**
|
|
4979
|
+
* Self-heal for the "kill-then-prune orphan" race: a live child can fall out of
|
|
4980
|
+
* `trackedFrames` (its 250ms poll was delayed past FRAME_STALE_MS during the parent's
|
|
4981
|
+
* stop/start NotActive gap, so pruneStaleFrames evicted it). It keeps polling but the
|
|
4982
|
+
* only re-enrollment path is an `iframeSignal`, which a stopped/active-but-orphaned
|
|
4983
|
+
* child never re-emits — so it would record nothing forever. When we (the parent) are
|
|
4984
|
+
* active and see a poll from an un-tracked context, push a `startIframe` so the child
|
|
4985
|
+
* restarts, re-runs the full handshake and re-observes with a fresh rootId. Cooldowned
|
|
4986
|
+
* so we don't spam restarts during the child's start window.
|
|
4987
|
+
*/
|
|
4988
|
+
reAdoptOrphanFrame(event, ctx) {
|
|
4989
|
+
const now = Date.now();
|
|
4990
|
+
const last = this.reAdoptCooldown.get(ctx) ?? 0;
|
|
4991
|
+
if (now - last < this.RE_ADOPT_COOLDOWN_MS)
|
|
4992
|
+
return;
|
|
4993
|
+
this.reAdoptCooldown.set(ctx, now);
|
|
4994
|
+
const message = {
|
|
4995
|
+
line: proto.startIframe,
|
|
4996
|
+
token: this.session.getSessionToken(this.projectKey),
|
|
4997
|
+
};
|
|
4998
|
+
const targetFrame = this.pageFrames.find((f) => f.contentWindow === event.source) ||
|
|
4999
|
+
Array.from(document.querySelectorAll('iframe')).find((f) => f.contentWindow === event.source);
|
|
5000
|
+
if (targetFrame) {
|
|
5001
|
+
const nodeId = targetFrame[this.options.node_id];
|
|
5002
|
+
if (nodeId !== undefined) {
|
|
5003
|
+
message.id = nodeId;
|
|
5004
|
+
}
|
|
5005
|
+
}
|
|
5006
|
+
// @ts-ignore
|
|
5007
|
+
event.source?.postMessage(message, '*');
|
|
5008
|
+
this.recordSentToFrame(ctx, proto.startIframe);
|
|
5009
|
+
this.debug.log('Re-adopting orphaned crossdomain iframe', ctx);
|
|
5010
|
+
}
|
|
4555
5011
|
allowAppStart() {
|
|
4556
5012
|
this.canStart = true;
|
|
4557
5013
|
if (this.startTimeout) {
|
|
@@ -4706,7 +5162,9 @@ class App {
|
|
|
4706
5162
|
window.parent.postMessage({
|
|
4707
5163
|
line: proto.iframeBatch,
|
|
4708
5164
|
messages: this.messages,
|
|
5165
|
+
context: this.contextId,
|
|
4709
5166
|
}, this.options.crossdomain?.parentDomain ?? '*');
|
|
5167
|
+
this.markSentToParent();
|
|
4710
5168
|
this.commitCallbacks.forEach((cb) => cb(this.messages));
|
|
4711
5169
|
this.messages.length = 0;
|
|
4712
5170
|
return;
|
|
@@ -5906,6 +6364,12 @@ function Img (app) {
|
|
|
5906
6364
|
sendImgAttrs(node);
|
|
5907
6365
|
observer.observe(node, { attributes: true, attributeFilter: ['src', 'srcset'] });
|
|
5908
6366
|
});
|
|
6367
|
+
// On a runtime level change, re-evaluate placeholder vs real src for this image.
|
|
6368
|
+
app.attachResanitizeCallback((node) => {
|
|
6369
|
+
if (hasTag(node, 'img')) {
|
|
6370
|
+
sendImgAttrs(node);
|
|
6371
|
+
}
|
|
6372
|
+
});
|
|
5909
6373
|
}
|
|
5910
6374
|
|
|
5911
6375
|
const INPUT_TYPES = [
|
|
@@ -6035,9 +6499,11 @@ function Input (app, opts) {
|
|
|
6035
6499
|
}
|
|
6036
6500
|
const inputValues = new Map();
|
|
6037
6501
|
const checkboxValues = new Map();
|
|
6502
|
+
const selectValues = new Map();
|
|
6038
6503
|
app.attachStopCallback(() => {
|
|
6039
6504
|
inputValues.clear();
|
|
6040
6505
|
checkboxValues.clear();
|
|
6506
|
+
selectValues.clear();
|
|
6041
6507
|
tagSelectorMap.clear();
|
|
6042
6508
|
});
|
|
6043
6509
|
function trackInputValue(id, node) {
|
|
@@ -6054,6 +6520,13 @@ function Input (app, opts) {
|
|
|
6054
6520
|
checkboxValues.set(id, value);
|
|
6055
6521
|
app.send(SetInputChecked(id, value));
|
|
6056
6522
|
}
|
|
6523
|
+
function trackSelectValue(id, node) {
|
|
6524
|
+
if (selectValues.get(id) === node.value) {
|
|
6525
|
+
return;
|
|
6526
|
+
}
|
|
6527
|
+
selectValues.set(id, node.value);
|
|
6528
|
+
sendInputValue(id, node);
|
|
6529
|
+
}
|
|
6057
6530
|
// The only way (to our knowledge) to track all kinds of input changes, including those made by JS
|
|
6058
6531
|
app.ticker.attach(() => {
|
|
6059
6532
|
inputValues.forEach((value, id) => {
|
|
@@ -6068,6 +6541,12 @@ function Input (app, opts) {
|
|
|
6068
6541
|
return checkboxValues.delete(id);
|
|
6069
6542
|
trackCheckboxValue(id, node.checked);
|
|
6070
6543
|
});
|
|
6544
|
+
selectValues.forEach((_, id) => {
|
|
6545
|
+
const node = app.nodes.getNode(id);
|
|
6546
|
+
if (!node)
|
|
6547
|
+
return selectValues.delete(id);
|
|
6548
|
+
trackSelectValue(id, node);
|
|
6549
|
+
});
|
|
6071
6550
|
}, 3);
|
|
6072
6551
|
function sendInputChange(id, node, hesitationTime, inputTime) {
|
|
6073
6552
|
const { value, mask } = getInputValue(id, node);
|
|
@@ -6077,6 +6556,13 @@ function Input (app, opts) {
|
|
|
6077
6556
|
}
|
|
6078
6557
|
app.send(InputChange(id, value, mask !== 0, label, hesitationTime, inputTime));
|
|
6079
6558
|
}
|
|
6559
|
+
// Re-emit a field's value when its sanitization level changes at runtime.
|
|
6560
|
+
// getInputValue() reads the current level, so re-sending applies the new mask.
|
|
6561
|
+
app.attachResanitizeCallback((node, id) => {
|
|
6562
|
+
if (isTextFieldElement(node) || hasTag(node, 'select')) {
|
|
6563
|
+
sendInputValue(id, node);
|
|
6564
|
+
}
|
|
6565
|
+
});
|
|
6080
6566
|
app.nodes.attachNodeCallback(app.safe((node) => {
|
|
6081
6567
|
const id = app.nodes.getID(node);
|
|
6082
6568
|
if (id === undefined) {
|
|
@@ -6084,8 +6570,8 @@ function Input (app, opts) {
|
|
|
6084
6570
|
}
|
|
6085
6571
|
// TODO: support multiple select (?): use selectedOptions;
|
|
6086
6572
|
if (hasTag(node, 'select')) {
|
|
6087
|
-
|
|
6088
|
-
app.nodes.attachNodeListener(node, 'change', () =>
|
|
6573
|
+
trackSelectValue(id, node);
|
|
6574
|
+
app.nodes.attachNodeListener(node, 'change', () => trackSelectValue(id, node));
|
|
6089
6575
|
}
|
|
6090
6576
|
if (isTextFieldElement(node)) {
|
|
6091
6577
|
trackInputValue(id, node);
|
|
@@ -7346,7 +7832,7 @@ class NetworkMessage {
|
|
|
7346
7832
|
return null;
|
|
7347
7833
|
const gqlHeader = "application/graphql-response";
|
|
7348
7834
|
const isGraphql = messageInfo.url.includes("/graphql")
|
|
7349
|
-
|| Object.values(messageInfo.request.headers).some(v => v.includes(gqlHeader));
|
|
7835
|
+
|| Object.values(messageInfo.request.headers).some(v => v && typeof v === 'string' && v.includes(gqlHeader));
|
|
7350
7836
|
if (isGraphql && messageInfo.response.body && typeof messageInfo.response.body === 'string') {
|
|
7351
7837
|
const isError = messageInfo.response.body.includes("errors");
|
|
7352
7838
|
messageInfo.status = isError ? 400 : 200;
|
|
@@ -7450,6 +7936,7 @@ const genStringBody = (body) => {
|
|
|
7450
7936
|
}
|
|
7451
7937
|
else if (body instanceof Blob ||
|
|
7452
7938
|
body instanceof ReadableStream ||
|
|
7939
|
+
ArrayBuffer.isView(body) ||
|
|
7453
7940
|
body instanceof ArrayBuffer) {
|
|
7454
7941
|
result = 'byte data';
|
|
7455
7942
|
}
|
|
@@ -8938,7 +9425,7 @@ class ConstantProperties {
|
|
|
8938
9425
|
user_id: this.user_id,
|
|
8939
9426
|
distinct_id: this.deviceId,
|
|
8940
9427
|
sdk_edition: 'web',
|
|
8941
|
-
sdk_version: '17.2.
|
|
9428
|
+
sdk_version: '17.2.11',
|
|
8942
9429
|
timezone: getUTCOffsetString(),
|
|
8943
9430
|
search_engine: this.searchEngine,
|
|
8944
9431
|
};
|
|
@@ -9640,7 +10127,7 @@ class API {
|
|
|
9640
10127
|
this.signalStartIssue = (reason, missingApi) => {
|
|
9641
10128
|
const doNotTrack = this.checkDoNotTrack();
|
|
9642
10129
|
console.log("Tracker couldn't start due to:", JSON.stringify({
|
|
9643
|
-
trackerVersion: '17.2.
|
|
10130
|
+
trackerVersion: '17.2.11',
|
|
9644
10131
|
projectKey: this.options.projectKey,
|
|
9645
10132
|
doNotTrack,
|
|
9646
10133
|
reason: missingApi.length ? `missing api: ${missingApi.join(',')}` : reason,
|
|
@@ -9652,6 +10139,31 @@ class API {
|
|
|
9652
10139
|
}
|
|
9653
10140
|
this.app.restartCanvasTracking();
|
|
9654
10141
|
};
|
|
10142
|
+
/**
|
|
10143
|
+
* Re-evaluates sanitization against the current DOM and re-emits whatever
|
|
10144
|
+
* changed, updating already-recorded nodes mid-session. Call after toggling
|
|
10145
|
+
* `data-openreplay-*` attributes or after changing whatever your `domSanitizer`
|
|
10146
|
+
* keys on (class/id/etc).
|
|
10147
|
+
*
|
|
10148
|
+
* @param el - the highest node you changed; omit to re-scan the whole document;
|
|
10149
|
+
* scanning the entire doc is O(dom size)
|
|
10150
|
+
* */
|
|
10151
|
+
this.resanitize = (el) => {
|
|
10152
|
+
if (this.app === null) {
|
|
10153
|
+
return;
|
|
10154
|
+
}
|
|
10155
|
+
this.app.resanitize(el);
|
|
10156
|
+
};
|
|
10157
|
+
/**
|
|
10158
|
+
* Returns the sanitization level the tracker currently has for a node
|
|
10159
|
+
* (0 = Plain, 1 = Obscured, 2 = Hidden), or undefined if it isn't tracked.
|
|
10160
|
+
* */
|
|
10161
|
+
this.checkSanitization = (el) => {
|
|
10162
|
+
if (this.app === null) {
|
|
10163
|
+
return undefined;
|
|
10164
|
+
}
|
|
10165
|
+
return this.app.checkSanitization(el);
|
|
10166
|
+
};
|
|
9655
10167
|
this.getSessionURL = (options) => {
|
|
9656
10168
|
if (this.app === null) {
|
|
9657
10169
|
return undefined;
|
|
@@ -9665,7 +10177,10 @@ class API {
|
|
|
9665
10177
|
}
|
|
9666
10178
|
};
|
|
9667
10179
|
this.identify = this.setUserID;
|
|
9668
|
-
|
|
10180
|
+
// Delegates at call time: `this.analytics` is assigned in the constructor body,
|
|
10181
|
+
// which runs AFTER field initializers, so binding it here directly would always
|
|
10182
|
+
// capture `undefined`.
|
|
10183
|
+
this.track = (eventName, properties, options) => this.analytics?.track(eventName, properties, options);
|
|
9669
10184
|
this.userID = (id) => {
|
|
9670
10185
|
deprecationWarn("'userID' method", "'setUserID' method", '/');
|
|
9671
10186
|
this.setUserID(id);
|