Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/core/injected-scripts/domReplayer.ts
1028 views
1
// NOTE: do not use node dependencies
2
3
import type { IFrontendDomChangeEvent } from '@secret-agent/core/models/DomChangesTable';
4
5
declare global {
6
interface Window {
7
replayDomChanges(...args: any[]);
8
replayInteractions(...args: any[]);
9
getIsMainFrame?: () => boolean;
10
debugLogs: any[];
11
debugToConsole: boolean;
12
selfFrameIdPath: string;
13
getNodeById(id: number): Node;
14
}
15
}
16
17
// copied since we can't import data types
18
enum DomActionType {
19
newDocument = 0,
20
location = 1,
21
added = 2,
22
removed = 3,
23
text = 4,
24
attribute = 5,
25
property = 6,
26
}
27
28
const SHADOW_NODE_TYPE = 40;
29
30
const domChangeList = [];
31
32
if (!window.debugLogs) window.debugLogs = [];
33
34
function isMainFrame() {
35
if ('isMainFrame' in window) return (window as any).isMainFrame;
36
if ('getIsMainFrame' in window) return window.getIsMainFrame();
37
return true;
38
}
39
40
function debugLog(message: string, ...args: any[]) {
41
if (window.debugToConsole) {
42
// eslint-disable-next-line prefer-rest-params,no-console
43
console.log(...arguments);
44
}
45
window.debugLogs.push({ message, args });
46
}
47
48
window.replayDomChanges = function replayDomChanges(changeEvents: IFrontendDomChangeEvent[]) {
49
if (changeEvents) applyDomChanges(changeEvents);
50
};
51
52
window.addEventListener('message', ev => {
53
if (ev.data.action !== 'replayDomChanges') return;
54
if (ev.data.recipientFrameIdPath && !window.selfFrameIdPath) {
55
window.selfFrameIdPath = ev.data.recipientFrameIdPath;
56
}
57
domChangeList.push(ev.data.event);
58
if (document.readyState !== 'loading') applyDomChanges([]);
59
});
60
61
function applyDomChanges(changeEvents: IFrontendDomChangeEvent[]) {
62
const toProcess = domChangeList.concat(changeEvents);
63
domChangeList.length = 0;
64
65
for (const changeEvent of toProcess) {
66
try {
67
replayDomEvent(changeEvent);
68
} catch (err) {
69
debugLog('ERROR applying change', changeEvent, err);
70
}
71
}
72
}
73
74
/////// DOM REPLAYER ///////////////////////////////////////////////////////////////////////////////////////////////////
75
76
function replayDomEvent(event: IFrontendDomChangeEvent) {
77
if (!window.selfFrameIdPath && isMainFrame()) {
78
window.selfFrameIdPath = 'main';
79
}
80
81
const { action, textContent, frameIdPath } = event;
82
if (frameIdPath && frameIdPath !== window.selfFrameIdPath) {
83
delegateToSubframe(event);
84
return;
85
}
86
87
if (action === DomActionType.newDocument) {
88
onNewDocument(event);
89
return;
90
}
91
92
if (action === DomActionType.location) {
93
debugLog('Location: href=%s', event.textContent);
94
window.history.replaceState({}, 'Replay', textContent);
95
return;
96
}
97
98
if (isPreservedElement(event)) return;
99
const { parentNodeId } = event;
100
101
let node: Node;
102
let parentNode: Node;
103
try {
104
parentNode = getNode(parentNodeId);
105
node = deserializeNode(event, parentNode as Element);
106
107
if (!parentNode && (action === DomActionType.added || action === DomActionType.removed)) {
108
debugLog('WARN: parent node id not found', event);
109
return;
110
}
111
112
switch (action) {
113
case DomActionType.added:
114
if (!event.previousSiblingId) {
115
(parentNode as Element).prepend(node);
116
} else if (getNode(event.previousSiblingId)) {
117
const next = getNode(event.previousSiblingId).nextSibling;
118
119
if (next) parentNode.insertBefore(node, next);
120
else parentNode.appendChild(node);
121
}
122
123
break;
124
case DomActionType.removed:
125
if (parentNode.contains(node)) parentNode.removeChild(node);
126
break;
127
case DomActionType.attribute:
128
setNodeAttributes(node as Element, event);
129
break;
130
case DomActionType.property:
131
setNodeProperties(node as Element, event);
132
break;
133
case DomActionType.text:
134
node.textContent = textContent;
135
break;
136
}
137
} catch (error) {
138
// eslint-disable-next-line no-console
139
console.error('ERROR: applying action', error.stack, parentNode, node, event);
140
}
141
}
142
143
/////// PRESERVE HTML, BODY, HEAD ELEMS ////////////////////////////////////////////////////////////////////////////////
144
145
const preserveElements = new Set<string>(['HTML', 'HEAD', 'BODY']);
146
function isPreservedElement(event: IFrontendDomChangeEvent) {
147
const { action, nodeId, nodeType } = event;
148
149
if (nodeType === document.DOCUMENT_NODE) {
150
NodeTracker.restore(nodeId, document);
151
return true;
152
}
153
154
if (nodeType === document.DOCUMENT_TYPE_NODE) {
155
NodeTracker.restore(nodeId, document.doctype);
156
return true;
157
}
158
159
let tagName = event.tagName;
160
if (!tagName) {
161
const existing = getNode(nodeId);
162
if (existing) tagName = (existing as Element).tagName;
163
}
164
if (!preserveElements.has(tagName)) return false;
165
166
const elem = document.querySelector(tagName);
167
if (!elem) {
168
debugLog('Preserved element doesnt exist!', tagName);
169
return true;
170
}
171
172
NodeTracker.restore(nodeId, elem);
173
if (action === DomActionType.removed) {
174
elem.innerHTML = '';
175
for (const attr of elem.attributes) {
176
elem.removeAttributeNS(attr.name, attr.namespaceURI);
177
elem.removeAttribute(attr.name);
178
}
179
debugLog('WARN: script trying to remove preserved node', event, elem);
180
return true;
181
}
182
183
if (action === DomActionType.added) {
184
elem.innerHTML = '';
185
}
186
if (event.attributes) {
187
setNodeAttributes(elem, event);
188
}
189
if (event.properties) {
190
setNodeProperties(elem, event);
191
}
192
return true;
193
}
194
195
/////// DELEGATION BETWEEN FRAMES ////////////////////////////////////////////////////////////////////////////////////
196
197
const pendingFrameCreationEvents = new Map<
198
string,
199
{ recipientFrameIdPath: string; event: IFrontendDomChangeEvent; action: string }[]
200
>();
201
(window as any).pendingFrameCreationEvents = pendingFrameCreationEvents;
202
function delegateToSubframe(event: IFrontendDomChangeEvent) {
203
const childPath = event.frameIdPath
204
.replace(window.selfFrameIdPath, '')
205
.split('_')
206
.filter(Boolean)
207
.map(Number);
208
209
const childId = childPath.shift();
210
const recipientFrameIdPath = `${window.selfFrameIdPath}_${childId}`;
211
212
const node = getNode(childId);
213
if (!node) {
214
if (!pendingFrameCreationEvents.has(recipientFrameIdPath)) {
215
pendingFrameCreationEvents.set(recipientFrameIdPath, []);
216
}
217
// queue for pending events
218
pendingFrameCreationEvents
219
.get(recipientFrameIdPath)
220
.push({ recipientFrameIdPath, event, action: 'replayDomChanges' });
221
debugLog('Frame: not loaded yet, queuing pending', recipientFrameIdPath);
222
return;
223
}
224
225
if (
226
(event.action === DomActionType.location || event.action === DomActionType.newDocument) &&
227
node instanceof HTMLObjectElement
228
) {
229
return;
230
}
231
232
const frame = node as HTMLIFrameElement;
233
if (!frame.contentWindow) {
234
debugLog('Frame: without window', frame);
235
return;
236
}
237
const events = [{ recipientFrameIdPath, event, action: 'replayDomChanges' }];
238
239
if (pendingFrameCreationEvents.has(recipientFrameIdPath)) {
240
events.unshift(...pendingFrameCreationEvents.get(recipientFrameIdPath));
241
pendingFrameCreationEvents.delete(recipientFrameIdPath);
242
}
243
244
for (const message of events) {
245
frame.contentWindow.postMessage(message, '*');
246
}
247
}
248
249
function onNewDocument(event: IFrontendDomChangeEvent) {
250
const { textContent } = event;
251
const href = textContent;
252
const newUrl = new URL(href);
253
254
debugLog(
255
'Location: (new document) %s, frame: %s, idx: %s',
256
href,
257
event.frameIdPath,
258
event.eventIndex,
259
);
260
261
if (!isMainFrame()) {
262
if (window.location.href !== href) {
263
window.location.href = href;
264
}
265
return;
266
}
267
268
window.scrollTo({ top: 0 });
269
270
if (document.documentElement) {
271
document.documentElement.innerHTML = '';
272
while (document.documentElement.previousSibling) {
273
const prev = document.documentElement.previousSibling;
274
if (prev === document.doctype) break;
275
prev.remove();
276
}
277
}
278
279
if (window.location.origin === newUrl.origin) {
280
window.history.replaceState({}, 'Replay', href);
281
}
282
}
283
284
function getNode(id: number) {
285
if (id === null || id === undefined) return null;
286
return NodeTracker.getWatchedNodeWithId(id, false);
287
}
288
window.getNodeById = getNode;
289
290
function setNodeAttributes(node: Element, data: IFrontendDomChangeEvent) {
291
const attributes = data.attributes;
292
if (!attributes) return;
293
294
const namespaces = data.attributeNamespaces;
295
296
for (const [name, value] of Object.entries(attributes)) {
297
const ns = namespaces ? namespaces[name] : null;
298
try {
299
if (name === 'xmlns' || name.startsWith('xmlns') || node.tagName === 'HTML' || !ns) {
300
if (value === null) node.removeAttribute(name);
301
else node.setAttribute(name, value as any);
302
} else if (value === null) {
303
node.removeAttributeNS(ns || null, name);
304
} else {
305
node.setAttributeNS(ns || null, name, value as any);
306
}
307
} catch (err) {
308
if (
309
!err.toString().includes('not a valid attribute name') &&
310
!err.toString().includes('qualified name')
311
)
312
throw err;
313
}
314
}
315
}
316
317
function setNodeProperties(node: Element, data: IFrontendDomChangeEvent) {
318
const properties = data.properties;
319
if (!properties) return;
320
for (const [name, value] of Object.entries(properties)) {
321
if (name === 'sheet.cssRules') {
322
const sheet = (node as HTMLStyleElement).sheet as CSSStyleSheet;
323
const newRules = value as string[];
324
let i = 0;
325
for (i = 0; i < sheet.cssRules.length; i += 1) {
326
const newRule = newRules[i];
327
if (newRule !== sheet.cssRules[i].cssText) {
328
sheet.deleteRule(i);
329
if (newRule) sheet.insertRule(newRule, i);
330
}
331
}
332
for (; i < newRules.length; i += 1) {
333
sheet.insertRule(newRules[i], i);
334
}
335
} else {
336
node[name] = value;
337
}
338
}
339
}
340
341
function deserializeNode(data: IFrontendDomChangeEvent, parent: Element): Node {
342
if (data === null) return null;
343
344
let node = getNode(data.nodeId);
345
if (node) {
346
setNodeProperties(node as Element, data);
347
setNodeAttributes(node as Element, data);
348
if (data.textContent) node.textContent = data.textContent;
349
return node;
350
}
351
352
if (parent && typeof parent.attachShadow === 'function' && data.nodeType === SHADOW_NODE_TYPE) {
353
// NOTE: we just make all shadows open in replay
354
node = parent.attachShadow({ mode: 'open' });
355
NodeTracker.restore(data.nodeId, node);
356
return node;
357
}
358
359
switch (data.nodeType) {
360
case Node.COMMENT_NODE:
361
node = document.createComment(data.textContent);
362
break;
363
364
case Node.TEXT_NODE:
365
node = document.createTextNode(data.textContent);
366
break;
367
368
case Node.ELEMENT_NODE:
369
if (!node) {
370
if (data.namespaceUri) {
371
node = document.createElementNS(data.namespaceUri, data.tagName);
372
} else {
373
node = document.createElement(data.tagName);
374
}
375
}
376
if (node instanceof HTMLIFrameElement) {
377
debugLog('Added Child Frame: frameIdPath=%s', `${window.selfFrameIdPath}_${data.nodeId}`);
378
}
379
if (data.tagName === 'NOSCRIPT') {
380
const sheet = new CSSStyleSheet();
381
// @ts-ignore
382
sheet.replaceSync(
383
`noscript { display:none !important; }
384
noscript * { display:none !important; }`,
385
);
386
387
// @ts-ignore
388
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
389
}
390
(node as any).nodeId = data.nodeId;
391
setNodeAttributes(node as Element, data);
392
setNodeProperties(node as Element, data);
393
if (data.textContent) {
394
node.textContent = data.textContent;
395
}
396
397
break;
398
}
399
400
if (!node) throw new Error(`Unable to translate node! nodeType = ${data.nodeType}`);
401
402
NodeTracker.restore(data.nodeId, node);
403
404
return node;
405
}
406
407