|
type Methods<T> = { [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<string, AuditHandler[]> = 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<T extends Node, M extends Methods<T> & string>(target: T, method: M, callback: (node: T, ...args: T[M] extends (...args: any[]) => any ? Parameters<T[M]> : 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<T extends Node>(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(); |
|
} |
|
} |