type Methods = { [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never }[keyof T]; interface BaseAuditEntry { id: number; node: Node; } interface SetAuditEntry extends BaseAuditEntry { type: 'set'; property: string; value: any; previous: any; } interface InsertionAuditEntry extends BaseAuditEntry { type: 'insert' | 'move'; child: Node; before?: Node; } interface RemovalAuditEntry extends BaseAuditEntry { type: 'remove'; } interface CustomAuditEntry extends BaseAuditEntry { type: 'custom'; text: string; value: any; previous?: any; } type AuditEntry = SetAuditEntry | InsertionAuditEntry | RemovalAuditEntry | CustomAuditEntry; type FormattedAuditEntry = AuditEntry & { message: string; }; type AuditHandler = (entry: FormattedAuditEntry) => void; export class DOMAuditLogger { #nextId = 0; #logs: FormattedAuditEntry[] = []; #handlers: Map = new Map(); #cleanup: (() => void)[] = []; private constructor() { this.observeSetter(Element.prototype, 'innerHTML'); this.observeSetter(Element.prototype, 'className'); this.observeSetter(Element.prototype, 'id'); this.observeSetter(CharacterData.prototype, 'data'); this.observeSetter(Node.prototype, 'nodeValue'); this.observeSetter(Node.prototype, 'textContent'); const logInsert = this.logInsert.bind(this); this.observeMethod(Element.prototype, 'append', (parent, ...children) => { for (const child of children) { parent.appendChild(child instanceof Node ? child : new Text(child)); } return false; }); this.observeMethod(Element.prototype, 'appendChild', logInsert); this.observeMethod(Element.prototype, 'insertBefore', logInsert); this.observeMethod(Element.prototype, 'removeChild', (_, child) => this.logRemove(child)); this.observeMethod(Element.prototype, 'remove', this.logRemove.bind(this)); this.observeMethod(CharacterData.prototype, 'remove', this.logRemove.bind(this)); } destroy() { this.clear(); // this.#handlers.clear(); for (const fn of this.#cleanup) fn(); } private observeMethod & string>(target: T, method: M, callback: (node: T, ...args: T[M] extends (...args: any[]) => any ? Parameters : unknown[]) => void | false) { const original = target[method]; if (typeof original !== 'function') throw Error(`Method "${method}" is not a function`); target[method] = function(this: T) { if (callback(this, ...arguments as any) === false) return; return original.apply(this, arguments); } as T[M]; } private observeSetter(target: T, prop: string) { const descriptor = Object.getOwnPropertyDescriptor(target, prop); if (!descriptor || !descriptor.set) throw Error(`Property "${prop}" is not a setter`); const logger = this; const originalSetter = descriptor.set; descriptor.set = function(this: T, value: any) { logger.logSet(this, prop, value, this[prop]); return originalSetter.call(this, value); }; Object.defineProperty(target, prop, descriptor); this.#cleanup.push(() => { descriptor.set = originalSetter; Object.defineProperty(target, prop, descriptor); }); } on(type: string, handler: AuditHandler) { let handlers = this.#handlers.get(type); if (!handlers) { handlers = []; this.#handlers.set(type, handlers); } handlers.push(handler); } off(type: string, handler: AuditHandler) { const handlers = this.#handlers.get(type); if (!handlers) return; const index = handlers.indexOf(handler); if (index !== -1) handlers.splice(index, 1); } logSet(node: Node, property: string, value: any, previous: any) { this.log({ id: this.#nextId++, type: 'set', node, property, value, previous }); } logInsert(node: Node, child: Node, before?: Node | null) { this.log({ id: this.#nextId++, type: child.parentNode === node ? 'move' : 'insert', node, child, before: before ?? undefined }); } logRemove(node: Node) { this.log({ id: this.#nextId++, type: 'remove', node, }); } logCustom(node: Node, text: string, value: any, previous?: any) { this.log({ id: this.#nextId++, type: 'custom', node, text, value, previous }); } private log(_entry: AuditEntry) { const entry = _entry as FormattedAuditEntry; entry.message = this.formatLog(entry); this.#logs.push(entry); const handlers = this.#handlers.get(entry.type); if (handlers) for (const fn of handlers) fn(entry); const logHandlers = this.#handlers.get('log'); if (logHandlers) for (const fn of logHandlers) fn(entry); } /** Get a copy of the current buffered logs */ get logs(): FormattedAuditEntry[] { return this.#logs.slice(); } /** Returns current logs and clears the internal log buffer */ flush() { const logs = this.logs; this.clear(); return logs; } /** Returns current logs as formatted text and clears the internal log buffer */ flushTrail(): string { return this.flush() .map(entry => entry.message) .join('\n'); } clear() { this.#logs.length = 0; } private formatLog(entry: AuditEntry): string { let out = this.formatNode(entry.node); switch (entry.type) { case 'set': out += `.${entry.property} = ${this._fmt(entry.value)} (↤ ${this._fmt(entry.previous)})`; break; case 'insert': out += `.insert(${this._fmt(entry.child)}`; if (entry.before) out += `, ${this._fmt(entry.before)}`; out += ')'; break; case 'move': { const cn = entry.node.childNodes; const prev = Array.prototype.indexOf.call(cn, entry.child); // note: fallback case here is end rather than new insertion at end, because this is a move const next = entry.before ? Array.prototype.indexOf.call(cn, entry.before) : cn.length - 1; out += `.move(${prev} → ${next})`; break; } case 'remove': out += `.remove()`; break; case 'custom': out += entry.text.replace('%VALUE%', this._fmt(entry.value)).replace('%PREVIOUS%', this._fmt(entry.previous)); break; default: out += `ERROR: Unknown audit entry type: ${(entry as any).type}`; } return out; } private _fmt(value: any): string { const type = typeof value; if (type === 'string') return `"${value}"`; if (type === 'number') return `${value}`; if (type === 'boolean') return `${value}`; if (type === 'undefined') return 'undefined'; if (type === 'object') { if (value === null) return 'null'; if (Array.isArray(value)) { let out = '['; for (let i = 0; i < value.length; i++) { if (i) out += ', '; if (i > 5) { out += ` + ${value.length - 5} more`; break; } out += this._fmt(value[i]); } out += ']'; return out; } if (value instanceof Element) { let out = `<${value.localName}`; if (value.id) out += `#${value.id}`; else if (value.className) out += `.${value.className.split(' ').join('.')}`; out += '>'; return out; } if (value instanceof Text) return `<#text>`; if (value instanceof Comment) return `<#comment>`; if (value instanceof DocumentFragment) return `<#document-fragment>`; if (value instanceof Document) return `<#document>`; if (value instanceof Date) return `#Date(${value.toISOString()})`; if (value instanceof RegExp) return `#RegExp(${value.toString()})`; if (value instanceof Error) return `#Error(${value.message})`; if (value instanceof Map) return `#Map(${this._fmt(Object.fromEntries(value.entries()))})`; if (value instanceof Set) return `#Set(${this._fmt(Array.from(value))})`; if (value instanceof WeakMap) return `#WeakMap()`; if (value instanceof WeakSet) return `#WeakSet()`; if (value instanceof Function) return `#Function(${value.name})`; let out = '{'; let i = 0; for (let prop in value) { if (i) out += ', '; if (i > 5) { out += ` + ${Object.keys(value).length - 5} more`; break; } out += prop; out += ': '; out += this._fmt(value[prop]); i++; } out += '}'; return out; } return `${value}`; } private formatNode(node: Node): string { if (node instanceof Element) { const id = node.id ? `#${node.id}` : ''; const classes = node.className ? `.${node.className.split(' ').join('.')}` : ''; return `${node.tagName.toLowerCase()}${id}${classes}`; } return node.nodeName.toLowerCase(); } }