clarity-js 0.8.42 → 0.8.43

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.
Files changed (114) hide show
  1. package/README.md +26 -26
  2. package/build/clarity.extended.js +1 -1
  3. package/build/clarity.insight.js +1 -1
  4. package/build/clarity.js +6043 -6030
  5. package/build/clarity.min.js +1 -1
  6. package/build/clarity.module.js +6043 -6030
  7. package/build/clarity.performance.js +1 -1
  8. package/package.json +70 -70
  9. package/rollup.config.ts +161 -161
  10. package/src/clarity.ts +65 -65
  11. package/src/core/api.ts +8 -8
  12. package/src/core/config.ts +29 -29
  13. package/src/core/copy.ts +3 -3
  14. package/src/core/dynamic.ts +13 -7
  15. package/src/core/event.ts +53 -53
  16. package/src/core/hash.ts +19 -19
  17. package/src/core/history.ts +71 -71
  18. package/src/core/index.ts +81 -81
  19. package/src/core/measure.ts +19 -19
  20. package/src/core/report.ts +28 -28
  21. package/src/core/scrub.ts +204 -202
  22. package/src/core/task.ts +181 -181
  23. package/src/core/throttle.ts +46 -46
  24. package/src/core/time.ts +26 -26
  25. package/src/core/timeout.ts +10 -10
  26. package/src/core/version.ts +2 -2
  27. package/src/data/baseline.ts +162 -162
  28. package/src/data/compress.ts +31 -31
  29. package/src/data/consent.ts +77 -77
  30. package/src/data/custom.ts +23 -23
  31. package/src/data/dimension.ts +53 -53
  32. package/src/data/encode.ts +155 -155
  33. package/src/data/envelope.ts +53 -53
  34. package/src/data/extract.ts +211 -211
  35. package/src/data/index.ts +50 -50
  36. package/src/data/limit.ts +44 -44
  37. package/src/data/metadata.ts +411 -408
  38. package/src/data/metric.ts +51 -51
  39. package/src/data/ping.ts +36 -36
  40. package/src/data/signal.ts +30 -30
  41. package/src/data/summary.ts +34 -34
  42. package/src/data/token.ts +39 -39
  43. package/src/data/upgrade.ts +44 -44
  44. package/src/data/upload.ts +333 -333
  45. package/src/data/variable.ts +83 -83
  46. package/src/diagnostic/encode.ts +40 -40
  47. package/src/diagnostic/fraud.ts +36 -36
  48. package/src/diagnostic/index.ts +13 -13
  49. package/src/diagnostic/internal.ts +28 -28
  50. package/src/diagnostic/script.ts +35 -35
  51. package/src/dynamic/agent/blank.ts +2 -2
  52. package/src/dynamic/agent/crisp.ts +40 -40
  53. package/src/dynamic/agent/encode.ts +25 -25
  54. package/src/dynamic/agent/index.ts +8 -8
  55. package/src/dynamic/agent/livechat.ts +58 -58
  56. package/src/dynamic/agent/tidio.ts +44 -44
  57. package/src/global.ts +6 -6
  58. package/src/index.ts +9 -9
  59. package/src/insight/blank.ts +14 -14
  60. package/src/insight/encode.ts +60 -60
  61. package/src/insight/snapshot.ts +114 -114
  62. package/src/interaction/change.ts +38 -38
  63. package/src/interaction/click.ts +173 -173
  64. package/src/interaction/clipboard.ts +32 -32
  65. package/src/interaction/encode.ts +210 -210
  66. package/src/interaction/index.ts +60 -60
  67. package/src/interaction/input.ts +57 -57
  68. package/src/interaction/pointer.ts +137 -137
  69. package/src/interaction/resize.ts +50 -50
  70. package/src/interaction/scroll.ts +129 -129
  71. package/src/interaction/selection.ts +66 -66
  72. package/src/interaction/submit.ts +30 -30
  73. package/src/interaction/timeline.ts +69 -69
  74. package/src/interaction/unload.ts +26 -26
  75. package/src/interaction/visibility.ts +27 -27
  76. package/src/layout/animation.ts +133 -133
  77. package/src/layout/custom.ts +42 -42
  78. package/src/layout/discover.ts +31 -31
  79. package/src/layout/document.ts +46 -46
  80. package/src/layout/dom.ts +439 -439
  81. package/src/layout/encode.ts +154 -154
  82. package/src/layout/index.ts +42 -42
  83. package/src/layout/mutation.ts +411 -411
  84. package/src/layout/node.ts +294 -294
  85. package/src/layout/offset.ts +19 -19
  86. package/src/layout/region.ts +151 -151
  87. package/src/layout/schema.ts +63 -63
  88. package/src/layout/selector.ts +82 -82
  89. package/src/layout/style.ts +159 -159
  90. package/src/layout/target.ts +32 -32
  91. package/src/layout/traverse.ts +27 -27
  92. package/src/performance/blank.ts +9 -9
  93. package/src/performance/encode.ts +31 -31
  94. package/src/performance/index.ts +12 -12
  95. package/src/performance/interaction.ts +125 -125
  96. package/src/performance/navigation.ts +31 -31
  97. package/src/performance/observer.ts +112 -112
  98. package/src/queue.ts +33 -33
  99. package/test/core.test.ts +139 -139
  100. package/test/helper.ts +162 -162
  101. package/test/html/core.html +27 -27
  102. package/test/stub.test.ts +7 -7
  103. package/test/tsconfig.test.json +5 -5
  104. package/tsconfig.json +21 -21
  105. package/tslint.json +32 -32
  106. package/types/agent.d.ts +39 -39
  107. package/types/core.d.ts +150 -150
  108. package/types/data.d.ts +572 -571
  109. package/types/diagnostic.d.ts +24 -24
  110. package/types/global.d.ts +30 -30
  111. package/types/index.d.ts +40 -40
  112. package/types/interaction.d.ts +177 -177
  113. package/types/layout.d.ts +276 -276
  114. package/types/performance.d.ts +31 -31
package/src/layout/dom.ts CHANGED
@@ -1,439 +1,439 @@
1
- import { Privacy } from "@clarity-types/core";
2
- import { Code, Setting, Severity } from "@clarity-types/data";
3
- import { Constant, Mask, NodeInfo, NodeMeta, NodeValue, Selector, SelectorInput, Source } from "@clarity-types/layout";
4
- import config from "@src/core/config";
5
- import { bind } from "@src/core/event";
6
- import hash from "@src/core/hash";
7
- import { shortid } from "@src/data/metadata";
8
- import * as internal from "@src/diagnostic/internal";
9
- import { removeObserver } from "@src/layout/node";
10
- import * as region from "@src/layout/region";
11
- import * as selector from "@src/layout/selector";
12
- let index: number = 1;
13
- let nodesMap: Map<Number, Node> = null; // Maps id => node to retrieve further node details using id.
14
- let values: NodeValue[] = [];
15
- let updateMap: number[] = [];
16
- let hashMap: { [hash: string]: number } = {};
17
- let override = [];
18
- let unmask = [];
19
- let maskText = [];
20
- let maskExclude = [];
21
- let maskDisable = [];
22
- let maskTags = [];
23
-
24
- // The WeakMap object is a collection of key/value pairs in which the keys are weakly referenced
25
- let idMap: WeakMap<Node, number> = null; // Maps node => id.
26
- let iframeMap: WeakMap<Document, HTMLIFrameElement> = null; // Maps iframe's contentDocument => parent iframe element
27
- let iframeContentMap: WeakMap<HTMLIFrameElement, { doc: Document, win: Window }> = null; // Maps parent iframe element => iframe's contentDocument & contentWindow
28
- let privacyMap: WeakMap<Node, Privacy> = null; // Maps node => Privacy (enum)
29
- let fraudMap: WeakMap<Node, number> = null; // Maps node => FraudId (number)
30
-
31
- export function start(): void {
32
- reset();
33
- parse(document, true);
34
- }
35
-
36
- export function stop(): void {
37
- reset();
38
- }
39
-
40
- function reset(): void {
41
- index = 1;
42
- values = [];
43
- updateMap = [];
44
- hashMap = {};
45
- override = [];
46
- unmask = [];
47
- maskText = Mask.Text.split(Constant.Comma);
48
- maskExclude = Mask.Exclude.split(Constant.Comma);
49
- maskDisable = Mask.Disable.split(Constant.Comma);
50
- maskTags = Mask.Tags.split(Constant.Comma);
51
- nodesMap = new Map();
52
- idMap = new WeakMap();
53
- iframeMap = new WeakMap();
54
- iframeContentMap = new WeakMap();
55
- privacyMap = new WeakMap();
56
- fraudMap = new WeakMap();
57
- selector.reset();
58
- }
59
-
60
- // We parse new root nodes for any regions or masked nodes in the beginning (document) and
61
- // later whenever there are new additions or modifications to DOM (mutations)
62
- export function parse(root: ParentNode, init: boolean = false): void {
63
- // Wrap selectors in a try / catch block.
64
- // It's possible for script to receive invalid selectors, e.g. "'#id'" with extra quotes, and cause the code below to fail
65
- try {
66
- // Parse unmask configuration into separate query selectors and override tokens as part of initialization
67
- if (init) { config.unmask.forEach(x => x.indexOf(Constant.Bang) < 0 ? unmask.push(x) : override.push(x.substr(1))); }
68
-
69
- // Since mutations may happen on leaf nodes too, e.g. text nodes, which may not support all selector APIs.
70
- // We ensure that the root note supports querySelectorAll API before executing the code below to identify new regions.
71
- if ("querySelectorAll" in root) {
72
- config.regions.forEach(x => root.querySelectorAll(x[1]).forEach(e => region.observe(e, `${x[0]}`))); // Regions
73
- config.mask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.TextImage))); // Masked Elements
74
- config.checksum.forEach(x => root.querySelectorAll(x[1]).forEach(e => fraudMap.set(e, x[0]))); // Fraud Checksum Check
75
- unmask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.None))); // Unmasked Elements
76
- }
77
- } catch (e) { internal.log(Code.Selector, Severity.Warning, e ? e.name : null); }
78
- }
79
-
80
- export function getId(node: Node, autogen: boolean = false): number {
81
- if (node === null) { return null; }
82
- let id = idMap.get(node);
83
- if (!id && autogen) {
84
- id = index++;
85
- idMap.set(node, id);
86
- }
87
-
88
- return id ? id : null;
89
- }
90
-
91
- export function add(node: Node, parent: Node, data: NodeInfo, source: Source): void {
92
- let parentId = parent ? getId(parent) : null;
93
-
94
- // Do not add detached nodes
95
- if ((!parent || !parentId) && (node as ShadowRoot).host == null && node.nodeType !== Node.DOCUMENT_TYPE_NODE) {
96
- return;
97
- }
98
-
99
- let id = getId(node, true);
100
- let previousId = getPreviousId(node);
101
- let parentValue: NodeValue = null;
102
- let regionId = region.exists(node) ? id : null;
103
- let fraudId = fraudMap.has(node) ? fraudMap.get(node) : null;
104
- let privacyId = config.content ? Privacy.Sensitive : Privacy.TextImage
105
- if (parentId >= 0 && values[parentId]) {
106
- parentValue = values[parentId];
107
- parentValue.children.push(id);
108
- regionId = regionId === null ? parentValue.region : regionId;
109
- fraudId = fraudId === null ? parentValue.metadata.fraud : fraudId;
110
- privacyId = parentValue.metadata.privacy;
111
- }
112
-
113
- // If there's an explicit region attribute set on the element, use it to mark a region on the page
114
- if (data.attributes && Constant.RegionData in data.attributes) {
115
- region.observe(node, data.attributes[Constant.RegionData]);
116
- regionId = id;
117
- }
118
-
119
- nodesMap.set(id, node);
120
- values[id] = {
121
- id,
122
- parent: parentId,
123
- previous: previousId,
124
- children: [],
125
- data,
126
- selector: null,
127
- hash: null,
128
- region: regionId,
129
- metadata: { active: true, suspend: false, privacy: privacyId, position: null, fraud: fraudId, size: null },
130
- };
131
-
132
- privacy(node, values[id], parentValue);
133
- updateSelector(values[id]);
134
- updateImageSize(values[id]);
135
- track(id, source);
136
- }
137
-
138
- export function update(node: Node, parent: Node, data: NodeInfo, source: Source): void {
139
- let id = getId(node);
140
- let parentId = parent ? getId(parent) : null;
141
- let previousId = getPreviousId(node);
142
- let changed = false;
143
- let parentChanged = false;
144
-
145
- if (id in values) {
146
- let value = values[id];
147
- value.metadata.active = true;
148
-
149
- // Handle case where internal ordering may have changed
150
- if (value.previous !== previousId) {
151
- changed = true;
152
- value.previous = previousId;
153
- }
154
-
155
- // Handle case where parent might have been updated
156
- if (value.parent !== parentId) {
157
- changed = true;
158
- let oldParentId = value.parent;
159
- value.parent = parentId;
160
- // Move this node to the right location under new parent
161
- if (parentId !== null && parentId >= 0) {
162
- let childIndex = previousId === null ? 0 : values[parentId].children.indexOf(previousId) + 1;
163
- values[parentId].children.splice(childIndex, 0, id);
164
- // Update region after the move
165
- value.region = region.exists(node) ? id : values[parentId].region;
166
- } else {
167
- // Mark this element as deleted if the parent has been updated to null
168
- remove(id, source);
169
- }
170
-
171
- // Remove reference to this node from the old parent
172
- if (oldParentId !== null && oldParentId >= 0) {
173
- let nodeIndex = values[oldParentId].children.indexOf(id);
174
- if (nodeIndex >= 0) {
175
- values[oldParentId].children.splice(nodeIndex, 1);
176
- }
177
- }
178
- parentChanged = true;
179
- }
180
-
181
- // Update data
182
- for (let key in data) {
183
- if (diff(value["data"], data, key)) {
184
- changed = true;
185
- value["data"][key] = data[key];
186
- }
187
- }
188
-
189
- // Update selector
190
- updateSelector(value);
191
- track(id, source, changed, parentChanged);
192
- }
193
- }
194
-
195
- export function sameorigin(node: Node): boolean {
196
- let output = false;
197
- if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === Constant.IFrameTag) {
198
- let frame = node as HTMLIFrameElement;
199
- // To determine if the iframe is same-origin or not, we try accessing it's contentDocument.
200
- // If the browser throws an exception, we assume it's cross-origin and move on.
201
- // However, if we do a get a valid document object back, we assume the contents are accessible and iframe is same-origin.
202
- try {
203
- let doc = frame.contentDocument;
204
- if (doc) {
205
- iframeMap.set(frame.contentDocument, frame);
206
- iframeContentMap.set(frame, { doc: frame.contentDocument, win: frame.contentWindow });
207
- output = true;
208
- }
209
- } catch { /* do nothing */ }
210
- }
211
- return output;
212
- }
213
-
214
- export function iframe(node: Node): HTMLIFrameElement {
215
- let doc = node.nodeType === Node.DOCUMENT_NODE ? node as Document : null;
216
- return doc && iframeMap.has(doc) ? iframeMap.get(doc) : null;
217
- }
218
-
219
- export function iframeContent(frame: HTMLIFrameElement): {doc: Document, win: Window } {
220
- if (iframeContentMap.has(frame)) {
221
- return iframeContentMap.get(frame);
222
- }
223
- return null;
224
- }
225
-
226
- export function removeIFrame(frame: HTMLIFrameElement, doc: Document): void {
227
- iframeContentMap.delete(frame);
228
- iframeMap.delete(doc);
229
- }
230
-
231
- function privacy(node: Node, value: NodeValue, parent: NodeValue): void {
232
- let data = value.data;
233
- let metadata = value.metadata;
234
- let current = metadata.privacy;
235
- let attributes = data.attributes || {};
236
- let tag = data.tag.toUpperCase();
237
-
238
- switch (true) {
239
- case maskTags.indexOf(tag) >= 0:
240
- let type = attributes[Constant.Type];
241
- let meta: string = Constant.Empty;
242
- const excludedPrivacyAttributes = [Constant.Class, Constant.Style]
243
- Object.keys(attributes)
244
- .filter((x) => !excludedPrivacyAttributes.includes(x as Constant))
245
- .forEach((x) => (meta += attributes[x].toLowerCase()));
246
- let exclude = maskExclude.some((x) => meta.indexOf(x) >= 0);
247
- // Regardless of privacy mode, always mask off user input from input boxes or drop downs with two exceptions:
248
- // (1) The node is detected to be one of the excluded fields, in which case we drop everything
249
- // (2) The node's type is one of the allowed types (like checkboxes)
250
- metadata.privacy = tag === Constant.InputTag && maskDisable.indexOf(type) >= 0 ? current : (exclude ? Privacy.Exclude : Privacy.Text);
251
- break;
252
- case Constant.MaskData in attributes:
253
- metadata.privacy = Privacy.TextImage;
254
- break;
255
- case Constant.UnmaskData in attributes:
256
- metadata.privacy = Privacy.None;
257
- break;
258
- case privacyMap.has(node):
259
- // If this node was explicitly configured to contain sensitive content, honor that privacy setting
260
- metadata.privacy = privacyMap.get(node);
261
- break;
262
- case fraudMap.has(node):
263
- // If this node was explicitly configured to be evaluated for fraud, then also mask content
264
- metadata.privacy = Privacy.Text;
265
- break;
266
- case tag === Constant.TextTag:
267
- // If it's a text node belonging to a STYLE or TITLE tag or one of scrub exceptions, then capture content
268
- let pTag = parent && parent.data ? parent.data.tag : Constant.Empty;
269
- let pSelector = parent && parent.selector ? parent.selector[Selector.Default] : Constant.Empty;
270
- let tags: string[] = [Constant.StyleTag, Constant.TitleTag, Constant.SvgStyle];
271
- metadata.privacy = tags.includes(pTag) || override.some(x => pSelector.indexOf(x) >= 0) ? Privacy.None : current;
272
- break;
273
- case current === Privacy.Sensitive:
274
- // In a mode where we mask sensitive information by default, look through class names to aggressively mask content
275
- metadata.privacy = inspect(attributes[Constant.Class], maskText, metadata);
276
- break;
277
- case tag === Constant.ImageTag:
278
- // Mask images with blob src as it is not publicly available anyway.
279
- if(attributes.src?.startsWith('blob:')){
280
- metadata.privacy = Privacy.TextImage;
281
- }
282
- break;
283
- }
284
- }
285
-
286
- function inspect(input: string, lookup: string[], metadata: NodeMeta): Privacy {
287
- if (input && lookup.some(x => input.indexOf(x) >= 0)) {
288
- return Privacy.Text;
289
- }
290
- return metadata.privacy;
291
- }
292
-
293
- function diff(a: NodeInfo, b: NodeInfo, field: string): boolean {
294
- if (typeof a[field] === "object" && typeof b[field] === "object") {
295
- for (let key in a[field]) { if (a[field][key] !== b[field][key]) { return true; } }
296
- for (let key in b[field]) { if (b[field][key] !== a[field][key]) { return true; } }
297
- return false;
298
- }
299
- return a[field] !== b[field];
300
- }
301
-
302
- function position(parent: NodeValue, child: NodeValue): number {
303
- child.metadata.position = 1;
304
- let idx = parent ? parent.children.indexOf(child.id) : -1;
305
- while (idx-- > 0) {
306
- let sibling = values[parent.children[idx]];
307
- if (child.data.tag === sibling.data.tag) {
308
- child.metadata.position = sibling.metadata.position + 1;
309
- break;
310
- }
311
- }
312
- return child.metadata.position;
313
- }
314
-
315
- function updateSelector(value: NodeValue): void {
316
- let parent = value.parent && value.parent in values ? values[value.parent] : null;
317
- let prefix = parent ? parent.selector : null;
318
- let d = value.data;
319
- let p = position(parent, value);
320
- let s: SelectorInput = { id: value.id, tag: d.tag, prefix, position: p, attributes: d.attributes };
321
- value.selector = [selector.get(s, Selector.Alpha), selector.get(s, Selector.Beta)];
322
- value.hash = value.selector.map(x => x ? hash(x) : null) as [string, string];
323
- value.hash.forEach(h => hashMap[h] = value.id);
324
- }
325
-
326
- export function hashText(hash: string): string {
327
- let id = lookup(hash);
328
- let node = getNode(id);
329
- return node !== null && node.textContent !== null ? node.textContent.substr(0, Setting.ClickText) : '';
330
- }
331
-
332
- export function getNode(id: number): Node {
333
- return nodesMap.has(id) ? nodesMap.get(id) : null;
334
- }
335
-
336
- export function getValue(id: number): NodeValue {
337
- if (id in values) {
338
- return values[id];
339
- }
340
- return null;
341
- }
342
-
343
- export function get(node: Node): NodeValue {
344
- let id = getId(node);
345
- return id in values ? values[id] : null;
346
- }
347
-
348
- export function lookup(hash: string): number {
349
- return hash in hashMap ? hashMap[hash] : null;
350
- }
351
-
352
- export function has(node: Node): boolean {
353
- return nodesMap.has(getId(node));
354
- }
355
-
356
- export function updates(): NodeValue[] {
357
- let output = [];
358
- for (let id of updateMap) {
359
- if (id in values) { output.push(values[id]); }
360
- }
361
- updateMap = [];
362
-
363
- return output;
364
- }
365
-
366
- function remove(id: number, source: Source): void {
367
- if (id in values) {
368
- let value = values[id];
369
- value.metadata.active = false;
370
- value.parent = null;
371
- track(id, source);
372
-
373
- // Clean up node references for removed nodes
374
- removeNodeFromNodesMap(id);
375
- }
376
- }
377
-
378
- function removeNodeFromNodesMap(id: number) {
379
- const nodeToBeRemoved = nodesMap.get(id);
380
- // Shadow dom roots shouldn't be deleted,
381
- // we should keep listening to the mutations there even they're not rendered in the DOM.
382
- if(nodeToBeRemoved?.nodeType === Node.DOCUMENT_FRAGMENT_NODE){
383
- return;
384
- }
385
-
386
- if (nodeToBeRemoved && nodeToBeRemoved?.nodeType === Node.ELEMENT_NODE && nodeToBeRemoved["tagName"] === "IFRAME") {
387
- const iframe = nodeToBeRemoved as HTMLIFrameElement;
388
- removeObserver(iframe);
389
- }
390
-
391
- nodesMap.delete(id);
392
-
393
- let value = id in values ? values[id] : null;
394
- if (value && value.children) {
395
- for (let childId of value.children) {
396
- removeNodeFromNodesMap(childId);
397
- }
398
- }
399
- }
400
-
401
- function updateImageSize(value: NodeValue): void {
402
- // If this element is a image node, and is masked, then track box model for the current element
403
- if (value.data.tag === Constant.ImageTag && value.metadata.privacy === Privacy.TextImage) {
404
- let img = getNode(value.id) as HTMLImageElement;
405
- // We will not capture the natural image dimensions until it loads.
406
- if(img && (!img.complete || img.naturalWidth === 0)){
407
- // This will trigger mutation to update the original width and height after image loads.
408
- bind(img, 'load', () => {
409
- img.setAttribute('data-clarity-loaded', `${shortid()}`);
410
- })
411
- }
412
- value.metadata.size = [];
413
- }
414
- }
415
-
416
- function getPreviousId(node: Node): number {
417
- let id = null;
418
-
419
- // Some nodes may not have an ID by design since Clarity skips over tags like SCRIPT, NOSCRIPT, META, COMMENTS, etc..
420
- // In that case, we keep going back and check for their sibling until we find a sibling with ID or no more sibling nodes are left.
421
- while (id === null && node.previousSibling) {
422
- id = getId(node.previousSibling);
423
- node = node.previousSibling;
424
- }
425
- return id;
426
- }
427
-
428
- function track(id: number, source: Source, changed: boolean = true, parentChanged: boolean = false): void {
429
- if (config.lean && config.lite) { return; }
430
-
431
- // Keep track of the order in which mutations happened, they may not be sequential
432
- // Edge case: If an element is added later on, and pre-discovered element is moved as a child.
433
- // In that case, we need to reorder the pre-discovered element in the update list to keep visualization consistent.
434
- let uIndex = updateMap.indexOf(id);
435
- if (uIndex >= 0 && source === Source.ChildListAdd && parentChanged) {
436
- updateMap.splice(uIndex, 1);
437
- updateMap.push(id);
438
- } else if (uIndex === -1 && changed) { updateMap.push(id); }
439
- }
1
+ import { Privacy } from "@clarity-types/core";
2
+ import { Code, Setting, Severity } from "@clarity-types/data";
3
+ import { Constant, Mask, NodeInfo, NodeMeta, NodeValue, Selector, SelectorInput, Source } from "@clarity-types/layout";
4
+ import config from "@src/core/config";
5
+ import { bind } from "@src/core/event";
6
+ import hash from "@src/core/hash";
7
+ import { shortid } from "@src/data/metadata";
8
+ import * as internal from "@src/diagnostic/internal";
9
+ import { removeObserver } from "@src/layout/node";
10
+ import * as region from "@src/layout/region";
11
+ import * as selector from "@src/layout/selector";
12
+ let index: number = 1;
13
+ let nodesMap: Map<Number, Node> = null; // Maps id => node to retrieve further node details using id.
14
+ let values: NodeValue[] = [];
15
+ let updateMap: number[] = [];
16
+ let hashMap: { [hash: string]: number } = {};
17
+ let override = [];
18
+ let unmask = [];
19
+ let maskText = [];
20
+ let maskExclude = [];
21
+ let maskDisable = [];
22
+ let maskTags = [];
23
+
24
+ // The WeakMap object is a collection of key/value pairs in which the keys are weakly referenced
25
+ let idMap: WeakMap<Node, number> = null; // Maps node => id.
26
+ let iframeMap: WeakMap<Document, HTMLIFrameElement> = null; // Maps iframe's contentDocument => parent iframe element
27
+ let iframeContentMap: WeakMap<HTMLIFrameElement, { doc: Document, win: Window }> = null; // Maps parent iframe element => iframe's contentDocument & contentWindow
28
+ let privacyMap: WeakMap<Node, Privacy> = null; // Maps node => Privacy (enum)
29
+ let fraudMap: WeakMap<Node, number> = null; // Maps node => FraudId (number)
30
+
31
+ export function start(): void {
32
+ reset();
33
+ parse(document, true);
34
+ }
35
+
36
+ export function stop(): void {
37
+ reset();
38
+ }
39
+
40
+ function reset(): void {
41
+ index = 1;
42
+ values = [];
43
+ updateMap = [];
44
+ hashMap = {};
45
+ override = [];
46
+ unmask = [];
47
+ maskText = Mask.Text.split(Constant.Comma);
48
+ maskExclude = Mask.Exclude.split(Constant.Comma);
49
+ maskDisable = Mask.Disable.split(Constant.Comma);
50
+ maskTags = Mask.Tags.split(Constant.Comma);
51
+ nodesMap = new Map();
52
+ idMap = new WeakMap();
53
+ iframeMap = new WeakMap();
54
+ iframeContentMap = new WeakMap();
55
+ privacyMap = new WeakMap();
56
+ fraudMap = new WeakMap();
57
+ selector.reset();
58
+ }
59
+
60
+ // We parse new root nodes for any regions or masked nodes in the beginning (document) and
61
+ // later whenever there are new additions or modifications to DOM (mutations)
62
+ export function parse(root: ParentNode, init: boolean = false): void {
63
+ // Wrap selectors in a try / catch block.
64
+ // It's possible for script to receive invalid selectors, e.g. "'#id'" with extra quotes, and cause the code below to fail
65
+ try {
66
+ // Parse unmask configuration into separate query selectors and override tokens as part of initialization
67
+ if (init) { config.unmask.forEach(x => x.indexOf(Constant.Bang) < 0 ? unmask.push(x) : override.push(x.substr(1))); }
68
+
69
+ // Since mutations may happen on leaf nodes too, e.g. text nodes, which may not support all selector APIs.
70
+ // We ensure that the root note supports querySelectorAll API before executing the code below to identify new regions.
71
+ if ("querySelectorAll" in root) {
72
+ config.regions.forEach(x => root.querySelectorAll(x[1]).forEach(e => region.observe(e, `${x[0]}`))); // Regions
73
+ config.mask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.TextImage))); // Masked Elements
74
+ config.checksum.forEach(x => root.querySelectorAll(x[1]).forEach(e => fraudMap.set(e, x[0]))); // Fraud Checksum Check
75
+ unmask.forEach(x => root.querySelectorAll(x).forEach(e => privacyMap.set(e, Privacy.None))); // Unmasked Elements
76
+ }
77
+ } catch (e) { internal.log(Code.Selector, Severity.Warning, e ? e.name : null); }
78
+ }
79
+
80
+ export function getId(node: Node, autogen: boolean = false): number {
81
+ if (node === null) { return null; }
82
+ let id = idMap.get(node);
83
+ if (!id && autogen) {
84
+ id = index++;
85
+ idMap.set(node, id);
86
+ }
87
+
88
+ return id ? id : null;
89
+ }
90
+
91
+ export function add(node: Node, parent: Node, data: NodeInfo, source: Source): void {
92
+ let parentId = parent ? getId(parent) : null;
93
+
94
+ // Do not add detached nodes
95
+ if ((!parent || !parentId) && (node as ShadowRoot).host == null && node.nodeType !== Node.DOCUMENT_TYPE_NODE) {
96
+ return;
97
+ }
98
+
99
+ let id = getId(node, true);
100
+ let previousId = getPreviousId(node);
101
+ let parentValue: NodeValue = null;
102
+ let regionId = region.exists(node) ? id : null;
103
+ let fraudId = fraudMap.has(node) ? fraudMap.get(node) : null;
104
+ let privacyId = config.content ? Privacy.Sensitive : Privacy.TextImage
105
+ if (parentId >= 0 && values[parentId]) {
106
+ parentValue = values[parentId];
107
+ parentValue.children.push(id);
108
+ regionId = regionId === null ? parentValue.region : regionId;
109
+ fraudId = fraudId === null ? parentValue.metadata.fraud : fraudId;
110
+ privacyId = parentValue.metadata.privacy;
111
+ }
112
+
113
+ // If there's an explicit region attribute set on the element, use it to mark a region on the page
114
+ if (data.attributes && Constant.RegionData in data.attributes) {
115
+ region.observe(node, data.attributes[Constant.RegionData]);
116
+ regionId = id;
117
+ }
118
+
119
+ nodesMap.set(id, node);
120
+ values[id] = {
121
+ id,
122
+ parent: parentId,
123
+ previous: previousId,
124
+ children: [],
125
+ data,
126
+ selector: null,
127
+ hash: null,
128
+ region: regionId,
129
+ metadata: { active: true, suspend: false, privacy: privacyId, position: null, fraud: fraudId, size: null },
130
+ };
131
+
132
+ privacy(node, values[id], parentValue);
133
+ updateSelector(values[id]);
134
+ updateImageSize(values[id]);
135
+ track(id, source);
136
+ }
137
+
138
+ export function update(node: Node, parent: Node, data: NodeInfo, source: Source): void {
139
+ let id = getId(node);
140
+ let parentId = parent ? getId(parent) : null;
141
+ let previousId = getPreviousId(node);
142
+ let changed = false;
143
+ let parentChanged = false;
144
+
145
+ if (id in values) {
146
+ let value = values[id];
147
+ value.metadata.active = true;
148
+
149
+ // Handle case where internal ordering may have changed
150
+ if (value.previous !== previousId) {
151
+ changed = true;
152
+ value.previous = previousId;
153
+ }
154
+
155
+ // Handle case where parent might have been updated
156
+ if (value.parent !== parentId) {
157
+ changed = true;
158
+ let oldParentId = value.parent;
159
+ value.parent = parentId;
160
+ // Move this node to the right location under new parent
161
+ if (parentId !== null && parentId >= 0) {
162
+ let childIndex = previousId === null ? 0 : values[parentId].children.indexOf(previousId) + 1;
163
+ values[parentId].children.splice(childIndex, 0, id);
164
+ // Update region after the move
165
+ value.region = region.exists(node) ? id : values[parentId].region;
166
+ } else {
167
+ // Mark this element as deleted if the parent has been updated to null
168
+ remove(id, source);
169
+ }
170
+
171
+ // Remove reference to this node from the old parent
172
+ if (oldParentId !== null && oldParentId >= 0) {
173
+ let nodeIndex = values[oldParentId].children.indexOf(id);
174
+ if (nodeIndex >= 0) {
175
+ values[oldParentId].children.splice(nodeIndex, 1);
176
+ }
177
+ }
178
+ parentChanged = true;
179
+ }
180
+
181
+ // Update data
182
+ for (let key in data) {
183
+ if (diff(value["data"], data, key)) {
184
+ changed = true;
185
+ value["data"][key] = data[key];
186
+ }
187
+ }
188
+
189
+ // Update selector
190
+ updateSelector(value);
191
+ track(id, source, changed, parentChanged);
192
+ }
193
+ }
194
+
195
+ export function sameorigin(node: Node): boolean {
196
+ let output = false;
197
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === Constant.IFrameTag) {
198
+ let frame = node as HTMLIFrameElement;
199
+ // To determine if the iframe is same-origin or not, we try accessing it's contentDocument.
200
+ // If the browser throws an exception, we assume it's cross-origin and move on.
201
+ // However, if we do a get a valid document object back, we assume the contents are accessible and iframe is same-origin.
202
+ try {
203
+ let doc = frame.contentDocument;
204
+ if (doc) {
205
+ iframeMap.set(frame.contentDocument, frame);
206
+ iframeContentMap.set(frame, { doc: frame.contentDocument, win: frame.contentWindow });
207
+ output = true;
208
+ }
209
+ } catch { /* do nothing */ }
210
+ }
211
+ return output;
212
+ }
213
+
214
+ export function iframe(node: Node): HTMLIFrameElement {
215
+ let doc = node.nodeType === Node.DOCUMENT_NODE ? node as Document : null;
216
+ return doc && iframeMap.has(doc) ? iframeMap.get(doc) : null;
217
+ }
218
+
219
+ export function iframeContent(frame: HTMLIFrameElement): {doc: Document, win: Window } {
220
+ if (iframeContentMap.has(frame)) {
221
+ return iframeContentMap.get(frame);
222
+ }
223
+ return null;
224
+ }
225
+
226
+ export function removeIFrame(frame: HTMLIFrameElement, doc: Document): void {
227
+ iframeContentMap.delete(frame);
228
+ iframeMap.delete(doc);
229
+ }
230
+
231
+ function privacy(node: Node, value: NodeValue, parent: NodeValue): void {
232
+ let data = value.data;
233
+ let metadata = value.metadata;
234
+ let current = metadata.privacy;
235
+ let attributes = data.attributes || {};
236
+ let tag = data.tag.toUpperCase();
237
+
238
+ switch (true) {
239
+ case maskTags.indexOf(tag) >= 0:
240
+ let type = attributes[Constant.Type];
241
+ let meta: string = Constant.Empty;
242
+ const excludedPrivacyAttributes = [Constant.Class, Constant.Style]
243
+ Object.keys(attributes)
244
+ .filter((x) => !excludedPrivacyAttributes.includes(x as Constant))
245
+ .forEach((x) => (meta += attributes[x].toLowerCase()));
246
+ let exclude = maskExclude.some((x) => meta.indexOf(x) >= 0);
247
+ // Regardless of privacy mode, always mask off user input from input boxes or drop downs with two exceptions:
248
+ // (1) The node is detected to be one of the excluded fields, in which case we drop everything
249
+ // (2) The node's type is one of the allowed types (like checkboxes)
250
+ metadata.privacy = tag === Constant.InputTag && maskDisable.indexOf(type) >= 0 ? current : (exclude ? Privacy.Exclude : Privacy.Text);
251
+ break;
252
+ case Constant.MaskData in attributes:
253
+ metadata.privacy = Privacy.TextImage;
254
+ break;
255
+ case Constant.UnmaskData in attributes:
256
+ metadata.privacy = Privacy.None;
257
+ break;
258
+ case privacyMap.has(node):
259
+ // If this node was explicitly configured to contain sensitive content, honor that privacy setting
260
+ metadata.privacy = privacyMap.get(node);
261
+ break;
262
+ case fraudMap.has(node):
263
+ // If this node was explicitly configured to be evaluated for fraud, then also mask content
264
+ metadata.privacy = Privacy.Text;
265
+ break;
266
+ case tag === Constant.TextTag:
267
+ // If it's a text node belonging to a STYLE or TITLE tag or one of scrub exceptions, then capture content
268
+ let pTag = parent && parent.data ? parent.data.tag : Constant.Empty;
269
+ let pSelector = parent && parent.selector ? parent.selector[Selector.Default] : Constant.Empty;
270
+ let tags: string[] = [Constant.StyleTag, Constant.TitleTag, Constant.SvgStyle];
271
+ metadata.privacy = tags.includes(pTag) || override.some(x => pSelector.indexOf(x) >= 0) ? Privacy.None : current;
272
+ break;
273
+ case current === Privacy.Sensitive:
274
+ // In a mode where we mask sensitive information by default, look through class names to aggressively mask content
275
+ metadata.privacy = inspect(attributes[Constant.Class], maskText, metadata);
276
+ break;
277
+ case tag === Constant.ImageTag:
278
+ // Mask images with blob src as it is not publicly available anyway.
279
+ if(attributes.src?.startsWith('blob:')){
280
+ metadata.privacy = Privacy.TextImage;
281
+ }
282
+ break;
283
+ }
284
+ }
285
+
286
+ function inspect(input: string, lookup: string[], metadata: NodeMeta): Privacy {
287
+ if (input && lookup.some(x => input.indexOf(x) >= 0)) {
288
+ return Privacy.Text;
289
+ }
290
+ return metadata.privacy;
291
+ }
292
+
293
+ function diff(a: NodeInfo, b: NodeInfo, field: string): boolean {
294
+ if (typeof a[field] === "object" && typeof b[field] === "object") {
295
+ for (let key in a[field]) { if (a[field][key] !== b[field][key]) { return true; } }
296
+ for (let key in b[field]) { if (b[field][key] !== a[field][key]) { return true; } }
297
+ return false;
298
+ }
299
+ return a[field] !== b[field];
300
+ }
301
+
302
+ function position(parent: NodeValue, child: NodeValue): number {
303
+ child.metadata.position = 1;
304
+ let idx = parent ? parent.children.indexOf(child.id) : -1;
305
+ while (idx-- > 0) {
306
+ let sibling = values[parent.children[idx]];
307
+ if (child.data.tag === sibling.data.tag) {
308
+ child.metadata.position = sibling.metadata.position + 1;
309
+ break;
310
+ }
311
+ }
312
+ return child.metadata.position;
313
+ }
314
+
315
+ function updateSelector(value: NodeValue): void {
316
+ let parent = value.parent && value.parent in values ? values[value.parent] : null;
317
+ let prefix = parent ? parent.selector : null;
318
+ let d = value.data;
319
+ let p = position(parent, value);
320
+ let s: SelectorInput = { id: value.id, tag: d.tag, prefix, position: p, attributes: d.attributes };
321
+ value.selector = [selector.get(s, Selector.Alpha), selector.get(s, Selector.Beta)];
322
+ value.hash = value.selector.map(x => x ? hash(x) : null) as [string, string];
323
+ value.hash.forEach(h => hashMap[h] = value.id);
324
+ }
325
+
326
+ export function hashText(hash: string): string {
327
+ let id = lookup(hash);
328
+ let node = getNode(id);
329
+ return node !== null && node.textContent !== null ? node.textContent.substr(0, Setting.ClickText) : '';
330
+ }
331
+
332
+ export function getNode(id: number): Node {
333
+ return nodesMap.has(id) ? nodesMap.get(id) : null;
334
+ }
335
+
336
+ export function getValue(id: number): NodeValue {
337
+ if (id in values) {
338
+ return values[id];
339
+ }
340
+ return null;
341
+ }
342
+
343
+ export function get(node: Node): NodeValue {
344
+ let id = getId(node);
345
+ return id in values ? values[id] : null;
346
+ }
347
+
348
+ export function lookup(hash: string): number {
349
+ return hash in hashMap ? hashMap[hash] : null;
350
+ }
351
+
352
+ export function has(node: Node): boolean {
353
+ return nodesMap.has(getId(node));
354
+ }
355
+
356
+ export function updates(): NodeValue[] {
357
+ let output = [];
358
+ for (let id of updateMap) {
359
+ if (id in values) { output.push(values[id]); }
360
+ }
361
+ updateMap = [];
362
+
363
+ return output;
364
+ }
365
+
366
+ function remove(id: number, source: Source): void {
367
+ if (id in values) {
368
+ let value = values[id];
369
+ value.metadata.active = false;
370
+ value.parent = null;
371
+ track(id, source);
372
+
373
+ // Clean up node references for removed nodes
374
+ removeNodeFromNodesMap(id);
375
+ }
376
+ }
377
+
378
+ function removeNodeFromNodesMap(id: number) {
379
+ const nodeToBeRemoved = nodesMap.get(id);
380
+ // Shadow dom roots shouldn't be deleted,
381
+ // we should keep listening to the mutations there even they're not rendered in the DOM.
382
+ if(nodeToBeRemoved?.nodeType === Node.DOCUMENT_FRAGMENT_NODE){
383
+ return;
384
+ }
385
+
386
+ if (nodeToBeRemoved && nodeToBeRemoved?.nodeType === Node.ELEMENT_NODE && nodeToBeRemoved["tagName"] === "IFRAME") {
387
+ const iframe = nodeToBeRemoved as HTMLIFrameElement;
388
+ removeObserver(iframe);
389
+ }
390
+
391
+ nodesMap.delete(id);
392
+
393
+ let value = id in values ? values[id] : null;
394
+ if (value && value.children) {
395
+ for (let childId of value.children) {
396
+ removeNodeFromNodesMap(childId);
397
+ }
398
+ }
399
+ }
400
+
401
+ function updateImageSize(value: NodeValue): void {
402
+ // If this element is a image node, and is masked, then track box model for the current element
403
+ if (value.data.tag === Constant.ImageTag && value.metadata.privacy === Privacy.TextImage) {
404
+ let img = getNode(value.id) as HTMLImageElement;
405
+ // We will not capture the natural image dimensions until it loads.
406
+ if(img && (!img.complete || img.naturalWidth === 0)){
407
+ // This will trigger mutation to update the original width and height after image loads.
408
+ bind(img, 'load', () => {
409
+ img.setAttribute('data-clarity-loaded', `${shortid()}`);
410
+ })
411
+ }
412
+ value.metadata.size = [];
413
+ }
414
+ }
415
+
416
+ function getPreviousId(node: Node): number {
417
+ let id = null;
418
+
419
+ // Some nodes may not have an ID by design since Clarity skips over tags like SCRIPT, NOSCRIPT, META, COMMENTS, etc..
420
+ // In that case, we keep going back and check for their sibling until we find a sibling with ID or no more sibling nodes are left.
421
+ while (id === null && node.previousSibling) {
422
+ id = getId(node.previousSibling);
423
+ node = node.previousSibling;
424
+ }
425
+ return id;
426
+ }
427
+
428
+ function track(id: number, source: Source, changed: boolean = true, parentChanged: boolean = false): void {
429
+ if (config.lean && config.lite) { return; }
430
+
431
+ // Keep track of the order in which mutations happened, they may not be sequential
432
+ // Edge case: If an element is added later on, and pre-discovered element is moved as a child.
433
+ // In that case, we need to reorder the pre-discovered element in the update list to keep visualization consistent.
434
+ let uIndex = updateMap.indexOf(id);
435
+ if (uIndex >= 0 && source === Source.ChildListAdd && parentChanged) {
436
+ updateMap.splice(uIndex, 1);
437
+ updateMap.push(id);
438
+ } else if (uIndex === -1 && changed) { updateMap.push(id); }
439
+ }