Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/injected-scripts/pageEventsRecorder.ts
1028 views
1
// NOTE: do not use node dependencies
2
// eslint-disable-next-line max-classes-per-file
3
import { IDomChangeEvent, INodeData } from '@secret-agent/interfaces/IDomChangeEvent';
4
import { IMouseEvent } from '@secret-agent/interfaces/IMouseEvent';
5
import { IFocusEvent } from '@secret-agent/interfaces/IFocusEvent';
6
import { IScrollEvent } from '@secret-agent/interfaces/IScrollEvent';
7
import { ILoadEvent } from '@secret-agent/interfaces/ILoadEvent';
8
9
enum DomActionType {
10
newDocument = 0,
11
location = 1,
12
added = 2,
13
removed = 3,
14
text = 4,
15
attribute = 5,
16
property = 6,
17
}
18
19
const MutationRecordType = {
20
attributes: 'attributes',
21
childList: 'childList',
22
characterData: 'characterData',
23
};
24
25
// exporting a type is ok. Don't export variables or will blow up the page
26
export type PageRecorderResultSet = [
27
IDomChangeEvent[],
28
IMouseEvent[],
29
IFocusEvent[],
30
IScrollEvent[],
31
ILoadEvent[],
32
];
33
const SHADOW_NODE_TYPE = 40;
34
35
// @ts-ignore
36
const eventsCallback = (window[runtimeFunction] as unknown) as (data: string) => void;
37
// @ts-ignore
38
delete window[runtimeFunction];
39
40
let lastUploadDate: Date;
41
42
function upload(records: PageRecorderResultSet) {
43
try {
44
const total = records.reduce((tot, ent) => tot + ent.length, 0);
45
if (total > 0) {
46
eventsCallback(JSON.stringify(records));
47
}
48
lastUploadDate = new Date();
49
return true;
50
} catch (err) {
51
// eslint-disable-next-line no-console
52
console.log(`ERROR calling page recorder callback: ${String(err)}`, err);
53
}
54
return false;
55
}
56
57
let eventCounter = 0;
58
59
function idx() {
60
return (eventCounter += 1);
61
}
62
63
let isStarted = false;
64
65
class PageEventsRecorder {
66
private domChanges: IDomChangeEvent[] = [];
67
68
private mouseEvents: IMouseEvent[] = [];
69
private focusEvents: IFocusEvent[] = [];
70
private scrollEvents: IScrollEvent[] = [];
71
private loadEvents: ILoadEvent[] = [];
72
private location = window.self.location.href;
73
74
private isListeningForInteractionEvents = false;
75
76
private propertyTrackingElements = new Map<Node, Map<string, string | boolean>>();
77
private stylesheets = new Map<HTMLStyleElement | HTMLLinkElement, string[]>();
78
79
private readonly observer: MutationObserver;
80
81
constructor() {
82
this.observer = new MutationObserver(this.onMutation.bind(this));
83
}
84
85
public start() {
86
if (isStarted || window.self.location.href === 'about:blank') {
87
return;
88
}
89
isStarted = true;
90
91
const stamp = new Date().getTime();
92
// preload with a document
93
this.domChanges.push([
94
DomActionType.newDocument,
95
{
96
id: -1,
97
textContent: window.self.location.href,
98
},
99
stamp,
100
idx(),
101
]);
102
if (document) {
103
this.domChanges.push([DomActionType.added, this.serializeNode(document), stamp, idx()]);
104
}
105
106
if (document && document.doctype) {
107
this.domChanges.push([
108
DomActionType.added,
109
this.serializeNode(document.doctype),
110
stamp,
111
idx(),
112
]);
113
}
114
const children = this.serializeChildren(document, new Map<Node, INodeData>());
115
this.observer.observe(document, {
116
attributes: true,
117
childList: true,
118
subtree: true,
119
characterData: true,
120
});
121
for (const childData of children) {
122
this.domChanges.push([DomActionType.added, childData, stamp, idx()]);
123
}
124
this.uploadChanges();
125
}
126
127
public extractChanges(): PageRecorderResultSet {
128
const changes = this.convertMutationsToChanges(this.observer.takeRecords());
129
this.domChanges.push(...changes);
130
return this.pageResultset;
131
}
132
133
public flushAndReturnLists(): PageRecorderResultSet {
134
const changes = recorder.extractChanges();
135
136
recorder.resetLists();
137
return changes;
138
}
139
140
public trackFocus(eventType: FocusType, focusEvent: FocusEvent) {
141
const nodeId = NodeTracker.getNodeId(focusEvent.target as Node);
142
const relatedNodeId = NodeTracker.getNodeId(focusEvent.relatedTarget as Node);
143
const time = new Date().getTime();
144
const event = [eventType as any, nodeId, relatedNodeId, time] as IFocusEvent;
145
this.focusEvents.push(event);
146
this.getPropertyChanges(time, this.domChanges);
147
}
148
149
public trackMouse(eventType: MouseEventType, mouseEvent: MouseEvent) {
150
const nodeId = NodeTracker.getNodeId(mouseEvent.target as Node);
151
const relatedNodeId = NodeTracker.getNodeId(mouseEvent.relatedTarget as Node);
152
const event = [
153
eventType,
154
mouseEvent.pageX,
155
mouseEvent.pageY,
156
// might not want to do this - causes reflow
157
mouseEvent.offsetX,
158
mouseEvent.offsetY,
159
mouseEvent.buttons,
160
nodeId,
161
relatedNodeId,
162
new Date().getTime(),
163
] as IMouseEvent;
164
this.mouseEvents.push(event);
165
}
166
167
public trackScroll(scrollX: number, scrollY: number) {
168
this.scrollEvents.push([scrollX, scrollY, new Date().getTime()]);
169
}
170
171
public onLoadEvent(name: string) {
172
this.start();
173
this.loadEvents.push([name, window.self.location.href, new Date().getTime()]);
174
this.uploadChanges();
175
}
176
177
public checkForAllPropertyChanges() {
178
this.getPropertyChanges(new Date().getTime(), this.domChanges);
179
}
180
181
public get pageResultset(): PageRecorderResultSet {
182
return [
183
[...this.domChanges],
184
[...this.mouseEvents],
185
[...this.focusEvents],
186
[...this.scrollEvents],
187
[...this.loadEvents],
188
];
189
}
190
191
public resetLists() {
192
this.domChanges.length = 0;
193
this.mouseEvents.length = 0;
194
this.focusEvents.length = 0;
195
this.scrollEvents.length = 0;
196
this.loadEvents.length = 0;
197
}
198
199
public disconnect() {
200
this.extractChanges();
201
this.observer.disconnect();
202
this.uploadChanges();
203
}
204
205
public uploadChanges() {
206
if (upload(this.pageResultset)) {
207
this.resetLists();
208
}
209
}
210
211
public listenToInteractionEvents() {
212
if (this.isListeningForInteractionEvents) return;
213
this.isListeningForInteractionEvents = true;
214
for (const event of ['input', 'keydown', 'change']) {
215
document.addEventListener(event, this.checkForAllPropertyChanges.bind(this), {
216
capture: true,
217
passive: true,
218
});
219
}
220
221
document.addEventListener('mousemove', e => this.trackMouse(MouseEventType.MOVE, e), {
222
capture: true,
223
passive: true,
224
});
225
226
document.addEventListener('mousedown', e => this.trackMouse(MouseEventType.DOWN, e), {
227
capture: true,
228
passive: true,
229
});
230
231
document.addEventListener('mouseup', e => this.trackMouse(MouseEventType.UP, e), {
232
capture: true,
233
passive: true,
234
});
235
236
document.addEventListener('mouseover', e => this.trackMouse(MouseEventType.OVER, e), {
237
capture: true,
238
passive: true,
239
});
240
241
document.addEventListener('mouseleave', e => this.trackMouse(MouseEventType.OUT, e), {
242
capture: true,
243
passive: true,
244
});
245
246
document.addEventListener('focusin', e => this.trackFocus(FocusType.IN, e), {
247
capture: true,
248
passive: true,
249
});
250
251
document.addEventListener('focusout', e => this.trackFocus(FocusType.OUT, e), {
252
capture: true,
253
passive: true,
254
});
255
256
document.addEventListener('scroll', () => this.trackScroll(window.scrollX, window.scrollY), {
257
capture: true,
258
passive: true,
259
});
260
}
261
262
private getLocationChange(changeUnixTime: number, changes: IDomChangeEvent[]) {
263
const timestamp = changeUnixTime || new Date().getTime();
264
const currentLocation = window.self.location.href;
265
if (this.location !== currentLocation) {
266
this.location = currentLocation;
267
changes.push([
268
DomActionType.location,
269
{ id: -1, textContent: currentLocation },
270
timestamp,
271
idx(),
272
]);
273
}
274
}
275
276
private getPropertyChanges(changeUnixTime: number, changes: IDomChangeEvent[]) {
277
for (const [input, propertyMap] of this.propertyTrackingElements) {
278
for (const [propertyName, value] of propertyMap) {
279
const newPropValue = input[propertyName];
280
if (newPropValue !== value) {
281
const nodeId = NodeTracker.getNodeId(input);
282
changes.push([
283
DomActionType.property,
284
{ id: nodeId, properties: { [propertyName]: newPropValue } },
285
changeUnixTime,
286
idx(),
287
]);
288
propertyMap.set(propertyName, newPropValue);
289
}
290
}
291
}
292
}
293
294
private trackStylesheet(element: HTMLStyleElement) {
295
if (!element || this.stylesheets.has(element)) return;
296
if (!element.sheet) return;
297
298
const shouldStoreCurrentStyleState = !!element.textContent;
299
if (element.sheet instanceof CSSStyleSheet) {
300
try {
301
// if there's style text, record the current state
302
const startingStyle = shouldStoreCurrentStyleState
303
? [...element.sheet.cssRules].map(x => x.cssText)
304
: [];
305
this.stylesheets.set(element, startingStyle);
306
} catch (err) {
307
// can't track cors stylesheet rules
308
}
309
}
310
}
311
312
private checkForStylesheetChanges(changeUnixTime: number, changes: IDomChangeEvent[]) {
313
const timestamp = changeUnixTime || new Date().getTime();
314
for (const [style, current] of this.stylesheets) {
315
if (!style.sheet || !style.isConnected) continue;
316
const sheet = style.sheet as CSSStyleSheet;
317
const newPropValue = [...sheet.cssRules].map(x => x.cssText);
318
if (newPropValue.toString() !== current.toString()) {
319
const nodeId = NodeTracker.getNodeId(style);
320
changes.push([
321
DomActionType.property,
322
{ id: nodeId, properties: { 'sheet.cssRules': newPropValue } },
323
timestamp,
324
idx(),
325
]);
326
this.stylesheets.set(style, newPropValue);
327
}
328
}
329
}
330
331
private onMutation(mutations: MutationRecord[]) {
332
const changes = this.convertMutationsToChanges(mutations);
333
this.domChanges.push(...changes);
334
}
335
336
private convertMutationsToChanges(mutations: MutationRecord[]) {
337
const changes: IDomChangeEvent[] = [];
338
const stamp = new Date().getTime();
339
340
this.getLocationChange(stamp, changes);
341
this.getPropertyChanges(stamp, changes);
342
343
const addedNodeMap = new Map<Node, INodeData>();
344
const removedNodes = new Set<Node>();
345
346
for (const mutation of mutations) {
347
const { type, target } = mutation;
348
if (!NodeTracker.has(target)) {
349
this.serializeHierarchy(target, changes, stamp, addedNodeMap);
350
}
351
352
if (type === MutationRecordType.childList) {
353
let isFirstRemoved = true;
354
for (let i = 0, length = mutation.removedNodes.length; i < length; i += 1) {
355
const node = mutation.removedNodes[i];
356
removedNodes.add(node);
357
if (!NodeTracker.has(node)) continue;
358
const serial = this.serializeNode(node);
359
serial.parentNodeId = NodeTracker.getNodeId(target);
360
serial.previousSiblingId = NodeTracker.getNodeId(
361
isFirstRemoved ? mutation.previousSibling : node.previousSibling,
362
);
363
changes.push([DomActionType.removed, serial, stamp, idx()]);
364
isFirstRemoved = false;
365
}
366
367
// A batch of changes includes changes in a set of nodes.
368
// Since we're flattening, only the first one should be added after the mutation sibling.
369
let isFirstAdded = true;
370
for (let i = 0, length = mutation.addedNodes.length; i < length; i += 1) {
371
const node = mutation.addedNodes[i];
372
const serial = this.serializeNode(node);
373
serial.parentNodeId = NodeTracker.getNodeId(target);
374
serial.previousSiblingId = NodeTracker.getNodeId(
375
isFirstAdded ? mutation.previousSibling : node.previousSibling,
376
);
377
isFirstAdded = false;
378
// if we get a re-order of nodes, sometimes we'll remove nodes, and add them again
379
if (addedNodeMap.has(node) && !removedNodes.has(node)) {
380
const existing = addedNodeMap.get(node);
381
if (
382
existing.previousSiblingId === serial.previousSiblingId &&
383
existing.parentNodeId === serial.parentNodeId
384
) {
385
continue;
386
}
387
}
388
addedNodeMap.set(node, serial);
389
changes.push([DomActionType.added, serial, stamp, idx()]);
390
}
391
}
392
393
if (type === MutationRecordType.attributes) {
394
// don't store
395
if (!NodeTracker.has(target)) {
396
this.serializeHierarchy(target, changes, stamp, addedNodeMap);
397
}
398
const serial = addedNodeMap.get(target) || this.serializeNode(target);
399
if (!serial.attributes) serial.attributes = {};
400
serial.attributes[mutation.attributeName] = (target as Element).getAttributeNS(
401
mutation.attributeNamespace,
402
mutation.attributeName,
403
);
404
if (mutation.attributeNamespace && mutation.attributeNamespace !== '') {
405
if (!serial.attributeNamespaces) serial.attributeNamespaces = {};
406
serial.attributeNamespaces[mutation.attributeName] = mutation.attributeNamespace;
407
}
408
409
// flatten changes
410
if (!addedNodeMap.has(target)) {
411
changes.push([DomActionType.attribute, serial, stamp, idx()]);
412
}
413
}
414
415
if (type === MutationRecordType.characterData) {
416
const textChange = this.serializeNode(target);
417
textChange.textContent = target.textContent;
418
changes.push([DomActionType.text, textChange, stamp, idx()]);
419
}
420
}
421
422
for (const [node] of addedNodeMap) {
423
// A batch of changes (setting innerHTML) will send nodes in a hierarchy instead of
424
// individually so we need to extract child nodes into flat hierarchy
425
const children = this.serializeChildren(node, addedNodeMap);
426
for (const childData of children) {
427
changes.push([DomActionType.added, childData, stamp, idx()]);
428
}
429
}
430
431
this.checkForStylesheetChanges(stamp, changes);
432
433
return changes;
434
}
435
436
private serializeHierarchy(
437
node: Node,
438
changes: IDomChangeEvent[],
439
changeTime: number,
440
addedNodeMap: Map<Node, INodeData>,
441
) {
442
if (NodeTracker.has(node)) return this.serializeNode(node);
443
444
const serial = this.serializeNode(node);
445
serial.parentNodeId = NodeTracker.getNodeId(node.parentNode);
446
if (!serial.parentNodeId && node.parentNode) {
447
const parentSerial = this.serializeHierarchy(
448
node.parentNode,
449
changes,
450
changeTime,
451
addedNodeMap,
452
);
453
454
serial.parentNodeId = parentSerial.id;
455
}
456
serial.previousSiblingId = NodeTracker.getNodeId(node.previousSibling);
457
if (!serial.previousSiblingId && node.previousSibling) {
458
const previous = this.serializeHierarchy(
459
node.previousSibling,
460
changes,
461
changeTime,
462
addedNodeMap,
463
);
464
serial.previousSiblingId = previous.id;
465
}
466
changes.push([DomActionType.added, serial, changeTime, idx()]);
467
addedNodeMap.set(node, serial);
468
return serial;
469
}
470
471
private serializeChildren(node: Node, addedNodes: Map<Node, INodeData>) {
472
const serialized: INodeData[] = [];
473
474
for (const child of node.childNodes) {
475
if (!NodeTracker.has(child)) {
476
const serial = this.serializeNode(child);
477
serial.parentNodeId = NodeTracker.getNodeId(child.parentElement ?? child.getRootNode());
478
serial.previousSiblingId = NodeTracker.getNodeId(child.previousSibling);
479
addedNodes.set(child, serial);
480
serialized.push(serial, ...this.serializeChildren(child, addedNodes));
481
}
482
}
483
484
for (const element of [node, ...node.childNodes] as Element[]) {
485
if (element.tagName === 'STYLE') {
486
this.trackStylesheet(element as HTMLStyleElement);
487
}
488
const shadowRoot = element.shadowRoot;
489
if (shadowRoot && !NodeTracker.has(shadowRoot)) {
490
const serial = this.serializeNode(shadowRoot);
491
serial.parentNodeId = NodeTracker.getNodeId(element);
492
serialized.push(serial, ...this.serializeChildren(shadowRoot, addedNodes));
493
this.observer.observe(shadowRoot, {
494
attributes: true,
495
childList: true,
496
subtree: true,
497
characterData: true,
498
});
499
}
500
}
501
502
return serialized;
503
}
504
505
private serializeNode(node: Node): INodeData {
506
if (node === null) {
507
return undefined;
508
}
509
510
const id = NodeTracker.getNodeId(node);
511
if (id !== undefined) {
512
return { id };
513
}
514
515
const data: INodeData = {
516
nodeType: node.nodeType,
517
id: NodeTracker.track(node),
518
};
519
520
if (node instanceof ShadowRoot) {
521
data.nodeType = SHADOW_NODE_TYPE;
522
return data;
523
}
524
525
switch (data.nodeType) {
526
case Node.COMMENT_NODE:
527
case Node.TEXT_NODE:
528
data.textContent = node.textContent;
529
break;
530
531
case Node.DOCUMENT_TYPE_NODE:
532
data.textContent = new XMLSerializer().serializeToString(node);
533
break;
534
535
case Node.ELEMENT_NODE:
536
const element = node as Element;
537
data.tagName = element.tagName;
538
if (element.namespaceURI && element.namespaceURI !== defaultNamespaceUri) {
539
data.namespaceUri = element.namespaceURI;
540
}
541
542
if (element.attributes.length) {
543
data.attributes = {};
544
for (let i = 0, length = element.attributes.length; i < length; i += 1) {
545
const attr = element.attributes[i];
546
data.attributes[attr.name] = attr.value;
547
if (attr.namespaceURI && attr.namespaceURI !== defaultNamespaceUri) {
548
if (!data.attributeNamespaces) data.attributeNamespaces = {};
549
data.attributeNamespaces[attr.name] = attr.namespaceURI;
550
}
551
}
552
}
553
554
let propertyChecks: [string, string | boolean][];
555
for (const prop of propertiesToCheck) {
556
if (prop in element) {
557
if (!propertyChecks) propertyChecks = [];
558
propertyChecks.push([prop, element[prop]]);
559
}
560
}
561
if (propertyChecks) {
562
const propsMap = new Map<string, string | boolean>(propertyChecks);
563
this.propertyTrackingElements.set(node, propsMap);
564
}
565
break;
566
}
567
568
return data;
569
}
570
}
571
572
const defaultNamespaceUri = 'http://www.w3.org/1999/xhtml';
573
const propertiesToCheck = ['value', 'selected', 'checked'];
574
575
const recorder = new PageEventsRecorder();
576
577
// @ts-ignore
578
window.extractDomChanges = () => recorder.extractChanges();
579
// @ts-ignore
580
window.flushPageRecorder = () => recorder.flushAndReturnLists();
581
// @ts-ignore
582
window.listenForInteractionEvents = () => recorder.listenToInteractionEvents();
583
584
const interval = setInterval(() => {
585
if (!lastUploadDate || new Date().getTime() - lastUploadDate.getTime() > 1e3) {
586
// if we haven't uploaded in 1 second, make sure nothing is pending
587
requestAnimationFrame(() => recorder.uploadChanges());
588
}
589
}, 500);
590
591
window.addEventListener('DOMContentLoaded', () => {
592
// force domContentLoaded to come first
593
recorder.onLoadEvent('DOMContentLoaded');
594
});
595
596
window.addEventListener('load', () => recorder.onLoadEvent('load'));
597
598
if (window.self.location?.href !== 'about:blank') {
599
window.addEventListener('beforeunload', () => {
600
clearInterval(interval);
601
recorder.disconnect();
602
});
603
604
const paintObserver = new PerformanceObserver(entryList => {
605
if (entryList.getEntriesByName('first-contentful-paint').length) {
606
recorder.start();
607
paintObserver.disconnect();
608
}
609
});
610
paintObserver.observe({ type: 'paint', buffered: true });
611
612
const contentStableObserver = new PerformanceObserver(() => {
613
recorder.onLoadEvent('LargestContentfulPaint');
614
contentStableObserver.disconnect();
615
});
616
contentStableObserver.observe({ type: 'largest-contentful-paint', buffered: true });
617
}
618
619
// need duplicate since this is a variable - not just a type
620
enum MouseEventType {
621
MOVE = 0,
622
DOWN = 1,
623
UP = 2,
624
OVER = 3,
625
OUT = 4,
626
}
627
628
enum FocusType {
629
IN = 0,
630
OUT = 1,
631
}
632
633