Skip inactive time is an important and useful feature. We consider user interaction events as active, and check next user interaction event after apply incremental snapshot. If next user interaction event has a time gap larger than the threshold, we will set a dynamic speed value which will skip the inactive time interval in about 5 seconds.
563 lines
17 KiB
TypeScript
563 lines
17 KiB
TypeScript
import { rebuild, buildNodeWithSN } from 'rrweb-snapshot';
|
|
import * as mittProxy from 'mitt';
|
|
import * as smoothscroll from 'smoothscroll-polyfill';
|
|
import Timer from './timer';
|
|
import {
|
|
EventType,
|
|
IncrementalSource,
|
|
fullSnapshotEvent,
|
|
eventWithTime,
|
|
MouseInteractions,
|
|
playerConfig,
|
|
playerMetaData,
|
|
viewportResizeDimention,
|
|
missingNodeMap,
|
|
addedNodeMutation,
|
|
missingNode,
|
|
actionWithDelay,
|
|
incrementalSnapshotEvent,
|
|
} from '../types';
|
|
import { mirror } from '../utils';
|
|
import injectStyleRules from './styles/inject-style';
|
|
import './styles/style.css';
|
|
|
|
const SKIP_TIME_THRESHOLD = 10 * 1000;
|
|
const SKIP_TIME_INTERVAL = 5 * 1000;
|
|
|
|
smoothscroll.polyfill();
|
|
|
|
// https://github.com/rollup/rollup/issues/1267#issuecomment-296395734
|
|
// tslint:disable-next-line
|
|
const mitt = (mittProxy as any).default || mittProxy;
|
|
|
|
const defaultConfig: playerConfig = {
|
|
speed: 1,
|
|
root: document.body,
|
|
loadTimeout: 0,
|
|
skipInactive: false,
|
|
};
|
|
|
|
export class Replayer {
|
|
public wrapper: HTMLDivElement;
|
|
|
|
private events: eventWithTime[] = [];
|
|
private config: playerConfig = defaultConfig;
|
|
|
|
private iframe: HTMLIFrameElement;
|
|
private mouse: HTMLDivElement;
|
|
|
|
private emitter: mitt.Emitter = mitt();
|
|
|
|
private baselineTime: number = 0;
|
|
// record last played event timestamp when paused
|
|
private lastPlayedEvent: eventWithTime;
|
|
|
|
private nextUserInteractionEvent: eventWithTime | null;
|
|
private noramlSpeed: number;
|
|
|
|
private timer: Timer;
|
|
|
|
private missingNodeRetryMap: missingNodeMap = {};
|
|
|
|
constructor(events: eventWithTime[], config?: Partial<playerConfig>) {
|
|
if (events.length < 2) {
|
|
throw new Error('Replayer need at least 2 events.');
|
|
}
|
|
this.events = events;
|
|
this.handleResize = this.handleResize.bind(this);
|
|
|
|
this.timer = new Timer(this.config);
|
|
this.setConfig(Object.assign({}, config));
|
|
this.setupDom();
|
|
this.emitter.on('resize', this.handleResize as mitt.Handler);
|
|
}
|
|
|
|
public on(event: string, handler: mitt.Handler) {
|
|
this.emitter.on(event, handler);
|
|
}
|
|
|
|
public setConfig(config: Partial<playerConfig>) {
|
|
Object.keys(config).forEach((key: keyof playerConfig) => {
|
|
this.config[key] = config[key]!;
|
|
});
|
|
}
|
|
|
|
public getMetaData(): playerMetaData {
|
|
const firstEvent = this.events[0];
|
|
const lastEvent = this.events[this.events.length - 1];
|
|
return {
|
|
totalTime: lastEvent.timestamp - firstEvent.timestamp,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* This API was designed to be used as play at any time offset.
|
|
* Since we minimized the data collected from recorder, we do not
|
|
* have the ability of undo an event.
|
|
* So the implementation of play at any time offset will always iterate
|
|
* all of the events, cast event before the offset synchronously
|
|
* and cast event after the offset asynchronously with timer.
|
|
* @param timeOffset number
|
|
*/
|
|
public play(timeOffset = 0) {
|
|
this.timer.clear();
|
|
this.baselineTime = this.events[0].timestamp + timeOffset;
|
|
const actions = new Array<actionWithDelay>();
|
|
for (const event of this.events) {
|
|
const isSync = event.timestamp < this.baselineTime;
|
|
const castFn = this.getCastFn(event, isSync);
|
|
if (isSync) {
|
|
castFn();
|
|
} else {
|
|
actions.push({ doAction: castFn, delay: this.getDelay(event) });
|
|
}
|
|
}
|
|
this.timer.addActions(actions);
|
|
this.timer.start();
|
|
}
|
|
|
|
public pause() {
|
|
this.timer.clear();
|
|
this.emitter.emit('pause');
|
|
}
|
|
|
|
public resume(timeOffset = 0) {
|
|
this.timer.clear();
|
|
this.baselineTime = this.events[0].timestamp + timeOffset;
|
|
const actions = new Array<actionWithDelay>();
|
|
for (const event of this.events) {
|
|
if (
|
|
event.timestamp <= this.lastPlayedEvent.timestamp ||
|
|
event === this.lastPlayedEvent
|
|
) {
|
|
continue;
|
|
}
|
|
const castFn = this.getCastFn(event);
|
|
actions.push({
|
|
doAction: castFn,
|
|
delay: this.getDelay(event),
|
|
});
|
|
}
|
|
this.timer.addActions(actions);
|
|
this.timer.start();
|
|
this.emitter.emit('resume');
|
|
}
|
|
|
|
private setupDom() {
|
|
this.wrapper = document.createElement('div');
|
|
this.wrapper.classList.add('replayer-wrapper');
|
|
this.config.root.appendChild(this.wrapper);
|
|
|
|
this.mouse = document.createElement('div');
|
|
this.mouse.classList.add('replayer-mouse');
|
|
this.wrapper.appendChild(this.mouse);
|
|
|
|
this.iframe = document.createElement('iframe');
|
|
this.iframe.setAttribute('sandbox', 'allow-same-origin');
|
|
this.iframe.setAttribute('scrolling', 'no');
|
|
this.wrapper.appendChild(this.iframe);
|
|
}
|
|
|
|
private handleResize(dimension: viewportResizeDimention) {
|
|
this.iframe.width = `${dimension.width}px`;
|
|
this.iframe.height = `${dimension.height}px`;
|
|
}
|
|
|
|
// TODO: add speed to mouse move timestamp calculation
|
|
private getDelay(event: eventWithTime): number {
|
|
// Mouse move events was recorded in a throttle function,
|
|
// so we need to find the real timestamp by traverse the time offsets.
|
|
if (
|
|
event.type === EventType.IncrementalSnapshot &&
|
|
event.data.source === IncrementalSource.MouseMove
|
|
) {
|
|
const firstOffset = event.data.positions[0].timeOffset;
|
|
// timeOffset is a negative offset to event.timestamp
|
|
const firstTimestamp = event.timestamp + firstOffset;
|
|
event.delay = firstTimestamp - this.baselineTime;
|
|
return firstTimestamp - this.baselineTime;
|
|
}
|
|
event.delay = event.timestamp - this.baselineTime;
|
|
return event.timestamp - this.baselineTime;
|
|
}
|
|
|
|
private getCastFn(event: eventWithTime, isSync = false) {
|
|
let castFn: undefined | (() => void);
|
|
switch (event.type) {
|
|
case EventType.DomContentLoaded:
|
|
case EventType.Load:
|
|
break;
|
|
case EventType.Meta:
|
|
castFn = () =>
|
|
this.emitter.emit('resize', {
|
|
width: event.data.width,
|
|
height: event.data.height,
|
|
});
|
|
break;
|
|
case EventType.FullSnapshot:
|
|
castFn = () => {
|
|
this.rebuildFullSnapshot(event);
|
|
this.iframe.contentWindow!.scrollTo(event.data.initialOffset);
|
|
};
|
|
break;
|
|
case EventType.IncrementalSnapshot:
|
|
castFn = () => {
|
|
this.applyIncremental(event, isSync);
|
|
if (event === this.nextUserInteractionEvent) {
|
|
this.nextUserInteractionEvent = null;
|
|
this.setConfig({ speed: this.noramlSpeed });
|
|
this.emitter.emit('skip-end');
|
|
}
|
|
if (this.config.skipInactive && !this.nextUserInteractionEvent) {
|
|
for (const _event of this.events) {
|
|
if (_event.delay! <= event.delay!) {
|
|
continue;
|
|
}
|
|
if (this.isUserInteraction(_event)) {
|
|
if (
|
|
_event.delay! - event.delay! >
|
|
SKIP_TIME_THRESHOLD * this.config.speed
|
|
) {
|
|
this.nextUserInteractionEvent = _event;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
if (this.nextUserInteractionEvent) {
|
|
this.noramlSpeed = this.config.speed;
|
|
const skipTime =
|
|
this.nextUserInteractionEvent.delay! - event.delay!;
|
|
this.setConfig({
|
|
speed: Math.round(skipTime / SKIP_TIME_INTERVAL),
|
|
});
|
|
this.emitter.emit('skip-start');
|
|
}
|
|
}
|
|
};
|
|
break;
|
|
default:
|
|
}
|
|
const wrappedCastFn = () => {
|
|
if (castFn) {
|
|
castFn();
|
|
}
|
|
this.lastPlayedEvent = event;
|
|
if (event === this.events[this.events.length - 1]) {
|
|
this.emitter.emit('finish');
|
|
}
|
|
};
|
|
return wrappedCastFn;
|
|
}
|
|
|
|
private rebuildFullSnapshot(
|
|
event: fullSnapshotEvent & { timestamp: number },
|
|
) {
|
|
if (Object.keys(this.missingNodeRetryMap).length) {
|
|
console.warn(
|
|
'Found unresolved missing node map',
|
|
this.missingNodeRetryMap,
|
|
);
|
|
}
|
|
this.missingNodeRetryMap = {};
|
|
mirror.map = rebuild(event.data.node, this.iframe.contentDocument!)[1];
|
|
const styleEl = document.createElement('style');
|
|
const { documentElement, head } = this.iframe.contentDocument!;
|
|
documentElement!.insertBefore(styleEl, head);
|
|
for (let idx = 0; idx < injectStyleRules.length; idx++) {
|
|
(styleEl.sheet! as CSSStyleSheet).insertRule(injectStyleRules[idx], idx);
|
|
}
|
|
this.waitForStylesheetLoad();
|
|
}
|
|
|
|
/**
|
|
* pause when loading style sheet, resume when loaded all timeout exceed
|
|
*/
|
|
private waitForStylesheetLoad() {
|
|
const { head } = this.iframe.contentDocument!;
|
|
if (head) {
|
|
const unloadSheets: Set<HTMLLinkElement> = new Set();
|
|
let timer: number;
|
|
head
|
|
.querySelectorAll('link[rel="stylesheet"]')
|
|
.forEach((css: HTMLLinkElement) => {
|
|
if (!css.sheet) {
|
|
if (unloadSheets.size === 0) {
|
|
this.pause();
|
|
this.emitter.emit('wait-stylesheet');
|
|
timer = window.setTimeout(() => {
|
|
this.resume();
|
|
// mark timer was called
|
|
timer = -1;
|
|
}, this.config.loadTimeout);
|
|
}
|
|
unloadSheets.add(css);
|
|
css.addEventListener('load', () => {
|
|
unloadSheets.delete(css);
|
|
if (unloadSheets.size === 0 && timer !== -1) {
|
|
this.resume();
|
|
this.emitter.emit('stylesheet-loaded');
|
|
if (timer) {
|
|
window.clearTimeout(timer);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private applyIncremental(
|
|
e: incrementalSnapshotEvent & { timestamp: number },
|
|
isSync: boolean,
|
|
) {
|
|
const { data: d } = e;
|
|
switch (d.source) {
|
|
case IncrementalSource.Mutation: {
|
|
d.removes.forEach(mutation => {
|
|
const target = mirror.getNode(mutation.id);
|
|
if (!target) {
|
|
return;
|
|
}
|
|
const parent = (mirror.getNode(
|
|
mutation.parentId!,
|
|
) as Node) as Element;
|
|
// target may be removed with its parents before
|
|
mirror.removeNodeFromMap(target);
|
|
if (parent) {
|
|
parent.removeChild(target);
|
|
}
|
|
});
|
|
|
|
const missingNodeMap: missingNodeMap = { ...this.missingNodeRetryMap };
|
|
d.adds.forEach(mutation => {
|
|
const target = buildNodeWithSN(
|
|
mutation.node,
|
|
this.iframe.contentDocument!,
|
|
mirror.map,
|
|
true,
|
|
) as Node;
|
|
const parent = (mirror.getNode(mutation.parentId) as Node) as Element;
|
|
let previous: Node | null = null;
|
|
let next: Node | null = null;
|
|
if (mutation.previousId) {
|
|
previous = mirror.getNode(mutation.previousId) as Node;
|
|
}
|
|
if (mutation.nextId) {
|
|
next = mirror.getNode(mutation.nextId) as Node;
|
|
}
|
|
|
|
if (mutation.previousId === -1 || mutation.nextId === -1) {
|
|
missingNodeMap[mutation.node.id] = {
|
|
node: target,
|
|
mutation,
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (
|
|
previous &&
|
|
previous.nextSibling &&
|
|
previous.nextSibling.parentNode
|
|
) {
|
|
parent.insertBefore(target, previous.nextSibling);
|
|
} else if (next && next.parentNode) {
|
|
parent.insertBefore(target, next);
|
|
} else {
|
|
parent.appendChild(target);
|
|
}
|
|
|
|
if (mutation.previousId || mutation.nextId) {
|
|
this.resolveMissingNode(missingNodeMap, parent, target, mutation);
|
|
}
|
|
});
|
|
if (Object.keys(missingNodeMap).length) {
|
|
Object.assign(this.missingNodeRetryMap, missingNodeMap);
|
|
}
|
|
|
|
d.texts.forEach(mutation => {
|
|
const target = (mirror.getNode(mutation.id) as Node) as Text;
|
|
target.textContent = mutation.value;
|
|
});
|
|
d.attributes.forEach(mutation => {
|
|
const target = (mirror.getNode(mutation.id) as Node) as Element;
|
|
for (const attributeName in mutation.attributes) {
|
|
if (typeof attributeName === 'string') {
|
|
const value = mutation.attributes[attributeName];
|
|
if (value) {
|
|
target.setAttribute(attributeName, value);
|
|
} else {
|
|
target.removeAttribute(attributeName);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
case IncrementalSource.MouseMove:
|
|
// skip mouse move in sync mode
|
|
if (!isSync) {
|
|
d.positions.forEach(p => {
|
|
const action = {
|
|
doAction: () => {
|
|
this.mouse.style.left = `${p.x}px`;
|
|
this.mouse.style.top = `${p.y}px`;
|
|
const target = mirror.getNode(p.id);
|
|
if (target) {
|
|
this.hoverElements((target as Node) as Element);
|
|
}
|
|
},
|
|
delay: p.timeOffset + e.timestamp - this.baselineTime,
|
|
};
|
|
this.timer.addAction(action);
|
|
});
|
|
}
|
|
break;
|
|
case IncrementalSource.MouseInteraction: {
|
|
/**
|
|
* Same as the situation of missing input target.
|
|
*/
|
|
if (d.id === -1) {
|
|
break;
|
|
}
|
|
const event = new Event(MouseInteractions[d.type].toLowerCase());
|
|
const target = (mirror.getNode(d.id) as Node) as HTMLElement;
|
|
switch (d.type) {
|
|
case MouseInteractions.Blur:
|
|
target.blur();
|
|
break;
|
|
case MouseInteractions.Focus:
|
|
target.focus({
|
|
preventScroll: true,
|
|
});
|
|
break;
|
|
case MouseInteractions.Click:
|
|
/**
|
|
* Click has no visual impact when replaying and may
|
|
* trigger navigation when apply to an <a> link.
|
|
* So we will not call click(), instead we add an
|
|
* animation to the mouse element which indicate user
|
|
* clicked at this moment.
|
|
*/
|
|
if (!isSync) {
|
|
this.mouse.classList.remove('active');
|
|
// tslint:disable-next-line
|
|
void this.mouse.offsetWidth;
|
|
this.mouse.classList.add('active');
|
|
}
|
|
break;
|
|
default:
|
|
target.dispatchEvent(event);
|
|
}
|
|
break;
|
|
}
|
|
case IncrementalSource.Scroll: {
|
|
/**
|
|
* Same as the situation of missing input target.
|
|
*/
|
|
if (d.id === -1) {
|
|
break;
|
|
}
|
|
const target = mirror.getNode(d.id) as Node;
|
|
if (target === this.iframe.contentDocument) {
|
|
this.iframe.contentWindow!.scrollTo({
|
|
top: d.y,
|
|
left: d.x,
|
|
behavior: isSync ? 'instant' : 'smooth',
|
|
});
|
|
} else {
|
|
try {
|
|
(target as Element).scrollTop = d.y;
|
|
(target as Element).scrollLeft = d.x;
|
|
} catch (error) {
|
|
/**
|
|
* Seldomly we may found scroll target was removed before
|
|
* its last scroll event.
|
|
*/
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case IncrementalSource.ViewportResize:
|
|
this.emitter.emit('resize', {
|
|
width: d.width,
|
|
height: d.height,
|
|
});
|
|
break;
|
|
case IncrementalSource.Input: {
|
|
/**
|
|
* Input event on an unserialized node usually means the event
|
|
* was synchrony triggered programmatically after the node was
|
|
* created. This means there was not an user observable interaction
|
|
* and we do not need to replay it.
|
|
*/
|
|
if (d.id === -1) {
|
|
break;
|
|
}
|
|
const target: HTMLInputElement = (mirror.getNode(
|
|
d.id,
|
|
) as Node) as HTMLInputElement;
|
|
try {
|
|
target.checked = d.isChecked;
|
|
target.value = d.text;
|
|
} catch (error) {
|
|
// for safe
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
}
|
|
}
|
|
|
|
private resolveMissingNode(
|
|
map: missingNodeMap,
|
|
parent: Node,
|
|
target: Node,
|
|
targetMutation: addedNodeMutation,
|
|
) {
|
|
const { previousId, nextId } = targetMutation;
|
|
const previousInMap = previousId && map[previousId];
|
|
const nextInMap = nextId && map[nextId];
|
|
if (previousInMap) {
|
|
const { node, mutation } = previousInMap as missingNode;
|
|
parent.insertBefore(node, target);
|
|
delete map[mutation.node.id];
|
|
delete this.missingNodeRetryMap[mutation.node.id];
|
|
if (mutation.previousId || mutation.nextId) {
|
|
this.resolveMissingNode(map, parent, node as Node, mutation);
|
|
}
|
|
}
|
|
if (nextInMap) {
|
|
const { node, mutation } = nextInMap as missingNode;
|
|
parent.insertBefore(node, target.nextSibling);
|
|
delete map[mutation.node.id];
|
|
delete this.missingNodeRetryMap[mutation.node.id];
|
|
if (mutation.previousId || mutation.nextId) {
|
|
this.resolveMissingNode(map, parent, node as Node, mutation);
|
|
}
|
|
}
|
|
}
|
|
|
|
private hoverElements(el: Element) {
|
|
this.iframe
|
|
.contentDocument!.querySelectorAll('.\\:hover')
|
|
.forEach(hoveredEl => {
|
|
hoveredEl.classList.remove(':hover');
|
|
});
|
|
let currentEl: Element | null = el;
|
|
while (currentEl) {
|
|
currentEl.classList.add(':hover');
|
|
currentEl = currentEl.parentElement;
|
|
}
|
|
}
|
|
|
|
private isUserInteraction(event: eventWithTime): boolean {
|
|
if (event.type !== EventType.IncrementalSnapshot) {
|
|
return false;
|
|
}
|
|
return (
|
|
event.data.source > IncrementalSource.Mutation &&
|
|
event.data.source <= IncrementalSource.Input
|
|
);
|
|
}
|
|
}
|