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