Skip to content

Instantly share code, notes, and snippets.

@developit
Last active January 13, 2025 18:46
Show Gist options
  • Save developit/b15ed619afcbc64b8faeb60c25168d0c to your computer and use it in GitHub Desktop.
Save developit/b15ed619afcbc64b8faeb60c25168d0c to your computer and use it in GitHub Desktop.

DOM Audit Logger

A little library for auditing DOM modifications.

Features

  • Tracks DOM modifications (textContent, innerHTML, className)
  • Extensible audit system for custom operations
  • Formatted audit trail output
  • Event-based subscription for real-time monitoring

Installation

npm install dom-audit-logger

Usage

Basic Usage

import { DOMAuditLogger } from 'dom-audit-logger';

// Get the singleton instance
const logger = new DOMAuditLogger();

// listen for log entries
logger.on('log', entry => console.log(logger.formatLog(entry)));

// automatically tracks DOM modifications
document.body.textContent = 'Hello World';
// LOG:  <body>.textContent = "Hello World"

// Get the audit trail (flushes internal buffer)
console.log(logger.flushTrail());

// Clear internal buffer
logger.clear();

Custom Audit Events

const logger = new DOMAuditLogger();

logger.logCustom({
  node,
  text: '.data = %VALUE% (via signal, was %PREVIOUS%)',
  value: 42,
  previous: 41
});

logger.on('custom', (entry) => {
  console.log(logger.formatEntry(entry));
});

Cleanup

// Remove all patches and clear logs
logger.destroy();

License

MIT

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();
}
}
export class DOMAuditLogger {
#nextId = 0;
#logs = [];
#handlers = new Map();
#cleanup = [];
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();
}
observeMethod(target, method, callback) {
const original = target[method];
if (typeof original !== 'function')
throw Error(`Method "${method}" is not a function`);
target[method] = function () {
if (callback(this, ...arguments) === false)
return;
return original.apply(this, arguments);
};
}
observeSetter(target, prop) {
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 (value) {
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, handler) {
let handlers = this.#handlers.get(type);
if (!handlers) {
handlers = [];
this.#handlers.set(type, handlers);
}
handlers.push(handler);
}
off(type, handler) {
const handlers = this.#handlers.get(type);
if (!handlers)
return;
const index = handlers.indexOf(handler);
if (index !== -1)
handlers.splice(index, 1);
}
logSet(node, property, value, previous) {
this.log({
id: this.#nextId++,
type: 'set',
node,
property,
value,
previous
});
}
logInsert(node, child, before) {
this.log({
id: this.#nextId++,
type: child.parentNode === node ? 'move' : 'insert',
node,
child,
before: before ?? undefined
});
}
logRemove(node) {
this.log({
id: this.#nextId++,
type: 'remove',
node,
});
}
logCustom(node, text, value, previous) {
this.log({
id: this.#nextId++,
type: 'custom',
node,
text,
value,
previous
});
}
log(_entry) {
const entry = _entry;
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() {
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() {
return this.flush()
.map(entry => entry.message)
.join('\n');
}
clear() {
this.#logs.length = 0;
}
formatLog(entry) {
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.type}`;
}
return out;
}
_fmt(value) {
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}`;
}
formatNode(node) {
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();
}
}
{
"name": "dom-audit-logger",
"version": "0.0.1",
"description": "A library for monitoring DOM operations with extensible buffered logging",
"type": "module",
"main": "out.js",
"types": "index.ts",
"exports": {
"types": "index.ts",
"default": "out.js"
},
"scripts": {
"build": "tsc index.ts -o out.js"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment