Files
rrweb/src/record/observer.ts
Sebastian Jakob 2502913883 Option to mask inputs (#80)
* Option to mask inputs

Added option 'maskAllInputs' to replace all user inputs with an Asterisk.

* Update types.d.ts
2026-04-01 12:00:00 +08:00

505 lines
15 KiB
TypeScript

import { INode, serializeNodeWithId } from 'rrweb-snapshot';
import {
mirror,
throttle,
on,
hookSetter,
getWindowHeight,
getWindowWidth,
isBlocked,
isAncestorRemoved,
} from '../utils';
import {
mutationCallBack,
removedNodeMutation,
addedNodeMutation,
observerParam,
mousemoveCallBack,
mousePosition,
mouseInteractionCallBack,
MouseInteractions,
listenerHandler,
scrollCallback,
viewportResizeCallback,
inputValue,
inputCallback,
hookResetter,
textCursor,
attributeCursor,
blockClass,
} from '../types';
import { deepDelete, isParentRemoved, isAncestorInSet } from './collection';
const moveKey = (id: number, parentId: number) => `${id}@${parentId}`;
function isINode(n: Node | INode): n is INode {
return '__sn' in n;
}
/**
* Mutation observer will merge several mutations into an array and pass
* it to the callback function, this may make tracing added nodes hard.
* For example, if we append an element el_1 into body, and then append
* another element el_2 into el_1, these two mutations may be passed to the
* callback function together when the two operations were done.
* Generally we need trace child nodes of newly added node, but in this
* case if we count el_2 as el_1's child node in the first mutation record,
* then we will count el_2 again in the secoond mutation record which was
* duplicated.
* To avoid of duplicate counting added nodes, we will use a Set to store
* added nodes and its child nodes during iterate mutation records. Then
* collect added nodes from the Set which will has no duplicate copy. But
* this also cause newly added node will not be serialized with id ASAP,
* which means all the id related calculation should be lazy too.
* @param cb mutationCallBack
*/
function initMutationObserver(
cb: mutationCallBack,
blockClass: blockClass,
inlineStylesheet: boolean,
): MutationObserver {
const observer = new MutationObserver(mutations => {
const texts: textCursor[] = [];
const attributes: attributeCursor[] = [];
let removes: removedNodeMutation[] = [];
const adds: addedNodeMutation[] = [];
const addedSet = new Set<Node>();
const movedSet = new Set<Node>();
const droppedSet = new Set<Node>();
const movedMap: Record<string, true> = {};
const genAdds = (n: Node | INode, target?: Node | INode) => {
if (isBlocked(n, blockClass)) {
return;
}
if (isINode(n)) {
movedSet.add(n);
let targetId: number | null = null;
if (target && isINode(target)) {
targetId = target.__sn.id;
}
if (targetId) {
movedMap[moveKey(n.__sn.id, targetId)] = true;
}
} else {
addedSet.add(n);
droppedSet.delete(n);
}
n.childNodes.forEach(childN => genAdds(childN));
};
mutations.forEach(mutation => {
const {
type,
target,
oldValue,
addedNodes,
removedNodes,
attributeName,
} = mutation;
switch (type) {
case 'characterData': {
const value = target.textContent;
if (!isBlocked(target, blockClass) && value !== oldValue) {
texts.push({
value,
node: target,
});
}
break;
}
case 'attributes': {
const value = (target as HTMLElement).getAttribute(attributeName!);
if (isBlocked(target, blockClass) || value === oldValue) {
return;
}
let item: attributeCursor | undefined = attributes.find(
a => a.node === target,
);
if (!item) {
item = {
node: target,
attributes: {},
};
attributes.push(item);
}
// overwrite attribute if the mutations was triggered in same time
item.attributes[attributeName!] = value;
break;
}
case 'childList': {
addedNodes.forEach(n => genAdds(n, target));
removedNodes.forEach(n => {
const nodeId = mirror.getId(n as INode);
const parentId = mirror.getId(target as INode);
if (isBlocked(n, blockClass)) {
return;
}
// removed node has not been serialized yet, just remove it from the Set
if (addedSet.has(n)) {
deepDelete(addedSet, n);
droppedSet.add(n);
} else if (addedSet.has(target) && nodeId === -1) {
/**
* If target was newly added and removed child node was
* not serialized, it means the child node has been removed
* before callback fired, so we can ignore it because
* newly added node will be serialized without child nodes.
* TODO: verify this
*/
} else if (isAncestorRemoved(target as INode)) {
/**
* If parent id was not in the mirror map any more, it
* means the parent node has already been removed. So
* the node is also removed which we do not need to track
* and replay.
*/
} else if (movedSet.has(n) && movedMap[moveKey(nodeId, parentId)]) {
deepDelete(movedSet, n);
} else {
removes.push({
parentId,
id: nodeId,
});
}
mirror.removeNodeFromMap(n as INode);
});
break;
}
default:
break;
}
});
/**
* Sometimes child node may be pushed before its newly added
* parent, so we init a queue to store these nodes.
*/
const addQueue: Node[] = [];
const pushAdd = (n: Node) => {
const parentId = mirror.getId((n.parentNode as Node) as INode);
if (parentId === -1) {
return addQueue.push(n);
}
adds.push({
parentId,
previousId: !n.previousSibling
? n.previousSibling
: mirror.getId(n.previousSibling as INode),
nextId: !n.nextSibling
? n.nextSibling
: mirror.getId((n.nextSibling as unknown) as INode),
node: serializeNodeWithId(
n,
document,
mirror.map,
blockClass,
true,
inlineStylesheet,
)!,
});
};
Array.from(movedSet).forEach(pushAdd);
Array.from(addedSet).forEach(n => {
if (!isAncestorInSet(droppedSet, n) && !isParentRemoved(removes, n)) {
pushAdd(n);
} else if (isAncestorInSet(movedSet, n)) {
pushAdd(n);
} else {
droppedSet.add(n);
}
});
while (addQueue.length) {
if (
addQueue.every(
n => mirror.getId((n.parentNode as Node) as INode) === -1,
)
) {
/**
* If all nodes in queue could not find a serialized parent,
* it may be a bug or corner case. We need to escape the
* dead while loop at once.
*/
break;
}
pushAdd(addQueue.shift()!);
}
const payload = {
texts: texts
.map(text => ({
id: mirror.getId(text.node as INode),
value: text.value,
}))
// text mutation's id was not in the mirror map means the target node has been removed
.filter(text => mirror.has(text.id)),
attributes: attributes
.map(attribute => ({
id: mirror.getId(attribute.node as INode),
attributes: attribute.attributes,
}))
// attribute mutation's id was not in the mirror map means the target node has been removed
.filter(attribute => mirror.has(attribute.id)),
removes,
adds,
};
// payload may be empty if the mutations happened in some blocked elements
if (
!payload.texts.length &&
!payload.attributes.length &&
!payload.removes.length &&
!payload.adds.length
) {
return;
}
cb(payload);
});
observer.observe(document, {
attributes: true,
attributeOldValue: true,
characterData: true,
characterDataOldValue: true,
childList: true,
subtree: true,
});
return observer;
}
function initMousemoveObserver(cb: mousemoveCallBack): listenerHandler {
let positions: mousePosition[] = [];
let timeBaseline: number | null;
const wrappedCb = throttle(() => {
const totalOffset = Date.now() - timeBaseline!;
cb(
positions.map(p => {
p.timeOffset -= totalOffset;
return p;
}),
);
positions = [];
timeBaseline = null;
}, 500);
const updatePosition = throttle<MouseEvent>(
evt => {
const { clientX, clientY, target } = evt;
if (!timeBaseline) {
timeBaseline = Date.now();
}
positions.push({
x: clientX,
y: clientY,
id: mirror.getId(target as INode),
timeOffset: Date.now() - timeBaseline,
});
wrappedCb();
},
50,
{
trailing: false,
},
);
return on('mousemove', updatePosition);
}
function initMouseInteractionObserver(
cb: mouseInteractionCallBack,
blockClass: blockClass,
): listenerHandler {
const handlers: listenerHandler[] = [];
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
return (event: MouseEvent) => {
if (isBlocked(event.target as Node, blockClass)) {
return;
}
const id = mirror.getId(event.target as INode);
const { clientX, clientY } = event;
cb({
type: MouseInteractions[eventKey],
id,
x: clientX,
y: clientY,
});
};
};
Object.keys(MouseInteractions)
.filter(key => Number.isNaN(Number(key)))
.forEach((eventKey: keyof typeof MouseInteractions) => {
const eventName = eventKey.toLowerCase();
const handler = getHandler(eventKey);
handlers.push(on(eventName, handler));
});
return () => {
handlers.forEach(h => h());
};
}
function initScrollObserver(
cb: scrollCallback,
blockClass: blockClass,
): listenerHandler {
const updatePosition = throttle<UIEvent>(evt => {
if (!evt.target || isBlocked(evt.target as Node, blockClass)) {
return;
}
const id = mirror.getId(evt.target as INode);
if (evt.target === document) {
const scrollEl = (document.scrollingElement || document.documentElement)!;
cb({
id,
x: scrollEl.scrollLeft,
y: scrollEl.scrollTop,
});
} else {
cb({
id,
x: (evt.target as HTMLElement).scrollLeft,
y: (evt.target as HTMLElement).scrollTop,
});
}
}, 100);
return on('scroll', updatePosition);
}
function initViewportResizeObserver(
cb: viewportResizeCallback,
): listenerHandler {
const updateDimension = throttle(() => {
const height = getWindowHeight();
const width = getWindowWidth();
cb({
width: Number(width),
height: Number(height),
});
}, 200);
return on('resize', updateDimension, window);
}
const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
const MASK_TYPES = ['color', 'date', 'datetime-local', 'email', 'month', 'number', 'range', 'search', 'tel', 'text', 'time', 'url', 'week']
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
function initInputObserver(
cb: inputCallback,
blockClass: blockClass,
ignoreClass: string,
maskAllInputs: boolean,
): listenerHandler {
function eventHandler(event: Event) {
const { target } = event;
if (
!target ||
!(target as Element).tagName ||
INPUT_TAGS.indexOf((target as Element).tagName) < 0 ||
isBlocked(target as Node, blockClass)
) {
return;
}
const type: string | undefined = (target as HTMLInputElement).type;
if (
type === 'password' ||
(target as HTMLElement).classList.contains(ignoreClass)
) {
return;
}
let text = (target as HTMLInputElement).value;
let isChecked = false;
if (type === 'radio' || type === 'checkbox') {
isChecked = (target as HTMLInputElement).checked;
} else if ((MASK_TYPES.indexOf(type) >= 0 || (target as Element).tagName == 'TEXTAREA') && maskAllInputs) {
text = Array(text.length+1).join('*')
}
cbWithDedup(target, { text, isChecked });
// if a radio was checked
// the other radios with the same name attribute will be unchecked.
const name: string | undefined = (target as HTMLInputElement).name;
if (type === 'radio' && name && isChecked) {
document
.querySelectorAll(`input[type="radio"][name="${name}"]`)
.forEach(el => {
if (el !== target) {
cbWithDedup(el, {
text: (el as HTMLInputElement).value,
isChecked: !isChecked,
});
}
});
}
}
function cbWithDedup(target: EventTarget, v: inputValue) {
const lastInputValue = lastInputValueMap.get(target);
if (
!lastInputValue ||
lastInputValue.text !== v.text ||
lastInputValue.isChecked !== v.isChecked
) {
lastInputValueMap.set(target, v);
const id = mirror.getId(target as INode);
cb({
...v,
id,
});
}
}
const handlers: Array<listenerHandler | hookResetter> = [
'input',
'change',
].map(eventName => on(eventName, eventHandler));
const propertyDescriptor = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
);
const hookProperties: Array<[HTMLElement, string]> = [
[HTMLInputElement.prototype, 'value'],
[HTMLInputElement.prototype, 'checked'],
[HTMLSelectElement.prototype, 'value'],
[HTMLTextAreaElement.prototype, 'value'],
];
if (propertyDescriptor && propertyDescriptor.set) {
handlers.push(
...hookProperties.map(p =>
hookSetter<HTMLElement>(p[0], p[1], {
set() {
// mock to a normal event
eventHandler({ target: this } as Event);
},
}),
),
);
}
return () => {
handlers.forEach(h => h());
};
}
export default function initObservers(o: observerParam): listenerHandler {
const mutationObserver = initMutationObserver(
o.mutationCb,
o.blockClass,
o.inlineStylesheet,
);
const mousemoveHandler = initMousemoveObserver(o.mousemoveCb);
const mouseInteractionHandler = initMouseInteractionObserver(
o.mouseInteractionCb,
o.blockClass,
);
const scrollHandler = initScrollObserver(o.scrollCb, o.blockClass);
const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb);
const inputHandler = initInputObserver(
o.inputCb,
o.blockClass,
o.ignoreClass,
o.maskAllInputs,
);
return () => {
mutationObserver.disconnect();
mousemoveHandler();
mouseInteractionHandler();
scrollHandler();
viewportResizeHandler();
inputHandler();
};
}