Path: blob/main/core/injected-scripts/domReplayer.ts
1028 views
// NOTE: do not use node dependencies12import type { IFrontendDomChangeEvent } from '@secret-agent/core/models/DomChangesTable';34declare global {5interface Window {6replayDomChanges(...args: any[]);7replayInteractions(...args: any[]);8getIsMainFrame?: () => boolean;9debugLogs: any[];10debugToConsole: boolean;11selfFrameIdPath: string;12getNodeById(id: number): Node;13}14}1516// copied since we can't import data types17enum DomActionType {18newDocument = 0,19location = 1,20added = 2,21removed = 3,22text = 4,23attribute = 5,24property = 6,25}2627const SHADOW_NODE_TYPE = 40;2829const domChangeList = [];3031if (!window.debugLogs) window.debugLogs = [];3233function isMainFrame() {34if ('isMainFrame' in window) return (window as any).isMainFrame;35if ('getIsMainFrame' in window) return window.getIsMainFrame();36return true;37}3839function debugLog(message: string, ...args: any[]) {40if (window.debugToConsole) {41// eslint-disable-next-line prefer-rest-params,no-console42console.log(...arguments);43}44window.debugLogs.push({ message, args });45}4647window.replayDomChanges = function replayDomChanges(changeEvents: IFrontendDomChangeEvent[]) {48if (changeEvents) applyDomChanges(changeEvents);49};5051window.addEventListener('message', ev => {52if (ev.data.action !== 'replayDomChanges') return;53if (ev.data.recipientFrameIdPath && !window.selfFrameIdPath) {54window.selfFrameIdPath = ev.data.recipientFrameIdPath;55}56domChangeList.push(ev.data.event);57if (document.readyState !== 'loading') applyDomChanges([]);58});5960function applyDomChanges(changeEvents: IFrontendDomChangeEvent[]) {61const toProcess = domChangeList.concat(changeEvents);62domChangeList.length = 0;6364for (const changeEvent of toProcess) {65try {66replayDomEvent(changeEvent);67} catch (err) {68debugLog('ERROR applying change', changeEvent, err);69}70}71}7273/////// DOM REPLAYER ///////////////////////////////////////////////////////////////////////////////////////////////////7475function replayDomEvent(event: IFrontendDomChangeEvent) {76if (!window.selfFrameIdPath && isMainFrame()) {77window.selfFrameIdPath = 'main';78}7980const { action, textContent, frameIdPath } = event;81if (frameIdPath && frameIdPath !== window.selfFrameIdPath) {82delegateToSubframe(event);83return;84}8586if (action === DomActionType.newDocument) {87onNewDocument(event);88return;89}9091if (action === DomActionType.location) {92debugLog('Location: href=%s', event.textContent);93window.history.replaceState({}, 'Replay', textContent);94return;95}9697if (isPreservedElement(event)) return;98const { parentNodeId } = event;99100let node: Node;101let parentNode: Node;102try {103parentNode = getNode(parentNodeId);104node = deserializeNode(event, parentNode as Element);105106if (!parentNode && (action === DomActionType.added || action === DomActionType.removed)) {107debugLog('WARN: parent node id not found', event);108return;109}110111switch (action) {112case DomActionType.added:113if (!event.previousSiblingId) {114(parentNode as Element).prepend(node);115} else if (getNode(event.previousSiblingId)) {116const next = getNode(event.previousSiblingId).nextSibling;117118if (next) parentNode.insertBefore(node, next);119else parentNode.appendChild(node);120}121122break;123case DomActionType.removed:124if (parentNode.contains(node)) parentNode.removeChild(node);125break;126case DomActionType.attribute:127setNodeAttributes(node as Element, event);128break;129case DomActionType.property:130setNodeProperties(node as Element, event);131break;132case DomActionType.text:133node.textContent = textContent;134break;135}136} catch (error) {137// eslint-disable-next-line no-console138console.error('ERROR: applying action', error.stack, parentNode, node, event);139}140}141142/////// PRESERVE HTML, BODY, HEAD ELEMS ////////////////////////////////////////////////////////////////////////////////143144const preserveElements = new Set<string>(['HTML', 'HEAD', 'BODY']);145function isPreservedElement(event: IFrontendDomChangeEvent) {146const { action, nodeId, nodeType } = event;147148if (nodeType === document.DOCUMENT_NODE) {149NodeTracker.restore(nodeId, document);150return true;151}152153if (nodeType === document.DOCUMENT_TYPE_NODE) {154NodeTracker.restore(nodeId, document.doctype);155return true;156}157158let tagName = event.tagName;159if (!tagName) {160const existing = getNode(nodeId);161if (existing) tagName = (existing as Element).tagName;162}163if (!preserveElements.has(tagName)) return false;164165const elem = document.querySelector(tagName);166if (!elem) {167debugLog('Preserved element doesnt exist!', tagName);168return true;169}170171NodeTracker.restore(nodeId, elem);172if (action === DomActionType.removed) {173elem.innerHTML = '';174for (const attr of elem.attributes) {175elem.removeAttributeNS(attr.name, attr.namespaceURI);176elem.removeAttribute(attr.name);177}178debugLog('WARN: script trying to remove preserved node', event, elem);179return true;180}181182if (action === DomActionType.added) {183elem.innerHTML = '';184}185if (event.attributes) {186setNodeAttributes(elem, event);187}188if (event.properties) {189setNodeProperties(elem, event);190}191return true;192}193194/////// DELEGATION BETWEEN FRAMES ////////////////////////////////////////////////////////////////////////////////////195196const pendingFrameCreationEvents = new Map<197string,198{ recipientFrameIdPath: string; event: IFrontendDomChangeEvent; action: string }[]199>();200(window as any).pendingFrameCreationEvents = pendingFrameCreationEvents;201function delegateToSubframe(event: IFrontendDomChangeEvent) {202const childPath = event.frameIdPath203.replace(window.selfFrameIdPath, '')204.split('_')205.filter(Boolean)206.map(Number);207208const childId = childPath.shift();209const recipientFrameIdPath = `${window.selfFrameIdPath}_${childId}`;210211const node = getNode(childId);212if (!node) {213if (!pendingFrameCreationEvents.has(recipientFrameIdPath)) {214pendingFrameCreationEvents.set(recipientFrameIdPath, []);215}216// queue for pending events217pendingFrameCreationEvents218.get(recipientFrameIdPath)219.push({ recipientFrameIdPath, event, action: 'replayDomChanges' });220debugLog('Frame: not loaded yet, queuing pending', recipientFrameIdPath);221return;222}223224if (225(event.action === DomActionType.location || event.action === DomActionType.newDocument) &&226node instanceof HTMLObjectElement227) {228return;229}230231const frame = node as HTMLIFrameElement;232if (!frame.contentWindow) {233debugLog('Frame: without window', frame);234return;235}236const events = [{ recipientFrameIdPath, event, action: 'replayDomChanges' }];237238if (pendingFrameCreationEvents.has(recipientFrameIdPath)) {239events.unshift(...pendingFrameCreationEvents.get(recipientFrameIdPath));240pendingFrameCreationEvents.delete(recipientFrameIdPath);241}242243for (const message of events) {244frame.contentWindow.postMessage(message, '*');245}246}247248function onNewDocument(event: IFrontendDomChangeEvent) {249const { textContent } = event;250const href = textContent;251const newUrl = new URL(href);252253debugLog(254'Location: (new document) %s, frame: %s, idx: %s',255href,256event.frameIdPath,257event.eventIndex,258);259260if (!isMainFrame()) {261if (window.location.href !== href) {262window.location.href = href;263}264return;265}266267window.scrollTo({ top: 0 });268269if (document.documentElement) {270document.documentElement.innerHTML = '';271while (document.documentElement.previousSibling) {272const prev = document.documentElement.previousSibling;273if (prev === document.doctype) break;274prev.remove();275}276}277278if (window.location.origin === newUrl.origin) {279window.history.replaceState({}, 'Replay', href);280}281}282283function getNode(id: number) {284if (id === null || id === undefined) return null;285return NodeTracker.getWatchedNodeWithId(id, false);286}287window.getNodeById = getNode;288289function setNodeAttributes(node: Element, data: IFrontendDomChangeEvent) {290const attributes = data.attributes;291if (!attributes) return;292293const namespaces = data.attributeNamespaces;294295for (const [name, value] of Object.entries(attributes)) {296const ns = namespaces ? namespaces[name] : null;297try {298if (name === 'xmlns' || name.startsWith('xmlns') || node.tagName === 'HTML' || !ns) {299if (value === null) node.removeAttribute(name);300else node.setAttribute(name, value as any);301} else if (value === null) {302node.removeAttributeNS(ns || null, name);303} else {304node.setAttributeNS(ns || null, name, value as any);305}306} catch (err) {307if (308!err.toString().includes('not a valid attribute name') &&309!err.toString().includes('qualified name')310)311throw err;312}313}314}315316function setNodeProperties(node: Element, data: IFrontendDomChangeEvent) {317const properties = data.properties;318if (!properties) return;319for (const [name, value] of Object.entries(properties)) {320if (name === 'sheet.cssRules') {321const sheet = (node as HTMLStyleElement).sheet as CSSStyleSheet;322const newRules = value as string[];323let i = 0;324for (i = 0; i < sheet.cssRules.length; i += 1) {325const newRule = newRules[i];326if (newRule !== sheet.cssRules[i].cssText) {327sheet.deleteRule(i);328if (newRule) sheet.insertRule(newRule, i);329}330}331for (; i < newRules.length; i += 1) {332sheet.insertRule(newRules[i], i);333}334} else {335node[name] = value;336}337}338}339340function deserializeNode(data: IFrontendDomChangeEvent, parent: Element): Node {341if (data === null) return null;342343let node = getNode(data.nodeId);344if (node) {345setNodeProperties(node as Element, data);346setNodeAttributes(node as Element, data);347if (data.textContent) node.textContent = data.textContent;348return node;349}350351if (parent && typeof parent.attachShadow === 'function' && data.nodeType === SHADOW_NODE_TYPE) {352// NOTE: we just make all shadows open in replay353node = parent.attachShadow({ mode: 'open' });354NodeTracker.restore(data.nodeId, node);355return node;356}357358switch (data.nodeType) {359case Node.COMMENT_NODE:360node = document.createComment(data.textContent);361break;362363case Node.TEXT_NODE:364node = document.createTextNode(data.textContent);365break;366367case Node.ELEMENT_NODE:368if (!node) {369if (data.namespaceUri) {370node = document.createElementNS(data.namespaceUri, data.tagName);371} else {372node = document.createElement(data.tagName);373}374}375if (node instanceof HTMLIFrameElement) {376debugLog('Added Child Frame: frameIdPath=%s', `${window.selfFrameIdPath}_${data.nodeId}`);377}378if (data.tagName === 'NOSCRIPT') {379const sheet = new CSSStyleSheet();380// @ts-ignore381sheet.replaceSync(382`noscript { display:none !important; }383noscript * { display:none !important; }`,384);385386// @ts-ignore387document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];388}389(node as any).nodeId = data.nodeId;390setNodeAttributes(node as Element, data);391setNodeProperties(node as Element, data);392if (data.textContent) {393node.textContent = data.textContent;394}395396break;397}398399if (!node) throw new Error(`Unable to translate node! nodeType = ${data.nodeType}`);400401NodeTracker.restore(data.nodeId, node);402403return node;404}405406407