In this commit we switched the sandbox strategy to use iframe's sandbox attribute. Indeed we do not need delegate event anymore, but need to add some styles into the iframe. The details were documented in the sandbox part of internal design.
473 lines
14 KiB
TypeScript
473 lines
14 KiB
TypeScript
import { rebuild, buildNodeWithSN } from 'rrweb-snapshot';
|
|
import * as mittProxy from 'mitt';
|
|
import Timer from './timer';
|
|
import {
|
|
EventType,
|
|
incrementalData,
|
|
IncrementalSource,
|
|
fullSnapshotEvent,
|
|
eventWithTime,
|
|
MouseInteractions,
|
|
playerConfig,
|
|
playerMetaData,
|
|
viewportResizeDimention,
|
|
missingNodeMap,
|
|
addedNodeMutation,
|
|
missingNode,
|
|
actionWithDelay,
|
|
} from '../types';
|
|
import { mirror } from '../utils';
|
|
import injectStyleRules from './styles/inject-style';
|
|
import './styles/style.css';
|
|
|
|
// 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: 10 * 1000,
|
|
};
|
|
|
|
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 timer: Timer;
|
|
|
|
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.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();
|
|
}
|
|
|
|
public resume(timeOffset = 0) {
|
|
this.timer.clear();
|
|
const actions = new Array<actionWithDelay>();
|
|
for (const event of this.events) {
|
|
if (
|
|
event.timestamp < this.lastPlayedEvent.timestamp ||
|
|
event === this.lastPlayedEvent
|
|
) {
|
|
continue;
|
|
}
|
|
const delayToBaseline = this.getDelay(event);
|
|
const castFn = this.getCastFn(event);
|
|
actions.push({
|
|
doAction: castFn,
|
|
delay: delayToBaseline - timeOffset,
|
|
});
|
|
}
|
|
this.timer.addActions(actions);
|
|
this.timer.start();
|
|
}
|
|
|
|
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.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;
|
|
const delay = firstTimestamp - this.baselineTime;
|
|
event.data.positions = event.data.positions.map(p => {
|
|
return {
|
|
...p,
|
|
timeOffset: p.timeOffset - firstOffset + delay,
|
|
};
|
|
});
|
|
return delay;
|
|
}
|
|
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.data, isSync);
|
|
};
|
|
break;
|
|
default:
|
|
}
|
|
const wrappedCastFn = () => {
|
|
if (castFn) {
|
|
castFn();
|
|
}
|
|
this.lastPlayedEvent = event;
|
|
};
|
|
return wrappedCastFn;
|
|
}
|
|
|
|
private rebuildFullSnapshot(
|
|
event: fullSnapshotEvent & { timestamp: number },
|
|
) {
|
|
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(d: incrementalData, isSync: boolean) {
|
|
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 = {};
|
|
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) {
|
|
console.warn('Found unresolved missing node map', 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,
|
|
};
|
|
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;
|
|
if (d.type === MouseInteractions.Blur) {
|
|
target.blur();
|
|
} else if (d.type === 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.
|
|
*/
|
|
this.mouse.classList.remove('active');
|
|
// tslint:disable-next-line
|
|
void this.mouse.offsetWidth;
|
|
this.mouse.classList.add('active');
|
|
} else if (d.type === MouseInteractions.Focus) {
|
|
target.focus();
|
|
} else {
|
|
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 {
|
|
(target as Element).scrollTop = d.y;
|
|
(target as Element).scrollLeft = d.x;
|
|
}
|
|
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;
|
|
target.checked = d.isChecked;
|
|
target.value = d.text;
|
|
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 || nextInMap) {
|
|
const { node, mutation } = (previousInMap || nextInMap) as missingNode;
|
|
if (previousInMap) {
|
|
parent.insertBefore(node, target);
|
|
} else {
|
|
parent.insertBefore(node, target.nextSibling);
|
|
}
|
|
delete map[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;
|
|
}
|
|
}
|
|
}
|