Path: blob/main/core/injected-scripts/pageEventsRecorder.ts
1028 views
// NOTE: do not use node dependencies1// eslint-disable-next-line max-classes-per-file2import { IDomChangeEvent, INodeData } from '@secret-agent/interfaces/IDomChangeEvent';3import { IMouseEvent } from '@secret-agent/interfaces/IMouseEvent';4import { IFocusEvent } from '@secret-agent/interfaces/IFocusEvent';5import { IScrollEvent } from '@secret-agent/interfaces/IScrollEvent';6import { ILoadEvent } from '@secret-agent/interfaces/ILoadEvent';78enum DomActionType {9newDocument = 0,10location = 1,11added = 2,12removed = 3,13text = 4,14attribute = 5,15property = 6,16}1718const MutationRecordType = {19attributes: 'attributes',20childList: 'childList',21characterData: 'characterData',22};2324// exporting a type is ok. Don't export variables or will blow up the page25export type PageRecorderResultSet = [26IDomChangeEvent[],27IMouseEvent[],28IFocusEvent[],29IScrollEvent[],30ILoadEvent[],31];32const SHADOW_NODE_TYPE = 40;3334// @ts-ignore35const eventsCallback = (window[runtimeFunction] as unknown) as (data: string) => void;36// @ts-ignore37delete window[runtimeFunction];3839let lastUploadDate: Date;4041function upload(records: PageRecorderResultSet) {42try {43const total = records.reduce((tot, ent) => tot + ent.length, 0);44if (total > 0) {45eventsCallback(JSON.stringify(records));46}47lastUploadDate = new Date();48return true;49} catch (err) {50// eslint-disable-next-line no-console51console.log(`ERROR calling page recorder callback: ${String(err)}`, err);52}53return false;54}5556let eventCounter = 0;5758function idx() {59return (eventCounter += 1);60}6162let isStarted = false;6364class PageEventsRecorder {65private domChanges: IDomChangeEvent[] = [];6667private mouseEvents: IMouseEvent[] = [];68private focusEvents: IFocusEvent[] = [];69private scrollEvents: IScrollEvent[] = [];70private loadEvents: ILoadEvent[] = [];71private location = window.self.location.href;7273private isListeningForInteractionEvents = false;7475private propertyTrackingElements = new Map<Node, Map<string, string | boolean>>();76private stylesheets = new Map<HTMLStyleElement | HTMLLinkElement, string[]>();7778private readonly observer: MutationObserver;7980constructor() {81this.observer = new MutationObserver(this.onMutation.bind(this));82}8384public start() {85if (isStarted || window.self.location.href === 'about:blank') {86return;87}88isStarted = true;8990const stamp = new Date().getTime();91// preload with a document92this.domChanges.push([93DomActionType.newDocument,94{95id: -1,96textContent: window.self.location.href,97},98stamp,99idx(),100]);101if (document) {102this.domChanges.push([DomActionType.added, this.serializeNode(document), stamp, idx()]);103}104105if (document && document.doctype) {106this.domChanges.push([107DomActionType.added,108this.serializeNode(document.doctype),109stamp,110idx(),111]);112}113const children = this.serializeChildren(document, new Map<Node, INodeData>());114this.observer.observe(document, {115attributes: true,116childList: true,117subtree: true,118characterData: true,119});120for (const childData of children) {121this.domChanges.push([DomActionType.added, childData, stamp, idx()]);122}123this.uploadChanges();124}125126public extractChanges(): PageRecorderResultSet {127const changes = this.convertMutationsToChanges(this.observer.takeRecords());128this.domChanges.push(...changes);129return this.pageResultset;130}131132public flushAndReturnLists(): PageRecorderResultSet {133const changes = recorder.extractChanges();134135recorder.resetLists();136return changes;137}138139public trackFocus(eventType: FocusType, focusEvent: FocusEvent) {140const nodeId = NodeTracker.getNodeId(focusEvent.target as Node);141const relatedNodeId = NodeTracker.getNodeId(focusEvent.relatedTarget as Node);142const time = new Date().getTime();143const event = [eventType as any, nodeId, relatedNodeId, time] as IFocusEvent;144this.focusEvents.push(event);145this.getPropertyChanges(time, this.domChanges);146}147148public trackMouse(eventType: MouseEventType, mouseEvent: MouseEvent) {149const nodeId = NodeTracker.getNodeId(mouseEvent.target as Node);150const relatedNodeId = NodeTracker.getNodeId(mouseEvent.relatedTarget as Node);151const event = [152eventType,153mouseEvent.pageX,154mouseEvent.pageY,155// might not want to do this - causes reflow156mouseEvent.offsetX,157mouseEvent.offsetY,158mouseEvent.buttons,159nodeId,160relatedNodeId,161new Date().getTime(),162] as IMouseEvent;163this.mouseEvents.push(event);164}165166public trackScroll(scrollX: number, scrollY: number) {167this.scrollEvents.push([scrollX, scrollY, new Date().getTime()]);168}169170public onLoadEvent(name: string) {171this.start();172this.loadEvents.push([name, window.self.location.href, new Date().getTime()]);173this.uploadChanges();174}175176public checkForAllPropertyChanges() {177this.getPropertyChanges(new Date().getTime(), this.domChanges);178}179180public get pageResultset(): PageRecorderResultSet {181return [182[...this.domChanges],183[...this.mouseEvents],184[...this.focusEvents],185[...this.scrollEvents],186[...this.loadEvents],187];188}189190public resetLists() {191this.domChanges.length = 0;192this.mouseEvents.length = 0;193this.focusEvents.length = 0;194this.scrollEvents.length = 0;195this.loadEvents.length = 0;196}197198public disconnect() {199this.extractChanges();200this.observer.disconnect();201this.uploadChanges();202}203204public uploadChanges() {205if (upload(this.pageResultset)) {206this.resetLists();207}208}209210public listenToInteractionEvents() {211if (this.isListeningForInteractionEvents) return;212this.isListeningForInteractionEvents = true;213for (const event of ['input', 'keydown', 'change']) {214document.addEventListener(event, this.checkForAllPropertyChanges.bind(this), {215capture: true,216passive: true,217});218}219220document.addEventListener('mousemove', e => this.trackMouse(MouseEventType.MOVE, e), {221capture: true,222passive: true,223});224225document.addEventListener('mousedown', e => this.trackMouse(MouseEventType.DOWN, e), {226capture: true,227passive: true,228});229230document.addEventListener('mouseup', e => this.trackMouse(MouseEventType.UP, e), {231capture: true,232passive: true,233});234235document.addEventListener('mouseover', e => this.trackMouse(MouseEventType.OVER, e), {236capture: true,237passive: true,238});239240document.addEventListener('mouseleave', e => this.trackMouse(MouseEventType.OUT, e), {241capture: true,242passive: true,243});244245document.addEventListener('focusin', e => this.trackFocus(FocusType.IN, e), {246capture: true,247passive: true,248});249250document.addEventListener('focusout', e => this.trackFocus(FocusType.OUT, e), {251capture: true,252passive: true,253});254255document.addEventListener('scroll', () => this.trackScroll(window.scrollX, window.scrollY), {256capture: true,257passive: true,258});259}260261private getLocationChange(changeUnixTime: number, changes: IDomChangeEvent[]) {262const timestamp = changeUnixTime || new Date().getTime();263const currentLocation = window.self.location.href;264if (this.location !== currentLocation) {265this.location = currentLocation;266changes.push([267DomActionType.location,268{ id: -1, textContent: currentLocation },269timestamp,270idx(),271]);272}273}274275private getPropertyChanges(changeUnixTime: number, changes: IDomChangeEvent[]) {276for (const [input, propertyMap] of this.propertyTrackingElements) {277for (const [propertyName, value] of propertyMap) {278const newPropValue = input[propertyName];279if (newPropValue !== value) {280const nodeId = NodeTracker.getNodeId(input);281changes.push([282DomActionType.property,283{ id: nodeId, properties: { [propertyName]: newPropValue } },284changeUnixTime,285idx(),286]);287propertyMap.set(propertyName, newPropValue);288}289}290}291}292293private trackStylesheet(element: HTMLStyleElement) {294if (!element || this.stylesheets.has(element)) return;295if (!element.sheet) return;296297const shouldStoreCurrentStyleState = !!element.textContent;298if (element.sheet instanceof CSSStyleSheet) {299try {300// if there's style text, record the current state301const startingStyle = shouldStoreCurrentStyleState302? [...element.sheet.cssRules].map(x => x.cssText)303: [];304this.stylesheets.set(element, startingStyle);305} catch (err) {306// can't track cors stylesheet rules307}308}309}310311private checkForStylesheetChanges(changeUnixTime: number, changes: IDomChangeEvent[]) {312const timestamp = changeUnixTime || new Date().getTime();313for (const [style, current] of this.stylesheets) {314if (!style.sheet || !style.isConnected) continue;315const sheet = style.sheet as CSSStyleSheet;316const newPropValue = [...sheet.cssRules].map(x => x.cssText);317if (newPropValue.toString() !== current.toString()) {318const nodeId = NodeTracker.getNodeId(style);319changes.push([320DomActionType.property,321{ id: nodeId, properties: { 'sheet.cssRules': newPropValue } },322timestamp,323idx(),324]);325this.stylesheets.set(style, newPropValue);326}327}328}329330private onMutation(mutations: MutationRecord[]) {331const changes = this.convertMutationsToChanges(mutations);332this.domChanges.push(...changes);333}334335private convertMutationsToChanges(mutations: MutationRecord[]) {336const changes: IDomChangeEvent[] = [];337const stamp = new Date().getTime();338339this.getLocationChange(stamp, changes);340this.getPropertyChanges(stamp, changes);341342const addedNodeMap = new Map<Node, INodeData>();343const removedNodes = new Set<Node>();344345for (const mutation of mutations) {346const { type, target } = mutation;347if (!NodeTracker.has(target)) {348this.serializeHierarchy(target, changes, stamp, addedNodeMap);349}350351if (type === MutationRecordType.childList) {352let isFirstRemoved = true;353for (let i = 0, length = mutation.removedNodes.length; i < length; i += 1) {354const node = mutation.removedNodes[i];355removedNodes.add(node);356if (!NodeTracker.has(node)) continue;357const serial = this.serializeNode(node);358serial.parentNodeId = NodeTracker.getNodeId(target);359serial.previousSiblingId = NodeTracker.getNodeId(360isFirstRemoved ? mutation.previousSibling : node.previousSibling,361);362changes.push([DomActionType.removed, serial, stamp, idx()]);363isFirstRemoved = false;364}365366// A batch of changes includes changes in a set of nodes.367// Since we're flattening, only the first one should be added after the mutation sibling.368let isFirstAdded = true;369for (let i = 0, length = mutation.addedNodes.length; i < length; i += 1) {370const node = mutation.addedNodes[i];371const serial = this.serializeNode(node);372serial.parentNodeId = NodeTracker.getNodeId(target);373serial.previousSiblingId = NodeTracker.getNodeId(374isFirstAdded ? mutation.previousSibling : node.previousSibling,375);376isFirstAdded = false;377// if we get a re-order of nodes, sometimes we'll remove nodes, and add them again378if (addedNodeMap.has(node) && !removedNodes.has(node)) {379const existing = addedNodeMap.get(node);380if (381existing.previousSiblingId === serial.previousSiblingId &&382existing.parentNodeId === serial.parentNodeId383) {384continue;385}386}387addedNodeMap.set(node, serial);388changes.push([DomActionType.added, serial, stamp, idx()]);389}390}391392if (type === MutationRecordType.attributes) {393// don't store394if (!NodeTracker.has(target)) {395this.serializeHierarchy(target, changes, stamp, addedNodeMap);396}397const serial = addedNodeMap.get(target) || this.serializeNode(target);398if (!serial.attributes) serial.attributes = {};399serial.attributes[mutation.attributeName] = (target as Element).getAttributeNS(400mutation.attributeNamespace,401mutation.attributeName,402);403if (mutation.attributeNamespace && mutation.attributeNamespace !== '') {404if (!serial.attributeNamespaces) serial.attributeNamespaces = {};405serial.attributeNamespaces[mutation.attributeName] = mutation.attributeNamespace;406}407408// flatten changes409if (!addedNodeMap.has(target)) {410changes.push([DomActionType.attribute, serial, stamp, idx()]);411}412}413414if (type === MutationRecordType.characterData) {415const textChange = this.serializeNode(target);416textChange.textContent = target.textContent;417changes.push([DomActionType.text, textChange, stamp, idx()]);418}419}420421for (const [node] of addedNodeMap) {422// A batch of changes (setting innerHTML) will send nodes in a hierarchy instead of423// individually so we need to extract child nodes into flat hierarchy424const children = this.serializeChildren(node, addedNodeMap);425for (const childData of children) {426changes.push([DomActionType.added, childData, stamp, idx()]);427}428}429430this.checkForStylesheetChanges(stamp, changes);431432return changes;433}434435private serializeHierarchy(436node: Node,437changes: IDomChangeEvent[],438changeTime: number,439addedNodeMap: Map<Node, INodeData>,440) {441if (NodeTracker.has(node)) return this.serializeNode(node);442443const serial = this.serializeNode(node);444serial.parentNodeId = NodeTracker.getNodeId(node.parentNode);445if (!serial.parentNodeId && node.parentNode) {446const parentSerial = this.serializeHierarchy(447node.parentNode,448changes,449changeTime,450addedNodeMap,451);452453serial.parentNodeId = parentSerial.id;454}455serial.previousSiblingId = NodeTracker.getNodeId(node.previousSibling);456if (!serial.previousSiblingId && node.previousSibling) {457const previous = this.serializeHierarchy(458node.previousSibling,459changes,460changeTime,461addedNodeMap,462);463serial.previousSiblingId = previous.id;464}465changes.push([DomActionType.added, serial, changeTime, idx()]);466addedNodeMap.set(node, serial);467return serial;468}469470private serializeChildren(node: Node, addedNodes: Map<Node, INodeData>) {471const serialized: INodeData[] = [];472473for (const child of node.childNodes) {474if (!NodeTracker.has(child)) {475const serial = this.serializeNode(child);476serial.parentNodeId = NodeTracker.getNodeId(child.parentElement ?? child.getRootNode());477serial.previousSiblingId = NodeTracker.getNodeId(child.previousSibling);478addedNodes.set(child, serial);479serialized.push(serial, ...this.serializeChildren(child, addedNodes));480}481}482483for (const element of [node, ...node.childNodes] as Element[]) {484if (element.tagName === 'STYLE') {485this.trackStylesheet(element as HTMLStyleElement);486}487const shadowRoot = element.shadowRoot;488if (shadowRoot && !NodeTracker.has(shadowRoot)) {489const serial = this.serializeNode(shadowRoot);490serial.parentNodeId = NodeTracker.getNodeId(element);491serialized.push(serial, ...this.serializeChildren(shadowRoot, addedNodes));492this.observer.observe(shadowRoot, {493attributes: true,494childList: true,495subtree: true,496characterData: true,497});498}499}500501return serialized;502}503504private serializeNode(node: Node): INodeData {505if (node === null) {506return undefined;507}508509const id = NodeTracker.getNodeId(node);510if (id !== undefined) {511return { id };512}513514const data: INodeData = {515nodeType: node.nodeType,516id: NodeTracker.track(node),517};518519if (node instanceof ShadowRoot) {520data.nodeType = SHADOW_NODE_TYPE;521return data;522}523524switch (data.nodeType) {525case Node.COMMENT_NODE:526case Node.TEXT_NODE:527data.textContent = node.textContent;528break;529530case Node.DOCUMENT_TYPE_NODE:531data.textContent = new XMLSerializer().serializeToString(node);532break;533534case Node.ELEMENT_NODE:535const element = node as Element;536data.tagName = element.tagName;537if (element.namespaceURI && element.namespaceURI !== defaultNamespaceUri) {538data.namespaceUri = element.namespaceURI;539}540541if (element.attributes.length) {542data.attributes = {};543for (let i = 0, length = element.attributes.length; i < length; i += 1) {544const attr = element.attributes[i];545data.attributes[attr.name] = attr.value;546if (attr.namespaceURI && attr.namespaceURI !== defaultNamespaceUri) {547if (!data.attributeNamespaces) data.attributeNamespaces = {};548data.attributeNamespaces[attr.name] = attr.namespaceURI;549}550}551}552553let propertyChecks: [string, string | boolean][];554for (const prop of propertiesToCheck) {555if (prop in element) {556if (!propertyChecks) propertyChecks = [];557propertyChecks.push([prop, element[prop]]);558}559}560if (propertyChecks) {561const propsMap = new Map<string, string | boolean>(propertyChecks);562this.propertyTrackingElements.set(node, propsMap);563}564break;565}566567return data;568}569}570571const defaultNamespaceUri = 'http://www.w3.org/1999/xhtml';572const propertiesToCheck = ['value', 'selected', 'checked'];573574const recorder = new PageEventsRecorder();575576// @ts-ignore577window.extractDomChanges = () => recorder.extractChanges();578// @ts-ignore579window.flushPageRecorder = () => recorder.flushAndReturnLists();580// @ts-ignore581window.listenForInteractionEvents = () => recorder.listenToInteractionEvents();582583const interval = setInterval(() => {584if (!lastUploadDate || new Date().getTime() - lastUploadDate.getTime() > 1e3) {585// if we haven't uploaded in 1 second, make sure nothing is pending586requestAnimationFrame(() => recorder.uploadChanges());587}588}, 500);589590window.addEventListener('DOMContentLoaded', () => {591// force domContentLoaded to come first592recorder.onLoadEvent('DOMContentLoaded');593});594595window.addEventListener('load', () => recorder.onLoadEvent('load'));596597if (window.self.location?.href !== 'about:blank') {598window.addEventListener('beforeunload', () => {599clearInterval(interval);600recorder.disconnect();601});602603const paintObserver = new PerformanceObserver(entryList => {604if (entryList.getEntriesByName('first-contentful-paint').length) {605recorder.start();606paintObserver.disconnect();607}608});609paintObserver.observe({ type: 'paint', buffered: true });610611const contentStableObserver = new PerformanceObserver(() => {612recorder.onLoadEvent('LargestContentfulPaint');613contentStableObserver.disconnect();614});615contentStableObserver.observe({ type: 'largest-contentful-paint', buffered: true });616}617618// need duplicate since this is a variable - not just a type619enum MouseEventType {620MOVE = 0,621DOWN = 1,622UP = 2,623OVER = 3,624OUT = 4,625}626627enum FocusType {628IN = 0,629OUT = 1,630}631632633