moved rrweb into packages/rrweb

This commit is contained in:
Mark-fenng
2026-04-01 12:00:00 +08:00
parent e7b8631992
commit f6aafb70e1
102 changed files with 1676 additions and 401 deletions

15
packages/rrweb/.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
.vscode
.idea
node_modules
package-lock.json
# yarn.lock
build
dist
es
lib
temp
*.log
.env

View File

@@ -0,0 +1,12 @@
{
"non-interactive": true,
"hooks": {
"before:init": ["npm run bundle", "npm run typings"]
},
"git": {
"requireCleanWorkingDir": false
},
"github": {
"release": true
}
}

View File

@@ -0,0 +1,79 @@
{
"name": "rrweb",
"version": "1.0.2",
"description": "record and replay the web",
"scripts": {
"prepare": "npm run prepack",
"prepack": "npm run bundle",
"test": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register -r ignore-styles -r jsdom-global/register test/**.test.ts",
"test:headless": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true PUPPETEER_HEADLESS=true mocha -r ts-node/register -r ignore-styles -r jsdom-global/register test/**.test.ts",
"test:watch": "PUPPETEER_HEADLESS=true npm run test -- --watch --watch-extensions js,ts",
"repl": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true ts-node scripts/repl.ts",
"bundle:browser": "cross-env BROWSER_ONLY=true rollup --config",
"bundle": "rollup --config",
"typings": "tsc -d --declarationDir typings",
"check-types": "tsc -noEmit"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/rrweb-io/rrweb.git"
},
"keywords": [
"rrweb"
],
"main": "lib/rrweb-all.js",
"module": "es/rrweb/src/entries/all.js",
"unpkg": "dist/rrweb.js",
"sideEffects": false,
"typings": "typings/entries/all.d.ts",
"files": [
"dist",
"lib",
"es",
"typings"
],
"author": "yanzhen@smartx.com",
"license": "MIT",
"bugs": {
"url": "https://github.com/rrweb-io/rrweb/issues"
},
"homepage": "https://github.com/rrweb-io/rrweb#readme",
"devDependencies": {
"@types/chai": "^4.1.6",
"@types/inquirer": "0.0.43",
"@types/jsdom": "^16.2.12",
"@types/mocha": "^5.2.5",
"@types/node": "^12.20.16",
"@types/prettier": "^2.3.2",
"@types/puppeteer": "^5.4.3",
"chai": "^4.2.0",
"cross-env": "^5.2.0",
"fast-mhtml": "^1.1.9",
"ignore-styles": "^5.0.1",
"inquirer": "^6.2.1",
"jest-snapshot": "^23.6.0",
"jsdom": "^16.6.0",
"jsdom-global": "^3.0.2",
"mocha": "^5.2.0",
"prettier": "2.2.1",
"puppeteer": "^9.1.1",
"rollup": "^2.3.3",
"rollup-plugin-commonjs": "^9.2.0",
"rollup-plugin-node-resolve": "^3.4.0",
"rollup-plugin-postcss": "^3.1.1",
"rollup-plugin-rename-node-modules": "^1.1.0",
"rollup-plugin-terser": "^5.3.0",
"rollup-plugin-typescript": "^1.0.0",
"ts-node": "^7.0.1",
"tslib": "^1.9.3",
"tslint": "^4.5.1",
"typescript": "^3.9.5"
},
"dependencies": {
"@types/css-font-loading-module": "0.0.4",
"@xstate/fsm": "^1.4.0",
"fflate": "^0.4.4",
"mitt": "^1.1.3",
"rrweb-snapshot": "^1.1.7"
}
}

View File

@@ -0,0 +1,211 @@
import typescript from 'rollup-plugin-typescript';
import resolve from 'rollup-plugin-node-resolve';
import { terser } from 'rollup-plugin-terser';
import postcss from 'rollup-plugin-postcss';
import renameNodeModules from 'rollup-plugin-rename-node-modules';
import pkg from './package.json';
function toRecordPath(path) {
return path
.replace(/^([\w]+)\//, '$1/record/')
.replace('rrweb', 'rrweb-record');
}
function toRecordPackPath(path) {
return path
.replace(/^([\w]+)\//, '$1/record/')
.replace('rrweb', 'rrweb-record-pack');
}
function toReplayPath(path) {
return path
.replace(/^([\w]+)\//, '$1/replay/')
.replace('rrweb', 'rrweb-replay');
}
function toReplayUnpackPath(path) {
return path
.replace(/^([\w]+)\//, '$1/replay/')
.replace('rrweb', 'rrweb-replay-unpack');
}
function toAllPath(path) {
return path.replace('rrweb', 'rrweb-all');
}
function toPluginPath(pluginName, stage) {
return (path) =>
path
.replace(/^([\w]+)\//, '$1/plugins/')
.replace('rrweb', `${pluginName}-${stage}`);
}
function toMinPath(path) {
return path.replace(/\.js$/, '.min.js');
}
const baseConfigs = [
// record only
{
input: './src/record/index.ts',
name: 'rrwebRecord',
pathFn: toRecordPath,
},
// record and pack
{
input: './src/entries/record-pack.ts',
name: 'rrwebRecord',
pathFn: toRecordPackPath,
},
// replay only
{
input: './src/replay/index.ts',
name: 'rrwebReplay',
pathFn: toReplayPath,
},
// replay and unpack
{
input: './src/entries/replay-unpack.ts',
name: 'rrwebReplay',
pathFn: toReplayUnpackPath,
},
// record and replay
{
input: './src/index.ts',
name: 'rrweb',
pathFn: (p) => p,
},
// all in one
{
input: './src/entries/all.ts',
name: 'rrweb',
pathFn: toAllPath,
esm: true,
},
// plugins
{
input: './src/plugins/console/record/index.ts',
name: 'rrwebConsoleRecord',
pathFn: toPluginPath('console', 'record'),
},
{
input: './src/plugins/console/replay/index.ts',
name: 'rrwebConsoleReplay',
pathFn: toPluginPath('console', 'replay'),
},
];
let configs = [];
for (const c of baseConfigs) {
const basePlugins = [resolve({ browser: true }), typescript()];
const plugins = basePlugins.concat(
postcss({
extract: false,
inject: false,
}),
);
// browser
configs.push({
input: c.input,
plugins,
output: [
{
name: c.name,
format: 'iife',
file: c.pathFn(pkg.unpkg),
},
],
});
// browser + minify
configs.push({
input: c.input,
plugins: basePlugins.concat(
postcss({
extract: true,
minimize: true,
sourceMap: true,
}),
terser(),
),
output: [
{
name: c.name,
format: 'iife',
file: toMinPath(c.pathFn(pkg.unpkg)),
sourcemap: true,
},
],
});
// CommonJS
configs.push({
input: c.input,
plugins,
output: [
{
format: 'cjs',
file: c.pathFn('lib/rrweb.js'),
},
],
});
if (c.esm) {
// ES module
configs.push({
input: c.input,
plugins,
preserveModules: true,
output: [
{
format: 'esm',
dir: 'es/rrweb',
plugins: [renameNodeModules('ext')],
},
],
});
}
}
if (process.env.BROWSER_ONLY) {
const browserOnlyBaseConfigs = [
{
input: './src/index.ts',
name: 'rrweb',
pathFn: (p) => p,
},
{
input: './src/plugins/console/record/index.ts',
name: 'rrwebConsoleRecord',
pathFn: toPluginPath('console', 'record'),
},
];
configs = [];
for (const c of browserOnlyBaseConfigs) {
const plugins = [
resolve({ browser: true }),
typescript(),
postcss({
extract: false,
inject: false,
sourceMap: true,
}),
terser(),
];
configs.push({
input: c.input,
plugins,
output: [
{
name: c.name,
format: 'iife',
file: toMinPath(c.pathFn(pkg.unpkg)),
sourcemap: true,
},
],
});
}
}
export default configs;

View File

@@ -0,0 +1,200 @@
/* tslint:disable: no-console */
import * as fs from 'fs';
import * as path from 'path';
import * as EventEmitter from 'events';
import * as inquirer from 'inquirer';
import * as puppeteer from 'puppeteer';
import { eventWithTime } from '../src/types';
const emitter = new EventEmitter();
function getCode(): string {
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
return fs.readFileSync(bundlePath, 'utf8');
}
(async () => {
const code = getCode();
let events: eventWithTime[] = [];
start();
async function start() {
events = [];
const { url } = await inquirer.prompt<{ url: string }>([
{
type: 'input',
name: 'url',
message:
'Enter the url you want to record, e.g https://react-redux.realworld.io: ',
},
]);
console.log(`Going to open ${url}...`);
await record(url);
console.log('Ready to record. You can do any interaction on the page.');
const { shouldReplay } = await inquirer.prompt<{ shouldReplay: boolean }>([
{
type: 'confirm',
name: 'shouldReplay',
message: `Once you want to finish the recording, enter 'y' to start replay: `,
},
]);
emitter.emit('done', shouldReplay);
const { shouldStore } = await inquirer.prompt<{ shouldStore: boolean }>([
{
type: 'confirm',
name: 'shouldStore',
message: `Persistently store these recorded events?`,
},
]);
if (shouldStore) {
saveEvents();
}
const { shouldRecordAnother } = await inquirer.prompt<{
shouldRecordAnother: boolean;
}>([
{
type: 'confirm',
name: 'shouldRecordAnother',
message: 'Record another one?',
},
]);
if (shouldRecordAnother) {
start();
} else {
process.exit();
}
}
async function record(url: string) {
const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 1600,
height: 900,
},
args: [
'--start-maximized',
'--ignore-certificate-errors',
'--no-sandbox',
],
});
const page = await browser.newPage();
await page.goto(url, {
waitUntil: 'domcontentloaded',
});
await page.exposeFunction('_replLog', (event: eventWithTime) => {
events.push(event);
});
await page.evaluate(`;${code}
window.__IS_RECORDING__ = true
rrweb.record({
emit: event => window._replLog(event),
recordCanvas: true,
collectFonts: true
});
`);
page.on('framenavigated', async () => {
const isRecording = await page.evaluate('window.__IS_RECORDING__');
if (!isRecording) {
await page.evaluate(`;${code}
window.__IS_RECORDING__ = true
rrweb.record({
emit: event => window._replLog(event),
recordCanvas: true,
collectFonts: true
});
`);
}
});
emitter.once('done', async (shouldReplay) => {
await browser.close();
if (shouldReplay) {
await replay();
}
});
}
async function replay() {
const browser = await puppeteer.launch({
headless: false,
defaultViewport: {
width: 1600,
height: 900,
},
args: ['--start-maximized', '--no-sandbox'],
});
const page = await browser.newPage();
await page.goto('about:blank');
await page.addStyleTag({
path: path.resolve(__dirname, '../dist/rrweb.min.css'),
});
await page.evaluate(`${code}
const events = ${JSON.stringify(events)};
const replayer = new rrweb.Replayer(events);
replayer.play();
`);
}
function saveEvents() {
const tempFolder = path.join(__dirname, '../temp');
console.log(tempFolder);
if (!fs.existsSync(tempFolder)) {
fs.mkdirSync(tempFolder);
}
const time = new Date()
.toISOString()
.replace(/[-|:]/g, '_')
.replace(/\..+/, '');
const fileName = `replay_${time}.html`;
const content = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Record @${time}</title>
<link rel="stylesheet" href="../dist/rrweb.min.css" />
</head>
<body>
<script src="../dist/rrweb.min.js"></script>
<script>
/*<!--*/
const events = ${JSON.stringify(events).replace(
/<\/script>/g,
'<\\/script>',
)};
/*-->*/
const replayer = new rrweb.Replayer(events, {
UNSAFE_replayCanvas: true
});
replayer.play();
</script>
</body>
</html>
`;
const savePath = path.resolve(tempFolder, fileName);
fs.writeFileSync(savePath, content);
console.log(`Saved at ${savePath}`);
}
process
.on('uncaughtException', (error) => {
console.error(error);
})
.on('unhandledRejection', (error) => {
console.error(error);
});
})();

View File

@@ -0,0 +1,4 @@
export * from '../index';
export * from '../packer';
export * from '../plugins/console/record';
export * from '../plugins/console/replay';

View File

@@ -0,0 +1,2 @@
export * from '../record/index';
export * from '../packer/pack';

View File

@@ -0,0 +1,2 @@
export * from '../replay';
export * from '../packer/unpack';

View File

@@ -0,0 +1,23 @@
import record from './record';
import { Replayer } from './replay';
import { _mirror } from './utils';
import * as utils from './utils';
export {
EventType,
IncrementalSource,
MouseInteractions,
ReplayerEvents,
} from './types';
const { addCustomEvent } = record;
const { freezePage } = record;
export {
record,
addCustomEvent,
freezePage,
Replayer,
_mirror as mirror,
utils,
};

View File

@@ -0,0 +1,10 @@
import { eventWithTime } from '../types';
export type PackFn = (event: eventWithTime) => string;
export type UnpackFn = (raw: string) => eventWithTime;
export type eventWithTimeAndPacker = eventWithTime & {
v: string;
};
export const MARK = 'v1';

View File

@@ -0,0 +1,2 @@
export { pack } from './pack';
export { unpack } from './unpack';

View File

@@ -0,0 +1,10 @@
import { strFromU8, strToU8, zlibSync } from 'fflate';
import { PackFn, MARK, eventWithTimeAndPacker } from './base';
export const pack: PackFn = (event) => {
const _e: eventWithTimeAndPacker = {
...event,
v: MARK,
};
return strFromU8(zlibSync(strToU8(JSON.stringify(_e))), true);
};

View File

@@ -0,0 +1,31 @@
import { strFromU8, strToU8, unzlibSync } from 'fflate';
import { UnpackFn, eventWithTimeAndPacker, MARK } from './base';
import { eventWithTime } from '../types';
export const unpack: UnpackFn = (raw: string) => {
if (typeof raw !== 'string') {
return raw;
}
try {
const e: eventWithTime = JSON.parse(raw);
if (e.timestamp) {
return e;
}
} catch (error) {
// ignore and continue
}
try {
const e: eventWithTimeAndPacker = JSON.parse(
strFromU8(unzlibSync(strToU8(raw, true)))
);
if (e.v === MARK) {
return e;
}
throw new Error(
`These events were packed with packer ${e.v} which is incompatible with current packer ${MARK}.`,
);
} catch (error) {
console.error(error);
throw new Error('Unknown data format.');
}
};

View File

@@ -0,0 +1,255 @@
// tslint:disable
/**
* Class StackFrame is a fork of https://github.com/stacktracejs/stackframe/blob/master/stackframe.js
* I fork it because:
* 1. There are some build issues when importing this package.
* 2. Rewrites into typescript give us a better type interface.
* 3. StackFrame contains some functions we don't need.
*/
export class StackFrame {
private fileName: string;
private functionName: string;
private lineNumber?: number;
private columnNumber?: number;
constructor(obj: {
fileName?: string;
functionName?: string;
lineNumber?: number;
columnNumber?: number;
}) {
this.fileName = obj.fileName || '';
this.functionName = obj.functionName || '';
this.lineNumber = obj.lineNumber;
this.columnNumber = obj.columnNumber;
}
toString() {
const lineNumber = this.lineNumber || '';
const columnNumber = this.columnNumber || '';
if (this.functionName) {
return (
this.functionName +
' (' +
this.fileName +
':' +
lineNumber +
':' +
columnNumber +
')'
);
}
return this.fileName + ':' + lineNumber + ':' + columnNumber;
}
}
/**
* ErrorStackParser is a fork of https://github.com/stacktracejs/error-stack-parser/blob/master/error-stack-parser.js
* I fork it because:
* 1. There are some build issues when importing this package.
* 2. Rewrites into typescript give us a better type interface.
*/
const FIREFOX_SAFARI_STACK_REGEXP = /(^|@)\S+:\d+/;
const CHROME_IE_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m;
const SAFARI_NATIVE_CODE_REGEXP = /^(eval@)?(\[native code])?$/;
export const ErrorStackParser = {
/**
* Given an Error object, extract the most information from it.
*
* @param {Error} error object
* @return {Array} of StackFrames
*/
parse: function (error: Error): StackFrame[] {
if (
// @ts-ignore
typeof error.stacktrace !== 'undefined' ||
// @ts-ignore
typeof error['opera#sourceloc'] !== 'undefined'
) {
return this.parseOpera(
error as {
stacktrace?: string;
message: string;
stack?: string;
},
);
} else if (error.stack && error.stack.match(CHROME_IE_STACK_REGEXP)) {
return this.parseV8OrIE(error as { stack: string });
} else if (error.stack) {
return this.parseFFOrSafari(error as { stack: string });
} else {
throw new Error('Cannot parse given Error object');
}
},
// Separate line and column numbers from a string of the form: (URI:Line:Column)
extractLocation: function (urlLike: string) {
// Fail-fast but return locations like "(native)"
if (urlLike.indexOf(':') === -1) {
return [urlLike];
}
const regExp = /(.+?)(?::(\d+))?(?::(\d+))?$/;
const parts = regExp.exec(urlLike.replace(/[()]/g, ''));
if (!parts) throw new Error(`Cannot parse given url: ${urlLike}`);
return [parts[1], parts[2] || undefined, parts[3] || undefined];
},
parseV8OrIE: function (error: { stack: string }) {
const filtered = error.stack.split('\n').filter(function (line) {
return !!line.match(CHROME_IE_STACK_REGEXP);
}, this);
return filtered.map(function (line) {
if (line.indexOf('(eval ') > -1) {
// Throw away eval information until we implement stacktrace.js/stackframe#8
line = line
.replace(/eval code/g, 'eval')
.replace(/(\(eval at [^()]*)|(\),.*$)/g, '');
}
let sanitizedLine = line.replace(/^\s+/, '').replace(/\(eval code/g, '(');
// capture and preseve the parenthesized location "(/foo/my bar.js:12:87)" in
// case it has spaces in it, as the string is split on \s+ later on
const location = sanitizedLine.match(/ (\((.+):(\d+):(\d+)\)$)/);
// remove the parenthesized location from the line, if it was matched
sanitizedLine = location
? sanitizedLine.replace(location[0], '')
: sanitizedLine;
const tokens = sanitizedLine.split(/\s+/).slice(1);
// if a location was matched, pass it to extractLocation() otherwise pop the last token
const locationParts = this.extractLocation(
location ? location[1] : tokens.pop(),
);
const functionName = tokens.join(' ') || undefined;
const fileName =
['eval', '<anonymous>'].indexOf(locationParts[0]) > -1
? undefined
: locationParts[0];
return new StackFrame({
functionName,
fileName,
lineNumber: locationParts[1],
columnNumber: locationParts[2],
});
}, this);
},
parseFFOrSafari: function (error: { stack: string }) {
const filtered = error.stack.split('\n').filter(function (line) {
return !line.match(SAFARI_NATIVE_CODE_REGEXP);
}, this);
return filtered.map(function (line) {
// Throw away eval information until we implement stacktrace.js/stackframe#8
if (line.indexOf(' > eval') > -1) {
line = line.replace(
/ line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g,
':$1',
);
}
if (line.indexOf('@') === -1 && line.indexOf(':') === -1) {
// Safari eval frames only have function names and nothing else
return new StackFrame({
functionName: line,
});
} else {
const functionNameRegex = /((.*".+"[^@]*)?[^@]*)(?:@)/;
const matches = line.match(functionNameRegex);
const functionName = matches && matches[1] ? matches[1] : undefined;
const locationParts = this.extractLocation(
line.replace(functionNameRegex, ''),
);
return new StackFrame({
functionName,
fileName: locationParts[0],
lineNumber: locationParts[1],
columnNumber: locationParts[2],
});
}
}, this);
},
parseOpera: function (e: {
stacktrace?: string;
message: string;
stack?: string;
}): StackFrame[] {
if (
!e.stacktrace ||
(e.message.indexOf('\n') > -1 &&
e.message.split('\n').length > e.stacktrace.split('\n').length)
) {
return this.parseOpera9(e as { message: string });
} else if (!e.stack) {
return this.parseOpera10(e as { stacktrace: string });
} else {
return this.parseOpera11(e as { stack: string });
}
},
parseOpera9: function (e: { message: string }) {
const lineRE = /Line (\d+).*script (?:in )?(\S+)/i;
const lines = e.message.split('\n');
const result = [];
for (let i = 2, len = lines.length; i < len; i += 2) {
const match = lineRE.exec(lines[i]);
if (match) {
result.push(
new StackFrame({
fileName: match[2],
lineNumber: parseFloat(match[1]),
}),
);
}
}
return result;
},
parseOpera10: function (e: { stacktrace: string }) {
const lineRE = /Line (\d+).*script (?:in )?(\S+)(?:: In function (\S+))?$/i;
const lines = e.stacktrace.split('\n');
const result = [];
for (let i = 0, len = lines.length; i < len; i += 2) {
const match = lineRE.exec(lines[i]);
if (match) {
result.push(
new StackFrame({
functionName: match[3] || undefined,
fileName: match[2],
lineNumber: parseFloat(match[1]),
}),
);
}
}
return result;
},
// Opera 10.65+ Error.stack very similar to FF/Safari
parseOpera11: function (error: { stack: string }) {
const filtered = error.stack.split('\n').filter(function (line) {
return (
!!line.match(FIREFOX_SAFARI_STACK_REGEXP) &&
!line.match(/^Error created at/)
);
}, this);
return filtered.map(function (line: string) {
const tokens = line.split('@');
const locationParts = this.extractLocation(tokens.pop());
const functionCall = tokens.shift() || '';
const functionName =
functionCall
.replace(/<anonymous function(: (\w+))?>/, '$2')
.replace(/\([^)]*\)/g, '') || undefined;
return new StackFrame({
functionName,
fileName: locationParts[0],
lineNumber: locationParts[1],
columnNumber: locationParts[2],
});
}, this);
},
};

View File

@@ -0,0 +1,203 @@
import { listenerHandler, RecordPlugin } from '../../../types';
import { stringify } from './stringify';
import { StackFrame, ErrorStackParser } from './error-stack-parser';
import { patch } from '../../../utils';
export type StringifyOptions = {
// limit of string length
stringLengthLimit?: number;
/**
* limit of number of keys in an object
* if an object contains more keys than this limit, we would call its toString function directly
*/
numOfKeysLimit: number;
};
type LogRecordOptions = {
level?: LogLevel[] | undefined;
lengthThreshold?: number;
stringifyOptions?: StringifyOptions;
logger?: Logger;
};
const defaultLogOptions: LogRecordOptions = {
level: [
'assert',
'clear',
'count',
'countReset',
'debug',
'dir',
'dirxml',
'error',
'group',
'groupCollapsed',
'groupEnd',
'info',
'log',
'table',
'time',
'timeEnd',
'timeLog',
'trace',
'warn',
],
lengthThreshold: 1000,
logger: console,
};
export type LogData = {
level: LogLevel;
trace: string[];
payload: string[];
};
type logCallback = (p: LogData) => void;
export type LogLevel =
| 'assert'
| 'clear'
| 'count'
| 'countReset'
| 'debug'
| 'dir'
| 'dirxml'
| 'error'
| 'group'
| 'groupCollapsed'
| 'groupEnd'
| 'info'
| 'log'
| 'table'
| 'time'
| 'timeEnd'
| 'timeLog'
| 'trace'
| 'warn';
/* fork from interface Console */
// all kinds of console functions
export type Logger = {
assert?: typeof console.assert;
clear?: typeof console.clear;
count?: typeof console.count;
countReset?: typeof console.countReset;
debug?: typeof console.debug;
dir?: typeof console.dir;
dirxml?: typeof console.dirxml;
error?: typeof console.error;
group?: typeof console.group;
groupCollapsed?: typeof console.groupCollapsed;
groupEnd?: () => void;
info?: typeof console.info;
log?: typeof console.log;
table?: typeof console.table;
time?: typeof console.time;
timeEnd?: typeof console.timeEnd;
timeLog?: typeof console.timeLog;
trace?: typeof console.trace;
warn?: typeof console.warn;
};
function initLogObserver(
cb: logCallback,
logOptions: LogRecordOptions,
): listenerHandler {
const logger = logOptions.logger;
if (!logger) {
return () => {};
}
let logCount = 0;
const cancelHandlers: listenerHandler[] = [];
// add listener to thrown errors
if (logOptions.level!.includes('error')) {
if (window) {
const originalOnError = window.onerror;
window.onerror = (
msg: Event | string,
file: string,
line: number,
col: number,
error: Error,
) => {
if (originalOnError) {
originalOnError.apply(this, [msg, file, line, col, error]);
}
const trace: string[] = ErrorStackParser.parse(
error,
).map((stackFrame: StackFrame) => stackFrame.toString());
const payload = [stringify(msg, logOptions.stringifyOptions)];
cb({
level: 'error',
trace,
payload,
});
};
cancelHandlers.push(() => {
window.onerror = originalOnError;
});
}
}
for (const levelType of logOptions.level!) {
cancelHandlers.push(replace(logger, levelType));
}
return () => {
cancelHandlers.forEach((h) => h());
};
/**
* replace the original console function and record logs
* @param logger the logger object such as Console
* @param level the name of log function to be replaced
*/
function replace(_logger: Logger, level: LogLevel) {
if (!_logger[level]) {
return () => {};
}
// replace the logger.{level}. return a restore function
return patch(_logger, level, (original) => {
return (...args: Array<unknown>) => {
original.apply(this, args);
try {
const trace = ErrorStackParser.parse(new Error())
.map((stackFrame: StackFrame) => stackFrame.toString())
.splice(1); // splice(1) to omit the hijacked log function
const payload = args.map((s) =>
stringify(s, logOptions.stringifyOptions),
);
logCount++;
if (logCount < logOptions.lengthThreshold!) {
cb({
level,
trace,
payload,
});
} else if (logCount === logOptions.lengthThreshold) {
// notify the user
cb({
level: 'warn',
trace: [],
payload: [
stringify('The number of log records reached the threshold.'),
],
});
}
} catch (error) {
original('rrweb logger error:', error, ...args);
}
};
});
}
}
export const PLUGIN_NAME = 'rrweb/console@1';
export const getRecordConsolePlugin: (
options?: LogRecordOptions,
) => RecordPlugin = (options) => ({
name: PLUGIN_NAME,
observer: initLogObserver,
options: options
? Object.assign({}, defaultLogOptions, options)
: defaultLogOptions,
});

View File

@@ -0,0 +1,143 @@
// tslint:disable:no-any no-bitwise forin
/**
* this file is used to serialize log message to string
*
*/
import { StringifyOptions } from './index';
/**
* transfer the node path in Event to string
* @param node the first node in a node path array
*/
function pathToSelector(node: HTMLElement): string | '' {
if (!node || !node.outerHTML) {
return '';
}
let path = '';
while (node.parentElement) {
let name = node.localName;
if (!name) {
break;
}
name = name.toLowerCase();
let parent = node.parentElement;
let domSiblings = [];
if (parent.children && parent.children.length > 0) {
// tslint:disable-next-line:prefer-for-of
for (let i = 0; i < parent.children.length; i++) {
let sibling = parent.children[i];
if (sibling.localName && sibling.localName.toLowerCase) {
if (sibling.localName.toLowerCase() === name) {
domSiblings.push(sibling);
}
}
}
}
if (domSiblings.length > 1) {
name += ':eq(' + domSiblings.indexOf(node) + ')';
}
path = name + (path ? '>' + path : '');
node = parent;
}
return path;
}
/**
* stringify any js object
* @param obj the object to stringify
*/
export function stringify(
obj: any,
stringifyOptions?: StringifyOptions,
): string {
const options: StringifyOptions = {
numOfKeysLimit: 50,
};
Object.assign(options, stringifyOptions);
const stack: any[] = [];
const keys: any[] = [];
return JSON.stringify(obj, function (key, value) {
/**
* forked from https://github.com/moll/json-stringify-safe/blob/master/stringify.js
* to deCycle the object
*/
if (stack.length > 0) {
const thisPos = stack.indexOf(this);
~thisPos ? stack.splice(thisPos + 1) : stack.push(this);
~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key);
if (~stack.indexOf(value)) {
if (stack[0] === value) {
value = '[Circular ~]';
} else {
value =
'[Circular ~.' +
keys.slice(0, stack.indexOf(value)).join('.') +
']';
}
}
} else {
stack.push(value);
}
/* END of the FORK */
if (value === null || value === undefined) {
return value;
}
if (shouldToString(value)) {
return toString(value);
}
if (value instanceof Event) {
const eventResult: any = {};
for (const eventKey in value) {
const eventValue = (value as any)[eventKey];
if (Array.isArray(eventValue)) {
eventResult[eventKey] = pathToSelector(
eventValue.length ? eventValue[0] : null,
);
} else {
eventResult[eventKey] = eventValue;
}
}
return eventResult;
} else if (value instanceof Node) {
if (value instanceof HTMLElement) {
return value ? value.outerHTML : '';
}
return value.nodeName;
}
return value;
});
/**
* whether we should call toString function of this object
*/
function shouldToString(_obj: object): boolean {
if (
typeof _obj === 'object' &&
Object.keys(_obj).length > options.numOfKeysLimit
) {
return true;
}
if (typeof _obj === 'function') {
return true;
}
return false;
}
/**
* limit the toString() result according to option
*/
function toString(_obj: object): string {
let str = _obj.toString();
if (options.stringLengthLimit && str.length > options.stringLengthLimit) {
str = `${str.slice(0, options.stringLengthLimit)}...`;
}
return str;
}
}

View File

@@ -0,0 +1,144 @@
import { LogLevel, LogData, PLUGIN_NAME } from '../record';
import {
eventWithTime,
EventType,
IncrementalSource,
ReplayPlugin,
} from '../../../types';
/**
* define an interface to replay log records
* (data: logData) => void> function to display the log data
*/
type ReplayLogger = Partial<Record<LogLevel, (data: LogData) => void>>;
type LogReplayConfig = {
level?: LogLevel[] | undefined;
replayLogger: ReplayLogger | undefined;
};
const ORIGINAL_ATTRIBUTE_NAME = '__rrweb_original__';
type PatchedConsoleLog = {
[ORIGINAL_ATTRIBUTE_NAME]: typeof console.log;
};
const defaultLogConfig: LogReplayConfig = {
level: [
'assert',
'clear',
'count',
'countReset',
'debug',
'dir',
'dirxml',
'error',
'group',
'groupCollapsed',
'groupEnd',
'info',
'log',
'table',
'time',
'timeEnd',
'timeLog',
'trace',
'warn',
],
replayLogger: undefined,
};
class LogReplayPlugin {
private config: LogReplayConfig;
constructor(config?: LogReplayConfig) {
this.config = Object.assign(defaultLogConfig, config);
}
/**
* generate a console log replayer which implement the interface ReplayLogger
*/
public getConsoleLogger(): ReplayLogger {
const replayLogger: ReplayLogger = {};
for (const level of this.config.level!) {
if (level === 'trace') {
replayLogger[level] = (data: LogData) => {
const logger = ((console.log as unknown) as PatchedConsoleLog)[
ORIGINAL_ATTRIBUTE_NAME
]
? ((console.log as unknown) as PatchedConsoleLog)[
ORIGINAL_ATTRIBUTE_NAME
]
: console.log;
logger(
...data.payload.map((s) => JSON.parse(s)),
this.formatMessage(data),
);
};
} else {
replayLogger[level] = (data: LogData) => {
const logger = ((console[level] as unknown) as PatchedConsoleLog)[
ORIGINAL_ATTRIBUTE_NAME
]
? ((console[level] as unknown) as PatchedConsoleLog)[
ORIGINAL_ATTRIBUTE_NAME
]
: console[level];
logger(
...data.payload.map((s) => JSON.parse(s)),
this.formatMessage(data),
);
};
}
}
return replayLogger;
}
/**
* format the trace data to a string
* @param data the log data
*/
private formatMessage(data: LogData): string {
if (data.trace.length === 0) {
return '';
}
const stackPrefix = '\n\tat ';
let result = stackPrefix;
result += data.trace.join(stackPrefix);
return result;
}
}
export const getReplayConsolePlugin: (
options?: LogReplayConfig,
) => ReplayPlugin = (options) => {
const replayLogger =
options?.replayLogger || new LogReplayPlugin(options).getConsoleLogger();
return {
handler(event: eventWithTime, _isSync, context) {
let logData: LogData | null = null;
if (
event.type === EventType.IncrementalSnapshot &&
event.data.source === (IncrementalSource.Log as IncrementalSource)
) {
logData = (event.data as unknown) as LogData;
} else if (
event.type === EventType.Plugin &&
event.data.plugin === PLUGIN_NAME
) {
logData = event.data.payload as LogData;
}
if (logData) {
try {
if (typeof replayLogger[logData.level] === 'function') {
replayLogger[logData.level]!(logData);
}
} catch (error) {
if (context.replayer.config.showWarning) {
console.warn(error);
}
}
}
},
};
};

View File

@@ -0,0 +1,37 @@
import { serializedNodeWithId, INode } from 'rrweb-snapshot';
import { mutationCallBack } from '../types';
export class IframeManager {
private iframes: WeakMap<HTMLIFrameElement, true> = new WeakMap();
private mutationCb: mutationCallBack;
private loadListener?: (iframeEl: HTMLIFrameElement) => unknown;
constructor(options: { mutationCb: mutationCallBack }) {
this.mutationCb = options.mutationCb;
}
public addIframe(iframeEl: HTMLIFrameElement) {
this.iframes.set(iframeEl, true);
}
public addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown) {
this.loadListener = cb;
}
public attachIframe(iframeEl: INode, childSn: serializedNodeWithId) {
this.mutationCb({
adds: [
{
parentId: iframeEl.__sn.id,
nextId: null,
node: childSn,
},
],
removes: [],
texts: [],
attributes: [],
isAttachIframe: true,
});
this.loadListener?.((iframeEl as unknown) as HTMLIFrameElement);
}
}

View File

@@ -0,0 +1,480 @@
import { snapshot, MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot';
import { initObservers, mutationBuffers } from './observer';
import {
on,
getWindowWidth,
getWindowHeight,
polyfill,
isIframeINode,
hasShadowRoot,
createMirror,
} from '../utils';
import {
EventType,
event,
eventWithTime,
recordOptions,
IncrementalSource,
listenerHandler,
mutationCallbackParam,
scrollCallback,
} from '../types';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
function wrapEvent(e: event): eventWithTime {
return {
...e,
timestamp: Date.now(),
};
}
let wrappedEmit!: (e: eventWithTime, isCheckout?: boolean) => void;
let takeFullSnapshot!: (isCheckout?: boolean) => void;
const mirror = createMirror();
function record<T = eventWithTime>(
options: recordOptions<T> = {},
): listenerHandler | undefined {
const {
emit,
checkoutEveryNms,
checkoutEveryNth,
blockClass = 'rr-block',
blockSelector = null,
ignoreClass = 'rr-ignore',
maskTextClass = 'rr-mask',
maskTextSelector = null,
inlineStylesheet = true,
maskAllInputs,
maskInputOptions: _maskInputOptions,
slimDOMOptions: _slimDOMOptions,
maskInputFn,
maskTextFn,
hooks,
packFn,
sampling = {},
mousemoveWait,
recordCanvas = false,
userTriggeredOnInput = false,
collectFonts = false,
plugins,
keepIframeSrcFn = () => false,
} = options;
// runtime checks for user options
if (!emit) {
throw new Error('emit function is required');
}
// move departed options to new options
if (mousemoveWait !== undefined && sampling.mousemove === undefined) {
sampling.mousemove = mousemoveWait;
}
const maskInputOptions: MaskInputOptions =
maskAllInputs === true
? {
color: true,
date: true,
'datetime-local': true,
email: true,
month: true,
number: true,
range: true,
search: true,
tel: true,
text: true,
time: true,
url: true,
week: true,
textarea: true,
select: true,
password: true,
}
: _maskInputOptions !== undefined
? _maskInputOptions
: { password: true };
const slimDOMOptions: SlimDOMOptions =
_slimDOMOptions === true || _slimDOMOptions === 'all'
? {
script: true,
comment: true,
headFavicon: true,
headWhitespace: true,
headMetaSocial: true,
headMetaRobots: true,
headMetaHttpEquiv: true,
headMetaVerification: true,
// the following are off for slimDOMOptions === true,
// as they destroy some (hidden) info:
headMetaAuthorship: _slimDOMOptions === 'all',
headMetaDescKeywords: _slimDOMOptions === 'all',
}
: _slimDOMOptions
? _slimDOMOptions
: {};
polyfill();
let lastFullSnapshotEvent: eventWithTime;
let incrementalSnapshotCount = 0;
wrappedEmit = (e: eventWithTime, isCheckout?: boolean) => {
if (
mutationBuffers[0]?.isFrozen() &&
e.type !== EventType.FullSnapshot &&
!(
e.type === EventType.IncrementalSnapshot &&
e.data.source === IncrementalSource.Mutation
)
) {
// we've got a user initiated event so first we need to apply
// all DOM changes that have been buffering during paused state
mutationBuffers.forEach((buf) => buf.unfreeze());
}
emit(((packFn ? packFn(e) : e) as unknown) as T, isCheckout);
if (e.type === EventType.FullSnapshot) {
lastFullSnapshotEvent = e;
incrementalSnapshotCount = 0;
} else if (e.type === EventType.IncrementalSnapshot) {
// attach iframe should be considered as full snapshot
if (
e.data.source === IncrementalSource.Mutation &&
e.data.isAttachIframe
) {
return;
}
incrementalSnapshotCount++;
const exceedCount =
checkoutEveryNth && incrementalSnapshotCount >= checkoutEveryNth;
const exceedTime =
checkoutEveryNms &&
e.timestamp - lastFullSnapshotEvent.timestamp > checkoutEveryNms;
if (exceedCount || exceedTime) {
takeFullSnapshot(true);
}
}
};
const wrappedMutationEmit = (m: mutationCallbackParam) => {
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
...m,
},
}),
);
};
const wrappedScrollEmit: scrollCallback = (p) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Scroll,
...p,
},
}),
);
const iframeManager = new IframeManager({
mutationCb: wrappedMutationEmit,
});
const shadowDomManager = new ShadowDomManager({
mutationCb: wrappedMutationEmit,
scrollCb: wrappedScrollEmit,
bypassOptions: {
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
inlineStylesheet,
maskInputOptions,
maskTextFn,
maskInputFn,
recordCanvas,
sampling,
slimDOMOptions,
iframeManager,
},
mirror,
});
takeFullSnapshot = (isCheckout = false) => {
wrappedEmit(
wrapEvent({
type: EventType.Meta,
data: {
href: window.location.href,
width: getWindowWidth(),
height: getWindowHeight(),
},
}),
isCheckout,
);
mutationBuffers.forEach((buf) => buf.lock()); // don't allow any mirror modifications during snapshotting
const [node, idNodeMap] = snapshot(document, {
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
inlineStylesheet,
maskAllInputs: maskInputOptions,
maskTextFn,
slimDOM: slimDOMOptions,
recordCanvas,
onSerialize: (n) => {
if (isIframeINode(n)) {
iframeManager.addIframe(n);
}
if (hasShadowRoot(n)) {
shadowDomManager.addShadowRoot(n.shadowRoot, document);
}
},
onIframeLoad: (iframe, childSn) => {
iframeManager.attachIframe(iframe, childSn);
},
keepIframeSrcFn,
});
if (!node) {
return console.warn('Failed to snapshot the document');
}
mirror.map = idNodeMap;
wrappedEmit(
wrapEvent({
type: EventType.FullSnapshot,
data: {
node,
initialOffset: {
left:
window.pageXOffset !== undefined
? window.pageXOffset
: document?.documentElement.scrollLeft ||
document?.body?.parentElement?.scrollLeft ||
document?.body.scrollLeft ||
0,
top:
window.pageYOffset !== undefined
? window.pageYOffset
: document?.documentElement.scrollTop ||
document?.body?.parentElement?.scrollTop ||
document?.body.scrollTop ||
0,
},
},
}),
);
mutationBuffers.forEach((buf) => buf.unlock()); // generate & emit any mutations that happened during snapshotting, as can now apply against the newly built mirror
};
try {
const handlers: listenerHandler[] = [];
handlers.push(
on('DOMContentLoaded', () => {
wrappedEmit(
wrapEvent({
type: EventType.DomContentLoaded,
data: {},
}),
);
}),
);
const observe = (doc: Document) => {
return initObservers(
{
mutationCb: wrappedMutationEmit,
mousemoveCb: (positions, source) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source,
positions,
},
}),
),
mouseInteractionCb: (d) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.MouseInteraction,
...d,
},
}),
),
scrollCb: wrappedScrollEmit,
viewportResizeCb: (d) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.ViewportResize,
...d,
},
}),
),
inputCb: (v) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Input,
...v,
},
}),
),
mediaInteractionCb: (p) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.MediaInteraction,
...p,
},
}),
),
styleSheetRuleCb: (r) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleSheetRule,
...r,
},
}),
),
canvasMutationCb: (p) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.CanvasMutation,
...p,
},
}),
),
fontCb: (p) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Font,
...p,
},
}),
),
blockClass,
ignoreClass,
maskTextClass,
maskTextSelector,
maskInputOptions,
inlineStylesheet,
sampling,
recordCanvas,
userTriggeredOnInput,
collectFonts,
doc,
maskInputFn,
maskTextFn,
blockSelector,
slimDOMOptions,
mirror,
iframeManager,
shadowDomManager,
plugins:
plugins?.map((p) => ({
observer: p.observer,
options: p.options,
callback: (payload: object) =>
wrappedEmit(
wrapEvent({
type: EventType.Plugin,
data: {
plugin: p.name,
payload,
},
}),
),
})) || [],
},
hooks,
);
};
iframeManager.addLoadListener((iframeEl) => {
handlers.push(observe(iframeEl.contentDocument!));
});
const init = () => {
takeFullSnapshot();
handlers.push(observe(document));
};
if (
document.readyState === 'interactive' ||
document.readyState === 'complete'
) {
init();
} else {
handlers.push(
on(
'load',
() => {
wrappedEmit(
wrapEvent({
type: EventType.Load,
data: {},
}),
);
init();
},
window,
),
);
}
return () => {
handlers.forEach((h) => h());
};
} catch (error) {
// TODO: handle internal error
console.warn(error);
}
}
record.addCustomEvent = <T>(tag: string, payload: T) => {
if (!wrappedEmit) {
throw new Error('please add custom event after start recording');
}
wrappedEmit(
wrapEvent({
type: EventType.Custom,
data: {
tag,
payload,
},
}),
);
};
record.freezePage = () => {
mutationBuffers.forEach((buf) => buf.freeze());
};
record.takeFullSnapshot = (isCheckout?: boolean) => {
if (!takeFullSnapshot) {
throw new Error('please take full snapshot after start recording');
}
takeFullSnapshot(isCheckout);
};
record.mirror = mirror;
export default record;

View File

@@ -0,0 +1,641 @@
import {
INode,
serializeNodeWithId,
transformAttribute,
MaskInputOptions,
SlimDOMOptions,
IGNORED_NODE,
isShadowRoot,
needMaskingText,
maskInputValue,
MaskTextFn,
MaskInputFn,
} from 'rrweb-snapshot';
import {
mutationRecord,
blockClass,
maskTextClass,
mutationCallBack,
textCursor,
attributeCursor,
removedNodeMutation,
addedNodeMutation,
Mirror,
styleAttributeValue,
} from '../types';
import {
isBlocked,
isAncestorRemoved,
isIgnored,
isIframeINode,
hasShadowRoot,
} from '../utils';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
type DoubleLinkedListNode = {
previous: DoubleLinkedListNode | null;
next: DoubleLinkedListNode | null;
value: NodeInLinkedList;
};
type NodeInLinkedList = Node & {
__ln: DoubleLinkedListNode;
};
function isNodeInLinkedList(n: Node | NodeInLinkedList): n is NodeInLinkedList {
return '__ln' in n;
}
class DoubleLinkedList {
public length = 0;
public head: DoubleLinkedListNode | null = null;
public get(position: number) {
if (position >= this.length) {
throw new Error('Position outside of list range');
}
let current = this.head;
for (let index = 0; index < position; index++) {
current = current?.next || null;
}
return current;
}
public addNode(n: Node) {
const node: DoubleLinkedListNode = {
value: n as NodeInLinkedList,
previous: null,
next: null,
};
(n as NodeInLinkedList).__ln = node;
if (n.previousSibling && isNodeInLinkedList(n.previousSibling)) {
const current = n.previousSibling.__ln.next;
node.next = current;
node.previous = n.previousSibling.__ln;
n.previousSibling.__ln.next = node;
if (current) {
current.previous = node;
}
} else if (
n.nextSibling &&
isNodeInLinkedList(n.nextSibling) &&
n.nextSibling.__ln.previous
) {
const current = n.nextSibling.__ln.previous;
node.previous = current;
node.next = n.nextSibling.__ln;
n.nextSibling.__ln.previous = node;
if (current) {
current.next = node;
}
} else {
if (this.head) {
this.head.previous = node;
}
node.next = this.head;
this.head = node;
}
this.length++;
}
public removeNode(n: NodeInLinkedList) {
const current = n.__ln;
if (!this.head) {
return;
}
if (!current.previous) {
this.head = current.next;
if (this.head) {
this.head.previous = null;
}
} else {
current.previous.next = current.next;
if (current.next) {
current.next.previous = current.previous;
}
}
if (n.__ln) {
delete n.__ln;
}
this.length--;
}
}
const moveKey = (id: number, parentId: number) => `${id}@${parentId}`;
function isINode(n: Node | INode): n is INode {
return '__sn' in n;
}
/**
* controls behaviour of a MutationObserver
*/
export default class MutationBuffer {
private frozen: boolean = false;
private locked: boolean = false;
private texts: textCursor[] = [];
private attributes: attributeCursor[] = [];
private removes: removedNodeMutation[] = [];
private mapRemoves: Node[] = [];
private movedMap: Record<string, true> = {};
/**
* the browser MutationObserver emits multiple mutations after
* a delay for performance reasons, making tracing added nodes hard
* in our `processMutations` callback function.
* 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 to trace child nodes of newly added nodes, 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 second mutation record which was
* duplicated.
* To avoid of duplicate counting added nodes, we use a Set to store
* added nodes and its child nodes during iterate mutation records. Then
* collect added nodes from the Set which have no duplicate copy. But
* this also causes newly added nodes will not be serialized with id ASAP,
* which means all the id related calculation should be lazy too.
*/
private addedSet = new Set<Node>();
private movedSet = new Set<Node>();
private droppedSet = new Set<Node>();
private emissionCallback: mutationCallBack;
private blockClass: blockClass;
private blockSelector: string | null;
private maskTextClass: maskTextClass;
private maskTextSelector: string | null;
private inlineStylesheet: boolean;
private maskInputOptions: MaskInputOptions;
private maskTextFn: MaskTextFn | undefined;
private maskInputFn: MaskInputFn | undefined;
private recordCanvas: boolean;
private slimDOMOptions: SlimDOMOptions;
private doc: Document;
private mirror: Mirror;
private iframeManager: IframeManager;
private shadowDomManager: ShadowDomManager;
public init(
cb: mutationCallBack,
blockClass: blockClass,
blockSelector: string | null,
maskTextClass: maskTextClass,
maskTextSelector: string | null,
inlineStylesheet: boolean,
maskInputOptions: MaskInputOptions,
maskTextFn: MaskTextFn | undefined,
maskInputFn: MaskInputFn | undefined,
recordCanvas: boolean,
slimDOMOptions: SlimDOMOptions,
doc: Document,
mirror: Mirror,
iframeManager: IframeManager,
shadowDomManager: ShadowDomManager,
) {
this.blockClass = blockClass;
this.blockSelector = blockSelector;
this.maskTextClass = maskTextClass;
this.maskTextSelector = maskTextSelector;
this.inlineStylesheet = inlineStylesheet;
this.maskInputOptions = maskInputOptions;
this.maskTextFn = maskTextFn;
this.maskInputFn = maskInputFn;
this.recordCanvas = recordCanvas;
this.slimDOMOptions = slimDOMOptions;
this.emissionCallback = cb;
this.doc = doc;
this.mirror = mirror;
this.iframeManager = iframeManager;
this.shadowDomManager = shadowDomManager;
}
public freeze() {
this.frozen = true;
}
public unfreeze() {
this.frozen = false;
this.emit();
}
public isFrozen() {
return this.frozen;
}
public lock() {
this.locked = true;
}
public unlock() {
this.locked = false;
this.emit();
}
public processMutations = (mutations: mutationRecord[]) => {
mutations.forEach(this.processMutation);
this.emit();
};
public emit = () => {
if (this.frozen || this.locked) {
return;
}
// delay any modification of the mirror until this function
// so that the mirror for takeFullSnapshot doesn't get mutated while it's event is being processed
const adds: addedNodeMutation[] = [];
/**
* Sometimes child node may be pushed before its newly added
* parent, so we init a queue to store these nodes.
*/
const addList = new DoubleLinkedList();
const getNextId = (n: Node): number | null => {
let ns: Node | null = n;
let nextId: number | null = IGNORED_NODE; // slimDOM: ignored
while (nextId === IGNORED_NODE) {
ns = ns && ns.nextSibling;
nextId = ns && this.mirror.getId((ns as unknown) as INode);
}
if (nextId === -1 && isBlocked(n.nextSibling, this.blockClass)) {
nextId = null;
}
return nextId;
};
const pushAdd = (n: Node) => {
const shadowHost: Element | null = n.getRootNode
? (n.getRootNode() as ShadowRoot)?.host
: null;
const notInDoc = !this.doc.contains(n) && !this.doc.contains(shadowHost);
if (!n.parentNode || notInDoc) {
return;
}
const parentId = isShadowRoot(n.parentNode)
? this.mirror.getId((shadowHost as unknown) as INode)
: this.mirror.getId((n.parentNode as Node) as INode);
const nextId = getNextId(n);
if (parentId === -1 || nextId === -1) {
return addList.addNode(n);
}
let sn = serializeNodeWithId(n, {
doc: this.doc,
map: this.mirror.map,
blockClass: this.blockClass,
blockSelector: this.blockSelector,
maskTextClass: this.maskTextClass,
maskTextSelector: this.maskTextSelector,
skipChild: true,
inlineStylesheet: this.inlineStylesheet,
maskInputOptions: this.maskInputOptions,
maskTextFn: this.maskTextFn,
maskInputFn: this.maskInputFn,
slimDOMOptions: this.slimDOMOptions,
recordCanvas: this.recordCanvas,
onSerialize: (currentN) => {
if (isIframeINode(currentN)) {
this.iframeManager.addIframe(currentN);
}
if (hasShadowRoot(n)) {
this.shadowDomManager.addShadowRoot(n.shadowRoot, document);
}
},
onIframeLoad: (iframe, childSn) => {
this.iframeManager.attachIframe(iframe, childSn);
},
});
if (sn) {
adds.push({
parentId,
nextId,
node: sn,
});
}
};
while (this.mapRemoves.length) {
this.mirror.removeNodeFromMap(this.mapRemoves.shift() as INode);
}
for (const n of this.movedSet) {
if (
isParentRemoved(this.removes, n, this.mirror) &&
!this.movedSet.has(n.parentNode!)
) {
continue;
}
pushAdd(n);
}
for (const n of this.addedSet) {
if (
!isAncestorInSet(this.droppedSet, n) &&
!isParentRemoved(this.removes, n, this.mirror)
) {
pushAdd(n);
} else if (isAncestorInSet(this.movedSet, n)) {
pushAdd(n);
} else {
this.droppedSet.add(n);
}
}
let candidate: DoubleLinkedListNode | null = null;
while (addList.length) {
let node: DoubleLinkedListNode | null = null;
if (candidate) {
const parentId = this.mirror.getId(
(candidate.value.parentNode as Node) as INode,
);
const nextId = getNextId(candidate.value);
if (parentId !== -1 && nextId !== -1) {
node = candidate;
}
}
if (!node) {
for (let index = addList.length - 1; index >= 0; index--) {
const _node = addList.get(index)!;
const parentId = this.mirror.getId(
(_node.value.parentNode as Node) as INode,
);
const nextId = getNextId(_node.value);
if (parentId !== -1 && nextId !== -1) {
node = _node;
break;
}
}
}
if (!node) {
/**
* 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.
*/
while (addList.head) {
addList.removeNode(addList.head.value);
}
break;
}
candidate = node.previous;
addList.removeNode(node.value);
pushAdd(node.value);
}
const payload = {
texts: this.texts
.map((text) => ({
id: this.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) => this.mirror.has(text.id)),
attributes: this.attributes
.map((attribute) => ({
id: this.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) => this.mirror.has(attribute.id)),
removes: this.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;
}
// reset
this.texts = [];
this.attributes = [];
this.removes = [];
this.addedSet = new Set<Node>();
this.movedSet = new Set<Node>();
this.droppedSet = new Set<Node>();
this.movedMap = {};
this.emissionCallback(payload);
};
private processMutation = (m: mutationRecord) => {
if (isIgnored(m.target)) {
return;
}
switch (m.type) {
case 'characterData': {
const value = m.target.textContent;
if (!isBlocked(m.target, this.blockClass) && value !== m.oldValue) {
this.texts.push({
value:
needMaskingText(
m.target,
this.maskTextClass,
this.maskTextSelector,
) && value
? this.maskTextFn
? this.maskTextFn(value)
: value.replace(/[\S]/g, '*')
: value,
node: m.target,
});
}
break;
}
case 'attributes': {
const target = m.target as HTMLElement;
let value = (m.target as HTMLElement).getAttribute(m.attributeName!);
if (m.attributeName === 'value') {
value = maskInputValue({
maskInputOptions: this.maskInputOptions,
tagName: (m.target as HTMLElement).tagName,
type: (m.target as HTMLElement).getAttribute('type'),
value,
maskInputFn: this.maskInputFn,
});
}
if (isBlocked(m.target, this.blockClass) || value === m.oldValue) {
return;
}
let item: attributeCursor | undefined = this.attributes.find(
(a) => a.node === m.target,
);
if (!item) {
item = {
node: m.target,
attributes: {},
};
this.attributes.push(item);
}
if (m.attributeName === 'style') {
const old = this.doc.createElement('span');
if (m.oldValue) {
old.setAttribute('style', m.oldValue);
}
if (
item.attributes['style'] === undefined ||
item.attributes['style'] === null
) {
item.attributes['style'] = {};
}
const styleObj = item.attributes['style'] as styleAttributeValue;
for (let i = 0; i < target.style.length; i++) {
let pname = target.style[i];
const newValue = target.style.getPropertyValue(pname);
const newPriority = target.style.getPropertyPriority(pname);
if (
newValue != old.style.getPropertyValue(pname) ||
newPriority != old.style.getPropertyPriority(pname)
) {
if (newPriority == '') {
styleObj[pname] = newValue;
} else {
styleObj[pname] = [newValue, newPriority];
}
}
}
for (let i = 0; i < old.style.length; i++) {
let pname = old.style[i];
if (
target.style.getPropertyValue(pname) === '' ||
!target.style.getPropertyValue(pname) // covering potential non-standard browsers
) {
styleObj[pname] = false; // delete
}
}
} else {
// overwrite attribute if the mutations was triggered in same time
item.attributes[m.attributeName!] = transformAttribute(
this.doc,
(m.target as HTMLElement).tagName,
m.attributeName!,
value!,
);
}
break;
}
case 'childList': {
m.addedNodes.forEach((n) => this.genAdds(n, m.target));
m.removedNodes.forEach((n) => {
const nodeId = this.mirror.getId(n as INode);
const parentId = isShadowRoot(m.target)
? this.mirror.getId((m.target.host as unknown) as INode)
: this.mirror.getId(m.target as INode);
if (
isBlocked(n, this.blockClass) ||
isBlocked(m.target, this.blockClass) ||
isIgnored(n)
) {
return;
}
// removed node has not been serialized yet, just remove it from the Set
if (this.addedSet.has(n)) {
deepDelete(this.addedSet, n);
this.droppedSet.add(n);
} else if (this.addedSet.has(m.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(m.target as INode, this.mirror)) {
/**
* 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 (
this.movedSet.has(n) &&
this.movedMap[moveKey(nodeId, parentId)]
) {
deepDelete(this.movedSet, n);
} else {
this.removes.push({
parentId,
id: nodeId,
isShadow: isShadowRoot(m.target) ? true : undefined,
});
}
this.mapRemoves.push(n);
});
break;
}
default:
break;
}
};
private genAdds = (n: Node | INode, target?: Node | INode) => {
if (isBlocked(n, this.blockClass)) {
return;
}
if (target && isBlocked(target, this.blockClass)) {
return;
}
if (isINode(n)) {
if (isIgnored(n)) {
return;
}
this.movedSet.add(n);
let targetId: number | null = null;
if (target && isINode(target)) {
targetId = target.__sn.id;
}
if (targetId) {
this.movedMap[moveKey(n.__sn.id, targetId)] = true;
}
} else {
this.addedSet.add(n);
this.droppedSet.delete(n);
}
n.childNodes.forEach((childN) => this.genAdds(childN));
};
}
/**
* Some utils to handle the mutation observer DOM records.
* It should be more clear to extend the native data structure
* like Set and Map, but currently Typescript does not support
* that.
*/
function deepDelete(addsSet: Set<Node>, n: Node) {
addsSet.delete(n);
n.childNodes.forEach((childN) => deepDelete(addsSet, childN));
}
function isParentRemoved(
removes: removedNodeMutation[],
n: Node,
mirror: Mirror,
): boolean {
const { parentNode } = n;
if (!parentNode) {
return false;
}
const parentId = mirror.getId((parentNode as Node) as INode);
if (removes.some((r) => r.id === parentId)) {
return true;
}
return isParentRemoved(removes, parentNode, mirror);
}
function isAncestorInSet(set: Set<Node>, n: Node): boolean {
const { parentNode } = n;
if (!parentNode) {
return false;
}
if (set.has(parentNode)) {
return true;
}
return isAncestorInSet(set, parentNode);
}

View File

@@ -0,0 +1,824 @@
import {
INode,
MaskInputOptions,
SlimDOMOptions,
maskInputValue,
MaskInputFn,
MaskTextFn,
} from 'rrweb-snapshot';
import { FontFaceDescriptors, FontFaceSet } from 'css-font-loading-module';
import {
throttle,
on,
hookSetter,
getWindowHeight,
getWindowWidth,
isBlocked,
isTouchEvent,
patch,
} from '../utils';
import {
mutationCallBack,
observerParam,
mousemoveCallBack,
mousePosition,
mouseInteractionCallBack,
MouseInteractions,
listenerHandler,
scrollCallback,
styleSheetRuleCallback,
viewportResizeCallback,
inputValue,
inputCallback,
hookResetter,
blockClass,
maskTextClass,
IncrementalSource,
hooksParam,
Arguments,
mediaInteractionCallback,
MediaInteractions,
SamplingStrategy,
canvasMutationCallback,
fontCallback,
fontParam,
Mirror,
} from '../types';
import MutationBuffer from './mutation';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
type WindowWithStoredMutationObserver = Window & {
__rrMutationObserver?: MutationObserver;
};
type WindowWithAngularZone = Window & {
Zone?: {
__symbol__?: (key: string) => string;
};
};
export const mutationBuffers: MutationBuffer[] = [];
function getEventTarget(event: Event): EventTarget | null {
try {
if ('composedPath' in event) {
const path = event.composedPath();
if (path.length) {
return path[0];
}
} else if (
'path' in event &&
(event as { path: EventTarget[] }).path.length
) {
return (event as { path: EventTarget[] }).path[0];
}
return event.target;
} catch {
return event.target;
}
}
export function initMutationObserver(
cb: mutationCallBack,
doc: Document,
blockClass: blockClass,
blockSelector: string | null,
maskTextClass: maskTextClass,
maskTextSelector: string | null,
inlineStylesheet: boolean,
maskInputOptions: MaskInputOptions,
maskTextFn: MaskTextFn | undefined,
maskInputFn: MaskInputFn | undefined,
recordCanvas: boolean,
slimDOMOptions: SlimDOMOptions,
mirror: Mirror,
iframeManager: IframeManager,
shadowDomManager: ShadowDomManager,
rootEl: Node,
): MutationObserver {
const mutationBuffer = new MutationBuffer();
mutationBuffers.push(mutationBuffer);
// see mutation.ts for details
mutationBuffer.init(
cb,
blockClass,
blockSelector,
maskTextClass,
maskTextSelector,
inlineStylesheet,
maskInputOptions,
maskTextFn,
maskInputFn,
recordCanvas,
slimDOMOptions,
doc,
mirror,
iframeManager,
shadowDomManager,
);
let mutationObserverCtor =
window.MutationObserver ||
/**
* Some websites may disable MutationObserver by removing it from the window object.
* If someone is using rrweb to build a browser extention or things like it, they
* could not change the website's code but can have an opportunity to inject some
* code before the website executing its JS logic.
* Then they can do this to store the native MutationObserver:
* window.__rrMutationObserver = MutationObserver
*/
(window as WindowWithStoredMutationObserver).__rrMutationObserver;
const angularZoneSymbol = (window as WindowWithAngularZone)?.Zone?.__symbol__?.(
'MutationObserver',
);
if (
angularZoneSymbol &&
((window as unknown) as Record<string, typeof MutationObserver>)[
angularZoneSymbol
]
) {
mutationObserverCtor = ((window as unknown) as Record<
string,
typeof MutationObserver
>)[angularZoneSymbol];
}
const observer = new mutationObserverCtor(
mutationBuffer.processMutations.bind(mutationBuffer),
);
observer.observe(rootEl, {
attributes: true,
attributeOldValue: true,
characterData: true,
characterDataOldValue: true,
childList: true,
subtree: true,
});
return observer;
}
function initMoveObserver(
cb: mousemoveCallBack,
sampling: SamplingStrategy,
doc: Document,
mirror: Mirror,
): listenerHandler {
if (sampling.mousemove === false) {
return () => {};
}
const threshold =
typeof sampling.mousemove === 'number' ? sampling.mousemove : 50;
const callbackThreshold =
typeof sampling.mousemoveCallback === 'number'
? sampling.mousemoveCallback
: 500;
let positions: mousePosition[] = [];
let timeBaseline: number | null;
const wrappedCb = throttle(
(
source:
| IncrementalSource.MouseMove
| IncrementalSource.TouchMove
| IncrementalSource.Drag,
) => {
const totalOffset = Date.now() - timeBaseline!;
cb(
positions.map((p) => {
p.timeOffset -= totalOffset;
return p;
}),
source,
);
positions = [];
timeBaseline = null;
},
callbackThreshold,
);
const updatePosition = throttle<MouseEvent | TouchEvent | DragEvent>(
(evt) => {
const target = getEventTarget(evt);
const { clientX, clientY } = isTouchEvent(evt)
? evt.changedTouches[0]
: evt;
if (!timeBaseline) {
timeBaseline = Date.now();
}
positions.push({
x: clientX,
y: clientY,
id: mirror.getId(target as INode),
timeOffset: Date.now() - timeBaseline,
});
wrappedCb(
evt instanceof DragEvent
? IncrementalSource.Drag
: evt instanceof MouseEvent
? IncrementalSource.MouseMove
: IncrementalSource.TouchMove,
);
},
threshold,
{
trailing: false,
},
);
const handlers = [
on('mousemove', updatePosition, doc),
on('touchmove', updatePosition, doc),
on('drag', updatePosition, doc),
];
return () => {
handlers.forEach((h) => h());
};
}
function initMouseInteractionObserver(
cb: mouseInteractionCallBack,
doc: Document,
mirror: Mirror,
blockClass: blockClass,
sampling: SamplingStrategy,
): listenerHandler {
if (sampling.mouseInteraction === false) {
return () => {};
}
const disableMap: Record<string, boolean | undefined> =
sampling.mouseInteraction === true ||
sampling.mouseInteraction === undefined
? {}
: sampling.mouseInteraction;
const handlers: listenerHandler[] = [];
const getHandler = (eventKey: keyof typeof MouseInteractions) => {
return (event: MouseEvent | TouchEvent) => {
const target = getEventTarget(event) as Node;
if (isBlocked(target as Node, blockClass)) {
return;
}
const e = isTouchEvent(event) ? event.changedTouches[0] : event;
if (!e) {
return;
}
const id = mirror.getId(target as INode);
const { clientX, clientY } = e;
cb({
type: MouseInteractions[eventKey],
id,
x: clientX,
y: clientY,
});
};
};
Object.keys(MouseInteractions)
.filter(
(key) =>
Number.isNaN(Number(key)) &&
!key.endsWith('_Departed') &&
disableMap[key] !== false,
)
.forEach((eventKey: keyof typeof MouseInteractions) => {
const eventName = eventKey.toLowerCase();
const handler = getHandler(eventKey);
handlers.push(on(eventName, handler, doc));
});
return () => {
handlers.forEach((h) => h());
};
}
export function initScrollObserver(
cb: scrollCallback,
doc: Document,
mirror: Mirror,
blockClass: blockClass,
sampling: SamplingStrategy,
): listenerHandler {
const updatePosition = throttle<UIEvent>((evt) => {
const target = getEventTarget(evt);
if (!target || isBlocked(target as Node, blockClass)) {
return;
}
const id = mirror.getId(target as INode);
if (target === doc) {
const scrollEl = (doc.scrollingElement || doc.documentElement)!;
cb({
id,
x: scrollEl.scrollLeft,
y: scrollEl.scrollTop,
});
} else {
cb({
id,
x: (target as HTMLElement).scrollLeft,
y: (target as HTMLElement).scrollTop,
});
}
}, sampling.scroll || 100);
return on('scroll', updatePosition, doc);
}
function initViewportResizeObserver(
cb: viewportResizeCallback,
): listenerHandler {
let lastH = -1;
let lastW = -1;
const updateDimension = throttle(() => {
const height = getWindowHeight();
const width = getWindowWidth();
if (lastH !== height || lastW !== width) {
cb({
width: Number(width),
height: Number(height),
});
lastH = height;
lastW = width;
}
}, 200);
return on('resize', updateDimension, window);
}
function wrapEventWithUserTriggeredFlag(
v: inputValue,
enable: boolean,
): inputValue {
const value = { ...v };
if (!enable) delete value.userTriggered;
return value;
}
export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
function initInputObserver(
cb: inputCallback,
doc: Document,
mirror: Mirror,
blockClass: blockClass,
ignoreClass: string,
maskInputOptions: MaskInputOptions,
maskInputFn: MaskInputFn | undefined,
sampling: SamplingStrategy,
userTriggeredOnInput: boolean,
): listenerHandler {
function eventHandler(event: Event) {
const target = getEventTarget(event);
const userTriggered = event.isTrusted;
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 ((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 (
maskInputOptions[
(target as Element).tagName.toLowerCase() as keyof MaskInputOptions
] ||
maskInputOptions[type as keyof MaskInputOptions]
) {
text = maskInputValue({
maskInputOptions,
tagName: (target as HTMLElement).tagName,
type,
value: text,
maskInputFn,
});
}
cbWithDedup(
target,
wrapEventWithUserTriggeredFlag(
{ text, isChecked, userTriggered },
userTriggeredOnInput,
),
);
// 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) {
doc
.querySelectorAll(`input[type="radio"][name="${name}"]`)
.forEach((el) => {
if (el !== target) {
cbWithDedup(
el,
wrapEventWithUserTriggeredFlag(
{
text: (el as HTMLInputElement).value,
isChecked: !isChecked,
userTriggered: false,
},
userTriggeredOnInput,
),
);
}
});
}
}
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 events = sampling.input === 'last' ? ['change'] : ['input', 'change'];
const handlers: Array<
listenerHandler | hookResetter
> = events.map((eventName) => on(eventName, eventHandler, doc));
const propertyDescriptor = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
);
const hookProperties: Array<[HTMLElement, string]> = [
[HTMLInputElement.prototype, 'value'],
[HTMLInputElement.prototype, 'checked'],
[HTMLSelectElement.prototype, 'value'],
[HTMLTextAreaElement.prototype, 'value'],
// Some UI library use selectedIndex to set select value
[HTMLSelectElement.prototype, 'selectedIndex'],
];
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());
};
}
function initStyleSheetObserver(
cb: styleSheetRuleCallback,
mirror: Mirror,
): listenerHandler {
const insertRule = CSSStyleSheet.prototype.insertRule;
CSSStyleSheet.prototype.insertRule = function (rule: string, index?: number) {
const id = mirror.getId(this.ownerNode as INode);
if (id !== -1) {
cb({
id,
adds: [{ rule, index }],
});
}
return insertRule.apply(this, arguments);
};
const deleteRule = CSSStyleSheet.prototype.deleteRule;
CSSStyleSheet.prototype.deleteRule = function (index: number) {
const id = mirror.getId(this.ownerNode as INode);
if (id !== -1) {
cb({
id,
removes: [{ index }],
});
}
return deleteRule.apply(this, arguments);
};
return () => {
CSSStyleSheet.prototype.insertRule = insertRule;
CSSStyleSheet.prototype.deleteRule = deleteRule;
};
}
function initMediaInteractionObserver(
mediaInteractionCb: mediaInteractionCallback,
blockClass: blockClass,
mirror: Mirror,
): listenerHandler {
const handler = (type: MediaInteractions) => (event: Event) => {
const target = getEventTarget(event);
if (!target || isBlocked(target as Node, blockClass)) {
return;
}
mediaInteractionCb({
type,
id: mirror.getId(target as INode),
currentTime: (target as HTMLMediaElement).currentTime,
});
};
const handlers = [
on('play', handler(MediaInteractions.Play)),
on('pause', handler(MediaInteractions.Pause)),
on('seeked', handler(MediaInteractions.Seeked)),
];
return () => {
handlers.forEach((h) => h());
};
}
function initCanvasMutationObserver(
cb: canvasMutationCallback,
blockClass: blockClass,
mirror: Mirror,
): listenerHandler {
const props = Object.getOwnPropertyNames(CanvasRenderingContext2D.prototype);
const handlers: listenerHandler[] = [];
for (const prop of props) {
try {
if (
typeof CanvasRenderingContext2D.prototype[
prop as keyof CanvasRenderingContext2D
] !== 'function'
) {
continue;
}
const restoreHandler = patch(
CanvasRenderingContext2D.prototype,
prop,
function (original) {
return function (
this: CanvasRenderingContext2D,
...args: Array<unknown>
) {
if (!isBlocked(this.canvas, blockClass)) {
setTimeout(() => {
const recordArgs = [...args];
if (prop === 'drawImage') {
if (
recordArgs[0] &&
recordArgs[0] instanceof HTMLCanvasElement
) {
const canvas = recordArgs[0];
const ctx = canvas.getContext('2d');
let imgd = ctx?.getImageData(
0,
0,
canvas.width,
canvas.height,
);
let pix = imgd?.data;
recordArgs[0] = JSON.stringify(pix);
}
}
cb({
id: mirror.getId((this.canvas as unknown) as INode),
property: prop,
args: recordArgs,
});
}, 0);
}
return original.apply(this, args);
};
},
);
handlers.push(restoreHandler);
} catch {
const hookHandler = hookSetter<CanvasRenderingContext2D>(
CanvasRenderingContext2D.prototype,
prop,
{
set(v) {
cb({
id: mirror.getId((this.canvas as unknown) as INode),
property: prop,
args: [v],
setter: true,
});
},
},
);
handlers.push(hookHandler);
}
}
return () => {
handlers.forEach((h) => h());
};
}
function initFontObserver(cb: fontCallback): listenerHandler {
const handlers: listenerHandler[] = [];
const fontMap = new WeakMap<FontFace, fontParam>();
const originalFontFace = FontFace;
// tslint:disable-next-line: no-any
(window as any).FontFace = function FontFace(
family: string,
source: string | ArrayBufferView,
descriptors?: FontFaceDescriptors,
) {
const fontFace = new originalFontFace(family, source, descriptors);
fontMap.set(fontFace, {
family,
buffer: typeof source !== 'string',
descriptors,
fontSource:
typeof source === 'string'
? source
: // tslint:disable-next-line: no-any
JSON.stringify(Array.from(new Uint8Array(source as any))),
});
return fontFace;
};
const restoreHandler = patch(document.fonts, 'add', function (original) {
return function (this: FontFaceSet, fontFace: FontFace) {
setTimeout(() => {
const p = fontMap.get(fontFace);
if (p) {
cb(p);
fontMap.delete(fontFace);
}
}, 0);
return original.apply(this, [fontFace]);
};
});
handlers.push(() => {
// tslint:disable-next-line: no-any
(window as any).FonFace = originalFontFace;
});
handlers.push(restoreHandler);
return () => {
handlers.forEach((h) => h());
};
}
function mergeHooks(o: observerParam, hooks: hooksParam) {
const {
mutationCb,
mousemoveCb,
mouseInteractionCb,
scrollCb,
viewportResizeCb,
inputCb,
mediaInteractionCb,
styleSheetRuleCb,
canvasMutationCb,
fontCb,
} = o;
o.mutationCb = (...p: Arguments<mutationCallBack>) => {
if (hooks.mutation) {
hooks.mutation(...p);
}
mutationCb(...p);
};
o.mousemoveCb = (...p: Arguments<mousemoveCallBack>) => {
if (hooks.mousemove) {
hooks.mousemove(...p);
}
mousemoveCb(...p);
};
o.mouseInteractionCb = (...p: Arguments<mouseInteractionCallBack>) => {
if (hooks.mouseInteraction) {
hooks.mouseInteraction(...p);
}
mouseInteractionCb(...p);
};
o.scrollCb = (...p: Arguments<scrollCallback>) => {
if (hooks.scroll) {
hooks.scroll(...p);
}
scrollCb(...p);
};
o.viewportResizeCb = (...p: Arguments<viewportResizeCallback>) => {
if (hooks.viewportResize) {
hooks.viewportResize(...p);
}
viewportResizeCb(...p);
};
o.inputCb = (...p: Arguments<inputCallback>) => {
if (hooks.input) {
hooks.input(...p);
}
inputCb(...p);
};
o.mediaInteractionCb = (...p: Arguments<mediaInteractionCallback>) => {
if (hooks.mediaInteaction) {
hooks.mediaInteaction(...p);
}
mediaInteractionCb(...p);
};
o.styleSheetRuleCb = (...p: Arguments<styleSheetRuleCallback>) => {
if (hooks.styleSheetRule) {
hooks.styleSheetRule(...p);
}
styleSheetRuleCb(...p);
};
o.canvasMutationCb = (...p: Arguments<canvasMutationCallback>) => {
if (hooks.canvasMutation) {
hooks.canvasMutation(...p);
}
canvasMutationCb(...p);
};
o.fontCb = (...p: Arguments<fontCallback>) => {
if (hooks.font) {
hooks.font(...p);
}
fontCb(...p);
};
}
export function initObservers(
o: observerParam,
hooks: hooksParam = {},
): listenerHandler {
mergeHooks(o, hooks);
const mutationObserver = initMutationObserver(
o.mutationCb,
o.doc,
o.blockClass,
o.blockSelector,
o.maskTextClass,
o.maskTextSelector,
o.inlineStylesheet,
o.maskInputOptions,
o.maskTextFn,
o.maskInputFn,
o.recordCanvas,
o.slimDOMOptions,
o.mirror,
o.iframeManager,
o.shadowDomManager,
o.doc,
);
const mousemoveHandler = initMoveObserver(
o.mousemoveCb,
o.sampling,
o.doc,
o.mirror,
);
const mouseInteractionHandler = initMouseInteractionObserver(
o.mouseInteractionCb,
o.doc,
o.mirror,
o.blockClass,
o.sampling,
);
const scrollHandler = initScrollObserver(
o.scrollCb,
o.doc,
o.mirror,
o.blockClass,
o.sampling,
);
const viewportResizeHandler = initViewportResizeObserver(o.viewportResizeCb);
const inputHandler = initInputObserver(
o.inputCb,
o.doc,
o.mirror,
o.blockClass,
o.ignoreClass,
o.maskInputOptions,
o.maskInputFn,
o.sampling,
o.userTriggeredOnInput,
);
const mediaInteractionHandler = initMediaInteractionObserver(
o.mediaInteractionCb,
o.blockClass,
o.mirror,
);
const styleSheetObserver = initStyleSheetObserver(
o.styleSheetRuleCb,
o.mirror,
);
const canvasMutationObserver = o.recordCanvas
? initCanvasMutationObserver(o.canvasMutationCb, o.blockClass, o.mirror)
: () => {};
const fontObserver = o.collectFonts ? initFontObserver(o.fontCb) : () => {};
// plugins
const pluginHandlers: listenerHandler[] = [];
for (const plugin of o.plugins) {
pluginHandlers.push(plugin.observer(plugin.callback, plugin.options));
}
return () => {
mutationObserver.disconnect();
mousemoveHandler();
mouseInteractionHandler();
scrollHandler();
viewportResizeHandler();
inputHandler();
mediaInteractionHandler();
styleSheetObserver();
canvasMutationObserver();
fontObserver();
pluginHandlers.forEach((h) => h());
};
}

View File

@@ -0,0 +1,80 @@
import {
mutationCallBack,
blockClass,
maskTextClass,
Mirror,
scrollCallback,
SamplingStrategy,
} from '../types';
import {
MaskInputOptions,
SlimDOMOptions,
MaskTextFn,
MaskInputFn,
} from 'rrweb-snapshot';
import { IframeManager } from './iframe-manager';
import { initMutationObserver, initScrollObserver } from './observer';
type BypassOptions = {
blockClass: blockClass;
blockSelector: string | null;
maskTextClass: maskTextClass;
maskTextSelector: string | null;
inlineStylesheet: boolean;
maskInputOptions: MaskInputOptions;
maskTextFn: MaskTextFn | undefined;
maskInputFn: MaskInputFn | undefined;
recordCanvas: boolean;
sampling: SamplingStrategy;
slimDOMOptions: SlimDOMOptions;
iframeManager: IframeManager;
};
export class ShadowDomManager {
private mutationCb: mutationCallBack;
private scrollCb: scrollCallback;
private bypassOptions: BypassOptions;
private mirror: Mirror;
constructor(options: {
mutationCb: mutationCallBack;
scrollCb: scrollCallback;
bypassOptions: BypassOptions;
mirror: Mirror;
}) {
this.mutationCb = options.mutationCb;
this.scrollCb = options.scrollCb;
this.bypassOptions = options.bypassOptions;
this.mirror = options.mirror;
}
public addShadowRoot(shadowRoot: ShadowRoot, doc: Document) {
initMutationObserver(
this.mutationCb,
doc,
this.bypassOptions.blockClass,
this.bypassOptions.blockSelector,
this.bypassOptions.maskTextClass,
this.bypassOptions.maskTextSelector,
this.bypassOptions.inlineStylesheet,
this.bypassOptions.maskInputOptions,
this.bypassOptions.maskTextFn,
this.bypassOptions.maskInputFn,
this.bypassOptions.recordCanvas,
this.bypassOptions.slimDOMOptions,
this.mirror,
this.bypassOptions.iframeManager,
this,
shadowRoot,
);
initScrollObserver(
this.scrollCb,
// https://gist.github.com/praveenpuglia/0832da687ed5a5d7a0907046c9ef1813
// scroll is not allowed to pass the boundary, so we need to listen the shadow document
(shadowRoot as unknown) as Document,
this.mirror,
this.bypassOptions.blockClass,
this.bypassOptions.sampling,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,379 @@
import { createMachine, interpret, assign, StateMachine } from '@xstate/fsm';
import {
playerConfig,
eventWithTime,
actionWithDelay,
ReplayerEvents,
EventType,
Emitter,
IncrementalSource,
} from '../types';
import { Timer, addDelay } from './timer';
import { needCastInSyncMode } from '../utils';
export type PlayerContext = {
events: eventWithTime[];
timer: Timer;
timeOffset: number;
baselineTime: number;
lastPlayedEvent: eventWithTime | null;
};
export type PlayerEvent =
| {
type: 'PLAY';
payload: {
timeOffset: number;
};
}
| {
type: 'CAST_EVENT';
payload: {
event: eventWithTime;
};
}
| { type: 'PAUSE' }
| { type: 'TO_LIVE'; payload: { baselineTime?: number } }
| {
type: 'ADD_EVENT';
payload: {
event: eventWithTime;
};
}
| {
type: 'END';
};
export type PlayerState =
| {
value: 'playing';
context: PlayerContext;
}
| {
value: 'paused';
context: PlayerContext;
}
| {
value: 'live';
context: PlayerContext;
};
/**
* If the array have multiple meta and fullsnapshot events,
* return the events from last meta to the end.
*/
export function discardPriorSnapshots(
events: eventWithTime[],
baselineTime: number,
): eventWithTime[] {
for (let idx = events.length - 1; idx >= 0; idx--) {
const event = events[idx];
if (event.type === EventType.Meta) {
if (event.timestamp <= baselineTime) {
return events.slice(idx);
}
}
}
return events;
}
type PlayerAssets = {
emitter: Emitter;
getCastFn(event: eventWithTime, isSync: boolean): () => void;
};
export function createPlayerService(
context: PlayerContext,
{ getCastFn, emitter }: PlayerAssets,
) {
const playerMachine = createMachine<PlayerContext, PlayerEvent, PlayerState>(
{
id: 'player',
context,
initial: 'paused',
states: {
playing: {
on: {
PAUSE: {
target: 'paused',
actions: ['pause'],
},
CAST_EVENT: {
target: 'playing',
actions: 'castEvent',
},
END: {
target: 'paused',
actions: ['resetLastPlayedEvent', 'pause'],
},
ADD_EVENT: {
target: 'playing',
actions: ['addEvent'],
},
},
},
paused: {
on: {
PLAY: {
target: 'playing',
actions: ['recordTimeOffset', 'play'],
},
CAST_EVENT: {
target: 'paused',
actions: 'castEvent',
},
TO_LIVE: {
target: 'live',
actions: ['startLive'],
},
ADD_EVENT: {
target: 'paused',
actions: ['addEvent'],
},
},
},
live: {
on: {
ADD_EVENT: {
target: 'live',
actions: ['addEvent'],
},
CAST_EVENT: {
target: 'live',
actions: ['castEvent'],
},
},
},
},
},
{
actions: {
castEvent: assign({
lastPlayedEvent: (ctx, event) => {
if (event.type === 'CAST_EVENT') {
return event.payload.event;
}
return ctx.lastPlayedEvent;
},
}),
recordTimeOffset: assign((ctx, event) => {
let timeOffset = ctx.timeOffset;
if ('payload' in event && 'timeOffset' in event.payload) {
timeOffset = event.payload.timeOffset;
}
return {
...ctx,
timeOffset,
baselineTime: ctx.events[0].timestamp + timeOffset,
};
}),
play(ctx) {
const { timer, events, baselineTime, lastPlayedEvent } = ctx;
timer.clear();
for (const event of events) {
// TODO: improve this API
addDelay(event, baselineTime);
}
const neededEvents = discardPriorSnapshots(events, baselineTime);
let lastPlayedTimestamp = lastPlayedEvent?.timestamp;
if (
lastPlayedEvent?.type === EventType.IncrementalSnapshot &&
lastPlayedEvent.data.source === IncrementalSource.MouseMove
) {
lastPlayedTimestamp =
lastPlayedEvent.timestamp +
lastPlayedEvent.data.positions[0]?.timeOffset;
}
if (baselineTime < (lastPlayedTimestamp || 0)) {
emitter.emit(ReplayerEvents.PlayBack);
}
const actions = new Array<actionWithDelay>();
for (const event of neededEvents) {
if (
lastPlayedTimestamp &&
lastPlayedTimestamp < baselineTime &&
(event.timestamp <= lastPlayedTimestamp ||
event === lastPlayedEvent)
) {
continue;
}
const isSync = event.timestamp < baselineTime;
if (isSync && !needCastInSyncMode(event)) {
continue;
}
const castFn = getCastFn(event, isSync);
if (isSync) {
castFn();
} else {
actions.push({
doAction: () => {
castFn();
emitter.emit(ReplayerEvents.EventCast, event);
},
delay: event.delay!,
});
}
}
emitter.emit(ReplayerEvents.Flush);
timer.addActions(actions);
timer.start();
},
pause(ctx) {
ctx.timer.clear();
},
resetLastPlayedEvent: assign((ctx) => {
return {
...ctx,
lastPlayedEvent: null,
};
}),
startLive: assign({
baselineTime: (ctx, event) => {
ctx.timer.toggleLiveMode(true);
ctx.timer.start();
if (event.type === 'TO_LIVE' && event.payload.baselineTime) {
return event.payload.baselineTime;
}
return Date.now();
},
}),
addEvent: assign((ctx, machineEvent) => {
const { baselineTime, timer, events } = ctx;
if (machineEvent.type === 'ADD_EVENT') {
const { event } = machineEvent.payload;
addDelay(event, baselineTime);
let end = events.length - 1;
if (!events[end] || events[end].timestamp <= event.timestamp) {
// fast track
events.push(event);
} else {
let insertionIndex = -1;
let start = 0;
while (start <= end) {
let mid = Math.floor((start + end) / 2);
if (events[mid].timestamp <= event.timestamp) {
start = mid + 1;
} else {
end = mid - 1;
}
}
if (insertionIndex === -1) {
insertionIndex = start;
}
events.splice(insertionIndex, 0, event);
}
const isSync = event.timestamp < baselineTime;
const castFn = getCastFn(event, isSync);
if (isSync) {
castFn();
} else if (timer.isActive()) {
timer.addAction({
doAction: () => {
castFn();
emitter.emit(ReplayerEvents.EventCast, event);
},
delay: event.delay!,
});
}
}
return { ...ctx, events };
}),
},
},
);
return interpret(playerMachine);
}
export type SpeedContext = {
normalSpeed: playerConfig['speed'];
timer: Timer;
};
export type SpeedEvent =
| {
type: 'FAST_FORWARD';
payload: { speed: playerConfig['speed'] };
}
| {
type: 'BACK_TO_NORMAL';
}
| {
type: 'SET_SPEED';
payload: { speed: playerConfig['speed'] };
};
export type SpeedState =
| {
value: 'normal';
context: SpeedContext;
}
| {
value: 'skipping';
context: SpeedContext;
};
export function createSpeedService(context: SpeedContext) {
const speedMachine = createMachine<SpeedContext, SpeedEvent, SpeedState>(
{
id: 'speed',
context,
initial: 'normal',
states: {
normal: {
on: {
FAST_FORWARD: {
target: 'skipping',
actions: ['recordSpeed', 'setSpeed'],
},
SET_SPEED: {
target: 'normal',
actions: ['setSpeed'],
},
},
},
skipping: {
on: {
BACK_TO_NORMAL: {
target: 'normal',
actions: ['restoreSpeed'],
},
SET_SPEED: {
target: 'normal',
actions: ['setSpeed'],
},
},
},
},
},
{
actions: {
setSpeed: (ctx, event) => {
if ('payload' in event) {
ctx.timer.setSpeed(event.payload.speed);
}
},
recordSpeed: assign({
normalSpeed: (ctx) => ctx.timer.speed,
}),
restoreSpeed: (ctx) => {
ctx.timer.setSpeed(ctx.normalSpeed);
},
},
},
);
return interpret(speedMachine);
}
export type PlayerMachineState = StateMachine.State<
PlayerContext,
PlayerEvent,
PlayerState
>;
export type SpeedMachineState = StateMachine.State<
SpeedContext,
SpeedEvent,
SpeedState
>;

View File

@@ -0,0 +1,429 @@
/**
* A fork version of https://github.com/iamdustan/smoothscroll
* Add support of customize target window and document
*/
// @ts-nocheck
// tslint:disable
export function polyfill(w: Window = window, d = document) {
// return if scroll behavior is supported and polyfill is not forced
if (
'scrollBehavior' in d.documentElement.style &&
w.__forceSmoothScrollPolyfill__ !== true
) {
return;
}
// globals
var Element = w.HTMLElement || w.Element;
var SCROLL_TIME = 468;
// object gathering original scroll methods
var original = {
scroll: w.scroll || w.scrollTo,
scrollBy: w.scrollBy,
elementScroll: Element.prototype.scroll || scrollElement,
scrollIntoView: Element.prototype.scrollIntoView,
};
// define timing method
var now =
w.performance && w.performance.now
? w.performance.now.bind(w.performance)
: Date.now;
/**
* indicates if a the current browser is made by Microsoft
* @method isMicrosoftBrowser
* @param {String} userAgent
* @returns {Boolean}
*/
function isMicrosoftBrowser(userAgent) {
var userAgentPatterns = ['MSIE ', 'Trident/', 'Edge/'];
return new RegExp(userAgentPatterns.join('|')).test(userAgent);
}
/*
* IE has rounding bug rounding down clientHeight and clientWidth and
* rounding up scrollHeight and scrollWidth causing false positives
* on hasScrollableSpace
*/
var ROUNDING_TOLERANCE = isMicrosoftBrowser(w.navigator.userAgent) ? 1 : 0;
/**
* changes scroll position inside an element
* @method scrollElement
* @param {Number} x
* @param {Number} y
* @returns {undefined}
*/
function scrollElement(x, y) {
this.scrollLeft = x;
this.scrollTop = y;
}
/**
* returns result of applying ease math function to a number
* @method ease
* @param {Number} k
* @returns {Number}
*/
function ease(k) {
return 0.5 * (1 - Math.cos(Math.PI * k));
}
/**
* indicates if a smooth behavior should be applied
* @method shouldBailOut
* @param {Number|Object} firstArg
* @returns {Boolean}
*/
function shouldBailOut(firstArg) {
if (
firstArg === null ||
typeof firstArg !== 'object' ||
firstArg.behavior === undefined ||
firstArg.behavior === 'auto' ||
firstArg.behavior === 'instant'
) {
// first argument is not an object/null
// or behavior is auto, instant or undefined
return true;
}
if (typeof firstArg === 'object' && firstArg.behavior === 'smooth') {
// first argument is an object and behavior is smooth
return false;
}
// throw error when behavior is not supported
throw new TypeError(
'behavior member of ScrollOptions ' +
firstArg.behavior +
' is not a valid value for enumeration ScrollBehavior.',
);
}
/**
* indicates if an element has scrollable space in the provided axis
* @method hasScrollableSpace
* @param {Node} el
* @param {String} axis
* @returns {Boolean}
*/
function hasScrollableSpace(el, axis) {
if (axis === 'Y') {
return el.clientHeight + ROUNDING_TOLERANCE < el.scrollHeight;
}
if (axis === 'X') {
return el.clientWidth + ROUNDING_TOLERANCE < el.scrollWidth;
}
}
/**
* indicates if an element has a scrollable overflow property in the axis
* @method canOverflow
* @param {Node} el
* @param {String} axis
* @returns {Boolean}
*/
function canOverflow(el, axis) {
var overflowValue = w.getComputedStyle(el, null)['overflow' + axis];
return overflowValue === 'auto' || overflowValue === 'scroll';
}
/**
* indicates if an element can be scrolled in either axis
* @method isScrollable
* @param {Node} el
* @param {String} axis
* @returns {Boolean}
*/
function isScrollable(el) {
var isScrollableY = hasScrollableSpace(el, 'Y') && canOverflow(el, 'Y');
var isScrollableX = hasScrollableSpace(el, 'X') && canOverflow(el, 'X');
return isScrollableY || isScrollableX;
}
/**
* finds scrollable parent of an element
* @method findScrollableParent
* @param {Node} el
* @returns {Node} el
*/
function findScrollableParent(el) {
while (el !== d.body && isScrollable(el) === false) {
el = el.parentNode || el.host;
}
return el;
}
/**
* self invoked function that, given a context, steps through scrolling
* @method step
* @param {Object} context
* @returns {undefined}
*/
function step(context) {
var time = now();
var value;
var currentX;
var currentY;
var elapsed = (time - context.startTime) / SCROLL_TIME;
// avoid elapsed times higher than one
elapsed = elapsed > 1 ? 1 : elapsed;
// apply easing to elapsed time
value = ease(elapsed);
currentX = context.startX + (context.x - context.startX) * value;
currentY = context.startY + (context.y - context.startY) * value;
context.method.call(context.scrollable, currentX, currentY);
// scroll more if we have not reached our destination
if (currentX !== context.x || currentY !== context.y) {
w.requestAnimationFrame(step.bind(w, context));
}
}
/**
* scrolls window or element with a smooth behavior
* @method smoothScroll
* @param {Object|Node} el
* @param {Number} x
* @param {Number} y
* @returns {undefined}
*/
function smoothScroll(el, x, y) {
var scrollable;
var startX;
var startY;
var method;
var startTime = now();
// define scroll context
if (el === d.body) {
scrollable = w;
startX = w.scrollX || w.pageXOffset;
startY = w.scrollY || w.pageYOffset;
method = original.scroll;
} else {
scrollable = el;
startX = el.scrollLeft;
startY = el.scrollTop;
method = scrollElement;
}
// scroll looping over a frame
step({
scrollable: scrollable,
method: method,
startTime: startTime,
startX: startX,
startY: startY,
x: x,
y: y,
});
}
// ORIGINAL METHODS OVERRIDES
// w.scroll and w.scrollTo
w.scroll = w.scrollTo = function () {
// avoid action when no arguments are passed
if (arguments[0] === undefined) {
return;
}
// avoid smooth behavior if not required
if (shouldBailOut(arguments[0]) === true) {
original.scroll.call(
w,
arguments[0].left !== undefined
? arguments[0].left
: typeof arguments[0] !== 'object'
? arguments[0]
: w.scrollX || w.pageXOffset,
// use top prop, second argument if present or fallback to scrollY
arguments[0].top !== undefined
? arguments[0].top
: arguments[1] !== undefined
? arguments[1]
: w.scrollY || w.pageYOffset,
);
return;
}
// LET THE SMOOTHNESS BEGIN!
smoothScroll.call(
w,
d.body,
arguments[0].left !== undefined
? ~~arguments[0].left
: w.scrollX || w.pageXOffset,
arguments[0].top !== undefined
? ~~arguments[0].top
: w.scrollY || w.pageYOffset,
);
};
// w.scrollBy
w.scrollBy = function () {
// avoid action when no arguments are passed
if (arguments[0] === undefined) {
return;
}
// avoid smooth behavior if not required
if (shouldBailOut(arguments[0])) {
original.scrollBy.call(
w,
arguments[0].left !== undefined
? arguments[0].left
: typeof arguments[0] !== 'object'
? arguments[0]
: 0,
arguments[0].top !== undefined
? arguments[0].top
: arguments[1] !== undefined
? arguments[1]
: 0,
);
return;
}
// LET THE SMOOTHNESS BEGIN!
smoothScroll.call(
w,
d.body,
~~arguments[0].left + (w.scrollX || w.pageXOffset),
~~arguments[0].top + (w.scrollY || w.pageYOffset),
);
};
// Element.prototype.scroll and Element.prototype.scrollTo
Element.prototype.scroll = Element.prototype.scrollTo = function () {
// avoid action when no arguments are passed
if (arguments[0] === undefined) {
return;
}
// avoid smooth behavior if not required
if (shouldBailOut(arguments[0]) === true) {
// if one number is passed, throw error to match Firefox implementation
if (typeof arguments[0] === 'number' && arguments[1] === undefined) {
throw new SyntaxError('Value could not be converted');
}
original.elementScroll.call(
this,
// use left prop, first number argument or fallback to scrollLeft
arguments[0].left !== undefined
? ~~arguments[0].left
: typeof arguments[0] !== 'object'
? ~~arguments[0]
: this.scrollLeft,
// use top prop, second argument or fallback to scrollTop
arguments[0].top !== undefined
? ~~arguments[0].top
: arguments[1] !== undefined
? ~~arguments[1]
: this.scrollTop,
);
return;
}
var left = arguments[0].left;
var top = arguments[0].top;
// LET THE SMOOTHNESS BEGIN!
smoothScroll.call(
this,
this,
typeof left === 'undefined' ? this.scrollLeft : ~~left,
typeof top === 'undefined' ? this.scrollTop : ~~top,
);
};
// Element.prototype.scrollBy
Element.prototype.scrollBy = function () {
// avoid action when no arguments are passed
if (arguments[0] === undefined) {
return;
}
// avoid smooth behavior if not required
if (shouldBailOut(arguments[0]) === true) {
original.elementScroll.call(
this,
arguments[0].left !== undefined
? ~~arguments[0].left + this.scrollLeft
: ~~arguments[0] + this.scrollLeft,
arguments[0].top !== undefined
? ~~arguments[0].top + this.scrollTop
: ~~arguments[1] + this.scrollTop,
);
return;
}
this.scroll({
left: ~~arguments[0].left + this.scrollLeft,
top: ~~arguments[0].top + this.scrollTop,
behavior: arguments[0].behavior,
});
};
// Element.prototype.scrollIntoView
Element.prototype.scrollIntoView = function () {
// avoid smooth behavior if not required
if (shouldBailOut(arguments[0]) === true) {
original.scrollIntoView.call(
this,
arguments[0] === undefined ? true : arguments[0],
);
return;
}
// LET THE SMOOTHNESS BEGIN!
var scrollableParent = findScrollableParent(this);
var parentRects = scrollableParent.getBoundingClientRect();
var clientRects = this.getBoundingClientRect();
if (scrollableParent !== d.body) {
// reveal element inside parent
smoothScroll.call(
this,
scrollableParent,
scrollableParent.scrollLeft + clientRects.left - parentRects.left,
scrollableParent.scrollTop + clientRects.top - parentRects.top,
);
// reveal parent in viewport unless is fixed
if (w.getComputedStyle(scrollableParent).position !== 'fixed') {
w.scrollBy({
left: parentRects.left,
top: parentRects.top,
behavior: 'smooth',
});
}
} else {
// reveal element in viewport
w.scrollBy({
left: clientRects.left,
top: clientRects.top,
behavior: 'smooth',
});
}
};
}

View File

@@ -0,0 +1,6 @@
const rules: (blockClass: string) => string[] = (blockClass: string) => [
`.${blockClass} { background: #ccc }`,
'noscript { display: none !important; }',
];
export default rules;

View File

@@ -0,0 +1,47 @@
.replayer-wrapper {
position: relative;
}
.replayer-mouse {
position: absolute;
width: 20px;
height: 20px;
transition: 0.05s linear;
background-size: contain;
background-position: center center;
background-repeat: no-repeat;
background-image: url('data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGRhdGEtbmFtZT0iTGF5ZXIgMSIgdmlld0JveD0iMCAwIDUwIDUwIiB4PSIwcHgiIHk9IjBweCI+PHRpdGxlPkRlc2lnbl90bnA8L3RpdGxlPjxwYXRoIGQ9Ik00OC43MSw0Mi45MUwzNC4wOCwyOC4yOSw0NC4zMywxOEExLDEsMCwwLDAsNDQsMTYuMzlMMi4zNSwxLjA2QTEsMSwwLDAsMCwxLjA2LDIuMzVMMTYuMzksNDRhMSwxLDAsMCwwLDEuNjUuMzZMMjguMjksMzQuMDgsNDIuOTEsNDguNzFhMSwxLDAsMCwwLDEuNDEsMGw0LjM4LTQuMzhBMSwxLDAsMCwwLDQ4LjcxLDQyLjkxWm0tNS4wOSwzLjY3TDI5LDMyYTEsMSwwLDAsMC0xLjQxLDBsLTkuODUsOS44NUwzLjY5LDMuNjlsMzguMTIsMTRMMzIsMjcuNThBMSwxLDAsMCwwLDMyLDI5TDQ2LjU5LDQzLjYyWiI+PC9wYXRoPjwvc3ZnPg==');
}
.replayer-mouse::after {
content: '';
display: inline-block;
width: 20px;
height: 20px;
border-radius: 10px;
background: rgb(73, 80, 246);
transform: translate(-10px, -10px);
opacity: 0.3;
}
.replayer-mouse.active::after {
animation: click 0.2s ease-in-out 1;
}
.replayer-mouse-tail {
position: absolute;
pointer-events: none;
}
@keyframes click {
0% {
opacity: 0.3;
width: 20px;
height: 20px;
border-radius: 10px;
transform: translate(-10px, -10px);
}
50% {
opacity: 0.5;
width: 10px;
height: 10px;
border-radius: 5px;
transform: translate(-5px, -5px);
}
}

View File

@@ -0,0 +1,114 @@
import {
actionWithDelay,
eventWithTime,
EventType,
IncrementalSource,
} from '../types';
export class Timer {
public timeOffset: number = 0;
public speed: number;
private actions: actionWithDelay[];
private raf: number | null = null;
private liveMode: boolean;
constructor(actions: actionWithDelay[] = [], speed: number) {
this.actions = actions;
this.speed = speed;
}
/**
* Add an action after the timer starts.
* @param action
*/
public addAction(action: actionWithDelay) {
const index = this.findActionIndex(action);
this.actions.splice(index, 0, action);
}
/**
* Add all actions before the timer starts
* @param actions
*/
public addActions(actions: actionWithDelay[]) {
this.actions = this.actions.concat(actions);
}
public start() {
this.timeOffset = 0;
let lastTimestamp = performance.now();
const { actions } = this;
const self = this;
function check() {
const time = performance.now();
self.timeOffset += (time - lastTimestamp) * self.speed;
lastTimestamp = time;
while (actions.length) {
const action = actions[0];
if (self.timeOffset >= action.delay) {
actions.shift();
action.doAction();
} else {
break;
}
}
if (actions.length > 0 || self.liveMode) {
self.raf = requestAnimationFrame(check);
}
}
this.raf = requestAnimationFrame(check);
}
public clear() {
if (this.raf) {
cancelAnimationFrame(this.raf);
this.raf = null;
}
this.actions.length = 0;
}
public setSpeed(speed: number) {
this.speed = speed;
}
public toggleLiveMode(mode: boolean) {
this.liveMode = mode;
}
public isActive() {
return this.raf !== null;
}
private findActionIndex(action: actionWithDelay): number {
let start = 0;
let end = this.actions.length - 1;
while (start <= end) {
let mid = Math.floor((start + end) / 2);
if (this.actions[mid].delay < action.delay) {
start = mid + 1;
} else if (this.actions[mid].delay > action.delay) {
end = mid - 1;
} else {
return mid;
}
}
return start;
}
}
// TODO: add speed to mouse move timestamp calculation
export function addDelay(event: eventWithTime, baselineTime: number): 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 - baselineTime;
return firstTimestamp - baselineTime;
}
event.delay = event.timestamp - baselineTime;
return event.delay;
}

View File

@@ -0,0 +1,119 @@
import { INode } from 'rrweb-snapshot';
export enum StyleRuleType {
Insert,
Remove,
Snapshot,
}
type InsertRule = {
cssText: string;
type: StyleRuleType.Insert;
index?: number;
};
type RemoveRule = {
type: StyleRuleType.Remove;
index: number;
};
type SnapshotRule = {
type: StyleRuleType.Snapshot;
cssTexts: string[];
};
export type VirtualStyleRules = Array<InsertRule | RemoveRule | SnapshotRule>;
export type VirtualStyleRulesMap = Map<INode, VirtualStyleRules>;
export function applyVirtualStyleRulesToNode(
storedRules: VirtualStyleRules,
styleNode: HTMLStyleElement,
) {
storedRules.forEach((rule) => {
if (rule.type === StyleRuleType.Insert) {
try {
styleNode.sheet?.insertRule(rule.cssText, rule.index);
} catch (e) {
/**
* sometimes we may capture rules with browser prefix
* insert rule with prefixs in other browsers may cause Error
*/
}
} else if (rule.type === StyleRuleType.Remove) {
try {
styleNode.sheet?.deleteRule(rule.index);
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
} else if (rule.type === StyleRuleType.Snapshot) {
restoreSnapshotOfStyleRulesToNode(rule.cssTexts, styleNode);
}
});
}
function restoreSnapshotOfStyleRulesToNode(
cssTexts: string[],
styleNode: HTMLStyleElement,
) {
try {
const existingRules = Array.from(styleNode.sheet?.cssRules || []).map(
(rule) => rule.cssText,
);
const existingRulesReversed = Object.entries(existingRules).reverse();
let lastMatch = existingRules.length;
existingRulesReversed.forEach(([index, rule]) => {
const indexOf = cssTexts.indexOf(rule);
if (indexOf === -1 || indexOf > lastMatch) {
try {
styleNode.sheet?.deleteRule(Number(index));
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
}
lastMatch = indexOf;
});
cssTexts.forEach((cssText, index) => {
try {
if (styleNode.sheet?.cssRules[index]?.cssText !== cssText) {
styleNode.sheet?.insertRule(cssText, index);
}
} catch (e) {
/**
* sometimes we may capture rules with browser prefix
* insert rule with prefixs in other browsers may cause Error
*/
}
});
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
}
export function storeCSSRules(
parentElement: HTMLStyleElement,
virtualStyleRulesMap: VirtualStyleRulesMap,
) {
try {
const cssTexts = Array.from(
(parentElement as HTMLStyleElement).sheet?.cssRules || [],
).map((rule) => rule.cssText);
virtualStyleRulesMap.set((parentElement as unknown) as INode, [
{
type: StyleRuleType.Snapshot,
cssTexts,
},
]);
} catch (e) {
/**
* accessing styleSheet rules may cause SecurityError
* for specific access control settings
*/
}
}

View File

@@ -0,0 +1,173 @@
import { RRdomTreeNode, AnyObject } from './tree-node';
class RRdomTree {
private readonly symbol = '__rrdom__';
public initialize(object: AnyObject) {
this._node(object);
return object;
}
public hasChildren(object: AnyObject): boolean {
return Boolean(this._node(object).hasChildren);
}
public firstChild(object: AnyObject) {
return this._node(object).firstChild || null;
}
public lastChild(object: AnyObject) {
return this._node(object).lastChild || null;
}
public previousSibling(object: AnyObject) {
return this._node(object).previousSibling || null;
}
public nextSibling(object: AnyObject) {
return this._node(object).nextSibling || null;
}
public parent(object: AnyObject) {
return this._node(object).parent || null;
}
public insertAfter(referenceObject: AnyObject, newObject: AnyObject) {
const referenceNode = this._node(referenceObject);
const nextNode = this._node(referenceNode.nextSibling);
const newNode = this._node(newObject);
const parentNode = this._node(referenceNode.parent);
if (newNode.isAttached) {
throw new Error('Node already attached');
}
if (!referenceNode) {
throw new Error('Reference node not attached');
}
newNode.parent = referenceNode.parent;
newNode.previousSibling = referenceObject;
newNode.nextSibling = referenceNode.nextSibling;
referenceNode.nextSibling = newObject;
if (nextNode) {
nextNode.previousSibling = newObject;
}
if (parentNode && parentNode.lastChild === referenceObject) {
parentNode.lastChild = newObject;
}
if (parentNode) {
parentNode.childrenChanged();
}
return newObject;
}
public insertBefore(referenceObject: AnyObject, newObject: AnyObject) {
const referenceNode = this._node(referenceObject);
const prevNode = this._node(referenceNode.previousSibling);
const newNode = this._node(newObject);
const parentNode = this._node(referenceNode.parent);
if (newNode.isAttached) {
throw new Error('Node already attached');
}
if (!referenceNode) {
throw new Error('Reference node not attached');
}
newNode.parent = referenceNode.parent;
newNode.previousSibling = referenceNode.previousSibling;
newNode.nextSibling = referenceObject;
referenceNode.previousSibling = newObject;
if (prevNode) {
prevNode.nextSibling = newObject;
}
if (parentNode && parentNode.firstChild === referenceObject) {
parentNode.firstChild = newObject;
}
if (parentNode) {
parentNode.childrenChanged();
}
return newObject;
}
public appendChild(referenceObject: AnyObject, newObject: AnyObject) {
const referenceNode = this._node(referenceObject);
const newNode = this._node(newObject);
if (newNode.isAttached) {
throw new Error('Node already attached');
}
if (!referenceNode) {
throw new Error('Reference node not attached');
}
if (referenceNode.hasChildren) {
this.insertAfter(referenceNode.lastChild!, newObject);
} else {
newNode.parent = referenceObject;
referenceNode.firstChild = newObject;
referenceNode.lastChild = newObject;
referenceNode.childrenChanged();
}
return newObject;
}
public remove(removeObject: AnyObject) {
const removeNode = this._node(removeObject);
const parentNode = this._node(removeNode.parent);
const prevNode = this._node(removeNode.previousSibling);
const nextNode = this._node(removeNode.nextSibling);
if (parentNode) {
if (parentNode.firstChild === removeObject) {
parentNode.firstChild = removeNode.nextSibling;
}
if (parentNode.lastChild === removeObject) {
parentNode.lastChild = removeNode.previousSibling;
}
}
if (prevNode) {
prevNode.nextSibling = removeNode.nextSibling;
}
if (nextNode) {
nextNode.previousSibling = removeNode.previousSibling;
}
removeNode.parent = null;
removeNode.previousSibling = null;
removeNode.nextSibling = null;
removeNode.cachedIndex = -1;
removeNode.cachedIndexVersion = NaN;
if (parentNode) {
parentNode.childrenChanged();
}
return removeObject;
}
private _node(object: AnyObject | null): RRdomTreeNode {
if (!object) {
throw new Error('Object is falsy');
}
if (this.symbol in object) {
return object[this.symbol] as RRdomTreeNode;
}
return (object[this.symbol] = new RRdomTreeNode());
}
}

View File

@@ -0,0 +1,52 @@
// tslint:disable-next-line: no-any
export type AnyObject = { [key: string]: any; __rrdom__?: RRdomTreeNode };
export class RRdomTreeNode implements AnyObject {
public parent: AnyObject | null = null;
public previousSibling: AnyObject | null = null;
public nextSibling: AnyObject | null = null;
public firstChild: AnyObject | null = null;
public lastChild: AnyObject | null = null;
// This value is incremented anytime a children is added or removed
public childrenVersion = 0;
// The last child object which has a cached index
public childIndexCachedUpTo: AnyObject | null = null;
/**
* This value represents the cached node index, as long as
* cachedIndexVersion matches with the childrenVersion of the parent
*/
public cachedIndex = -1;
public cachedIndexVersion = NaN;
public get isAttached() {
return Boolean(this.parent || this.previousSibling || this.nextSibling);
}
public get hasChildren() {
return Boolean(this.firstChild);
}
public childrenChanged() {
// tslint:disable-next-line: no-bitwise
this.childrenVersion = (this.childrenVersion + 1) & 0xffffffff;
this.childIndexCachedUpTo = null;
}
public getCachedIndex(parentNode: AnyObject) {
if (this.cachedIndexVersion !== parentNode.childrenVersion) {
this.cachedIndexVersion = NaN;
// cachedIndex is no longer valid
return -1;
}
return this.cachedIndex;
}
public setCachedIndex(parentNode: AnyObject, index: number) {
this.cachedIndexVersion = parentNode.childrenVersion;
this.cachedIndex = index;
}
}

569
packages/rrweb/src/types.ts Normal file
View File

@@ -0,0 +1,569 @@
import {
serializedNodeWithId,
idNodeMap,
INode,
MaskInputOptions,
SlimDOMOptions,
MaskInputFn,
MaskTextFn,
} from 'rrweb-snapshot';
import { PackFn, UnpackFn } from './packer/base';
import { FontFaceDescriptors } from 'css-font-loading-module';
import { IframeManager } from './record/iframe-manager';
import { ShadowDomManager } from './record/shadow-dom-manager';
import type { Replayer } from './replay';
export enum EventType {
DomContentLoaded,
Load,
FullSnapshot,
IncrementalSnapshot,
Meta,
Custom,
Plugin,
}
export type domContentLoadedEvent = {
type: EventType.DomContentLoaded;
data: {};
};
export type loadedEvent = {
type: EventType.Load;
data: {};
};
export type fullSnapshotEvent = {
type: EventType.FullSnapshot;
data: {
node: serializedNodeWithId;
initialOffset: {
top: number;
left: number;
};
};
};
export type incrementalSnapshotEvent = {
type: EventType.IncrementalSnapshot;
data: incrementalData;
};
export type metaEvent = {
type: EventType.Meta;
data: {
href: string;
width: number;
height: number;
};
};
export type customEvent<T = unknown> = {
type: EventType.Custom;
data: {
tag: string;
payload: T;
};
};
export type pluginEvent<T = unknown> = {
type: EventType.Plugin;
data: {
plugin: string;
payload: T;
};
};
export type styleSheetEvent = {};
export enum IncrementalSource {
Mutation,
MouseMove,
MouseInteraction,
Scroll,
ViewportResize,
Input,
TouchMove,
MediaInteraction,
StyleSheetRule,
CanvasMutation,
Font,
Log,
Drag,
}
export type mutationData = {
source: IncrementalSource.Mutation;
} & mutationCallbackParam;
export type mousemoveData = {
source:
| IncrementalSource.MouseMove
| IncrementalSource.TouchMove
| IncrementalSource.Drag;
positions: mousePosition[];
};
export type mouseInteractionData = {
source: IncrementalSource.MouseInteraction;
} & mouseInteractionParam;
export type scrollData = {
source: IncrementalSource.Scroll;
} & scrollPosition;
export type viewportResizeData = {
source: IncrementalSource.ViewportResize;
} & viewportResizeDimension;
export type inputData = {
source: IncrementalSource.Input;
id: number;
} & inputValue;
export type mediaInteractionData = {
source: IncrementalSource.MediaInteraction;
} & mediaInteractionParam;
export type styleSheetRuleData = {
source: IncrementalSource.StyleSheetRule;
} & styleSheetRuleParam;
export type canvasMutationData = {
source: IncrementalSource.CanvasMutation;
} & canvasMutationParam;
export type fontData = {
source: IncrementalSource.Font;
} & fontParam;
export type incrementalData =
| mutationData
| mousemoveData
| mouseInteractionData
| scrollData
| viewportResizeData
| inputData
| mediaInteractionData
| styleSheetRuleData
| canvasMutationData
| fontData;
export type event =
| domContentLoadedEvent
| loadedEvent
| fullSnapshotEvent
| incrementalSnapshotEvent
| metaEvent
| customEvent
| pluginEvent;
export type eventWithTime = event & {
timestamp: number;
delay?: number;
};
export type blockClass = string | RegExp;
export type maskTextClass = string | RegExp;
export type SamplingStrategy = Partial<{
/**
* false means not to record mouse/touch move events
* number is the throttle threshold of recording mouse/touch move
*/
mousemove: boolean | number;
/**
* number is the throttle threshold of mouse/touch move callback
*/
mousemoveCallback: number;
/**
* false means not to record mouse interaction events
* can also specify record some kinds of mouse interactions
*/
mouseInteraction: boolean | Record<string, boolean | undefined>;
/**
* number is the throttle threshold of recording scroll
*/
scroll: number;
/**
* 'all' will record all the input events
* 'last' will only record the last input value while input a sequence of chars
*/
input: 'all' | 'last';
}>;
export type RecordPlugin<TOptions = unknown> = {
name: string;
observer: (cb: Function, options: TOptions) => listenerHandler;
options: TOptions;
};
export type recordOptions<T> = {
emit?: (e: T, isCheckout?: boolean) => void;
checkoutEveryNth?: number;
checkoutEveryNms?: number;
blockClass?: blockClass;
blockSelector?: string;
ignoreClass?: string;
maskTextClass?: maskTextClass;
maskTextSelector?: string;
maskAllInputs?: boolean;
maskInputOptions?: MaskInputOptions;
maskInputFn?: MaskInputFn;
maskTextFn?: MaskTextFn;
slimDOMOptions?: SlimDOMOptions | 'all' | true;
inlineStylesheet?: boolean;
hooks?: hooksParam;
packFn?: PackFn;
sampling?: SamplingStrategy;
recordCanvas?: boolean;
userTriggeredOnInput?: boolean;
collectFonts?: boolean;
plugins?: RecordPlugin[];
// departed, please use sampling options
mousemoveWait?: number;
keepIframeSrcFn?: KeepIframeSrcFn;
};
export type observerParam = {
mutationCb: mutationCallBack;
mousemoveCb: mousemoveCallBack;
mouseInteractionCb: mouseInteractionCallBack;
scrollCb: scrollCallback;
viewportResizeCb: viewportResizeCallback;
inputCb: inputCallback;
mediaInteractionCb: mediaInteractionCallback;
blockClass: blockClass;
blockSelector: string | null;
ignoreClass: string;
maskTextClass: maskTextClass;
maskTextSelector: string | null;
maskInputOptions: MaskInputOptions;
maskInputFn?: MaskInputFn;
maskTextFn?: MaskTextFn;
inlineStylesheet: boolean;
styleSheetRuleCb: styleSheetRuleCallback;
canvasMutationCb: canvasMutationCallback;
fontCb: fontCallback;
sampling: SamplingStrategy;
recordCanvas: boolean;
userTriggeredOnInput: boolean;
collectFonts: boolean;
slimDOMOptions: SlimDOMOptions;
doc: Document;
mirror: Mirror;
iframeManager: IframeManager;
shadowDomManager: ShadowDomManager;
plugins: Array<{
observer: Function;
callback: Function;
options: unknown;
}>;
};
export type hooksParam = {
mutation?: mutationCallBack;
mousemove?: mousemoveCallBack;
mouseInteraction?: mouseInteractionCallBack;
scroll?: scrollCallback;
viewportResize?: viewportResizeCallback;
input?: inputCallback;
mediaInteaction?: mediaInteractionCallback;
styleSheetRule?: styleSheetRuleCallback;
canvasMutation?: canvasMutationCallback;
font?: fontCallback;
};
// https://dom.spec.whatwg.org/#interface-mutationrecord
export type mutationRecord = {
type: string;
target: Node;
oldValue: string | null;
addedNodes: NodeList;
removedNodes: NodeList;
attributeName: string | null;
};
export type textCursor = {
node: Node;
value: string | null;
};
export type textMutation = {
id: number;
value: string | null;
};
export type styleAttributeValue = {
[key:string]: styleValueWithPriority | string | false;
};
export type styleValueWithPriority = [string, string];
export type attributeCursor = {
node: Node;
attributes: {
[key: string]: string | styleAttributeValue | null;
};
};
export type attributeMutation = {
id: number;
attributes: {
[key: string]: string | styleAttributeValue | null;
};
};
export type removedNodeMutation = {
parentId: number;
id: number;
isShadow?: boolean;
};
export type addedNodeMutation = {
parentId: number;
// Newly recorded mutations will not have previousId any more, just for compatibility
previousId?: number | null;
nextId: number | null;
node: serializedNodeWithId;
};
export type mutationCallbackParam = {
texts: textMutation[];
attributes: attributeMutation[];
removes: removedNodeMutation[];
adds: addedNodeMutation[];
isAttachIframe?: true;
};
export type mutationCallBack = (m: mutationCallbackParam) => void;
export type mousemoveCallBack = (
p: mousePosition[],
source:
| IncrementalSource.MouseMove
| IncrementalSource.TouchMove
| IncrementalSource.Drag,
) => void;
export type mousePosition = {
x: number;
y: number;
id: number;
timeOffset: number;
};
export enum MouseInteractions {
MouseUp,
MouseDown,
Click,
ContextMenu,
DblClick,
Focus,
Blur,
TouchStart,
TouchMove_Departed, // we will start a separate observer for touch move event
TouchEnd,
}
type mouseInteractionParam = {
type: MouseInteractions;
id: number;
x: number;
y: number;
};
export type mouseInteractionCallBack = (d: mouseInteractionParam) => void;
export type scrollPosition = {
id: number;
x: number;
y: number;
};
export type scrollCallback = (p: scrollPosition) => void;
export type styleSheetAddRule = {
rule: string;
index?: number;
};
export type styleSheetDeleteRule = {
index: number;
};
export type styleSheetRuleParam = {
id: number;
removes?: styleSheetDeleteRule[];
adds?: styleSheetAddRule[];
};
export type styleSheetRuleCallback = (s: styleSheetRuleParam) => void;
export type canvasMutationCallback = (p: canvasMutationParam) => void;
export type canvasMutationParam = {
id: number;
property: string;
args: Array<unknown>;
setter?: true;
};
export type fontParam = {
family: string;
fontSource: string;
buffer: boolean;
descriptors?: FontFaceDescriptors;
};
export type fontCallback = (p: fontParam) => void;
export type viewportResizeDimension = {
width: number;
height: number;
};
export type viewportResizeCallback = (d: viewportResizeDimension) => void;
export type inputValue = {
text: string;
isChecked: boolean;
// `userTriggered` indicates if this event was triggered directly by user (userTriggered: true)
// or was triggered indirectly (userTriggered: false)
// Example of `userTriggered` in action:
// User clicks on radio element (userTriggered: true) which triggers the other radio element to change (userTriggered: false)
userTriggered?: boolean;
};
export type inputCallback = (v: inputValue & { id: number }) => void;
export const enum MediaInteractions {
Play,
Pause,
Seeked,
}
export type mediaInteractionParam = {
type: MediaInteractions;
id: number;
currentTime?: number;
};
export type mediaInteractionCallback = (p: mediaInteractionParam) => void;
export type DocumentDimension = {
x: number;
y: number;
// scale value relative to its parent iframe
relativeScale: number;
// scale value relative to the root iframe
absoluteScale: number;
};
export type Mirror = {
map: idNodeMap;
getId: (n: INode) => number;
getNode: (id: number) => INode | null;
removeNodeFromMap: (n: INode) => void;
has: (id: number) => boolean;
reset: () => void;
};
export type throttleOptions = {
leading?: boolean;
trailing?: boolean;
};
export type listenerHandler = () => void;
export type hookResetter = () => void;
export type ReplayPlugin = {
handler: (
event: eventWithTime,
isSync: boolean,
context: { replayer: Replayer },
) => void;
};
export type playerConfig = {
speed: number;
maxSpeed: number;
root: Element;
loadTimeout: number;
skipInactive: boolean;
showWarning: boolean;
showDebug: boolean;
blockClass: string;
liveMode: boolean;
insertStyleRules: string[];
triggerFocus: boolean;
UNSAFE_replayCanvas: boolean;
pauseAnimation?: boolean;
mouseTail:
| boolean
| {
duration?: number;
lineCap?: string;
lineWidth?: number;
strokeStyle?: string;
};
unpackFn?: UnpackFn;
plugins?: ReplayPlugin[];
};
export type playerMetaData = {
startTime: number;
endTime: number;
totalTime: number;
};
export type missingNode = {
node: Node;
mutation: addedNodeMutation;
};
export type missingNodeMap = {
[id: number]: missingNode;
};
export type actionWithDelay = {
doAction: () => void;
delay: number;
};
export type Handler = (event?: unknown) => void;
export type Emitter = {
on(type: string, handler: Handler): void;
emit(type: string, event?: unknown): void;
off(type: string, handler: Handler): void;
};
export type Arguments<T> = T extends (...payload: infer U) => unknown
? U
: unknown;
export enum ReplayerEvents {
Start = 'start',
Pause = 'pause',
Resume = 'resume',
Resize = 'resize',
Finish = 'finish',
FullsnapshotRebuilded = 'fullsnapshot-rebuilded',
LoadStylesheetStart = 'load-stylesheet-start',
LoadStylesheetEnd = 'load-stylesheet-end',
SkipStart = 'skip-start',
SkipEnd = 'skip-end',
MouseInteraction = 'mouse-interaction',
EventCast = 'event-cast',
CustomEvent = 'custom-event',
Flush = 'flush',
StateChange = 'state-change',
PlayBack = 'play-back',
}
// store the state that would be changed during the process(unmount from dom and mount again)
export type ElementState = {
// [scrollLeft,scrollTop]
scroll?: [number, number];
};
export type KeepIframeSrcFn = (src: string) => boolean;

662
packages/rrweb/src/utils.ts Normal file
View File

@@ -0,0 +1,662 @@
import {
Mirror,
throttleOptions,
listenerHandler,
hookResetter,
blockClass,
eventWithTime,
EventType,
IncrementalSource,
addedNodeMutation,
removedNodeMutation,
textMutation,
attributeMutation,
mutationData,
scrollData,
inputData,
DocumentDimension,
} from './types';
import {
INode,
IGNORED_NODE,
serializedNodeWithId,
NodeType,
isShadowRoot,
} from 'rrweb-snapshot';
export function on(
type: string,
fn: EventListenerOrEventListenerObject,
target: Document | Window = document,
): listenerHandler {
const options = { capture: true, passive: true };
target.addEventListener(type, fn, options);
return () => target.removeEventListener(type, fn, options);
}
export function createMirror(): Mirror {
return {
map: {},
getId(n) {
// if n is not a serialized INode, use -1 as its id.
if (!n.__sn) {
return -1;
}
return n.__sn.id;
},
getNode(id) {
return this.map[id] || null;
},
// TODO: use a weakmap to get rid of manually memory management
removeNodeFromMap(n) {
const id = n.__sn && n.__sn.id;
delete this.map[id];
if (n.childNodes) {
n.childNodes.forEach((child) =>
this.removeNodeFromMap((child as Node) as INode),
);
}
},
has(id) {
return this.map.hasOwnProperty(id);
},
reset() {
this.map = {};
},
};
}
// https://github.com/rrweb-io/rrweb/pull/407
const DEPARTED_MIRROR_ACCESS_WARNING =
'Please stop import mirror directly. Instead of that,' +
'\r\n' +
'now you can use replayer.getMirror() to access the mirror instance of a replayer,' +
'\r\n' +
'or you can use record.mirror to access the mirror instance during recording.';
export let _mirror: Mirror = {
map: {},
getId() {
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
return -1;
},
getNode() {
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
return null;
},
removeNodeFromMap() {
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
},
has() {
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
return false;
},
reset() {
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
},
};
if (typeof window !== 'undefined' && window.Proxy && window.Reflect) {
_mirror = new Proxy(_mirror, {
get(target, prop, receiver) {
if (prop === 'map') {
console.error(DEPARTED_MIRROR_ACCESS_WARNING);
}
return Reflect.get(target, prop, receiver);
},
});
}
// copy from underscore and modified
export function throttle<T>(
func: (arg: T) => void,
wait: number,
options: throttleOptions = {},
) {
let timeout: number | null = null;
let previous = 0;
// tslint:disable-next-line: only-arrow-functions
return function (arg: T) {
let now = Date.now();
if (!previous && options.leading === false) {
previous = now;
}
let remaining = wait - (now - previous);
let context = this;
let args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
window.clearTimeout(timeout);
timeout = null;
}
previous = now;
func.apply(context, args);
} else if (!timeout && options.trailing !== false) {
timeout = window.setTimeout(() => {
previous = options.leading === false ? 0 : Date.now();
timeout = null;
func.apply(context, args);
}, remaining);
}
};
}
export function hookSetter<T>(
target: T,
key: string | number | symbol,
d: PropertyDescriptor,
isRevoked?: boolean,
win = window,
): hookResetter {
const original = win.Object.getOwnPropertyDescriptor(target, key);
win.Object.defineProperty(
target,
key,
isRevoked
? d
: {
set(value) {
// put hooked setter into event loop to avoid of set latency
setTimeout(() => {
d.set!.call(this, value);
}, 0);
if (original && original.set) {
original.set.call(this, value);
}
},
},
);
return () => hookSetter(target, key, original || {}, true);
}
// copy from https://github.com/getsentry/sentry-javascript/blob/b2109071975af8bf0316d3b5b38f519bdaf5dc15/packages/utils/src/object.ts
export function patch(
// tslint:disable-next-line:no-any
source: { [key: string]: any },
name: string,
// tslint:disable-next-line:no-any
replacement: (...args: any[]) => any,
): () => void {
try {
if (!(name in source)) {
return () => {};
}
const original = source[name] as () => unknown;
const wrapped = replacement(original);
// Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work
// otherwise it'll throw "TypeError: Object.defineProperties called on non-object"
// tslint:disable-next-line:strict-type-predicates
if (typeof wrapped === 'function') {
wrapped.prototype = wrapped.prototype || {};
Object.defineProperties(wrapped, {
__rrweb_original__: {
enumerable: false,
value: original,
},
});
}
source[name] = wrapped;
return () => {
source[name] = original;
};
} catch {
return () => {};
// This can throw if multiple fill happens on a global object like XMLHttpRequest
// Fixes https://github.com/getsentry/sentry-javascript/issues/2043
}
}
export function getWindowHeight(): number {
return (
window.innerHeight ||
(document.documentElement && document.documentElement.clientHeight) ||
(document.body && document.body.clientHeight)
);
}
export function getWindowWidth(): number {
return (
window.innerWidth ||
(document.documentElement && document.documentElement.clientWidth) ||
(document.body && document.body.clientWidth)
);
}
export function isBlocked(node: Node | null, blockClass: blockClass): boolean {
if (!node) {
return false;
}
if (node.nodeType === node.ELEMENT_NODE) {
let needBlock = false;
if (typeof blockClass === 'string') {
needBlock = (node as HTMLElement).classList.contains(blockClass);
} else {
(node as HTMLElement).classList.forEach((className) => {
if (blockClass.test(className)) {
needBlock = true;
}
});
}
return needBlock || isBlocked(node.parentNode, blockClass);
}
if (node.nodeType === node.TEXT_NODE) {
// check parent node since text node do not have class name
return isBlocked(node.parentNode, blockClass);
}
return isBlocked(node.parentNode, blockClass);
}
export function isIgnored(n: Node | INode): boolean {
if ('__sn' in n) {
return (n as INode).__sn.id === IGNORED_NODE;
}
// The main part of the slimDOM check happens in
// rrweb-snapshot::serializeNodeWithId
return false;
}
export function isAncestorRemoved(target: INode, mirror: Mirror): boolean {
if (isShadowRoot(target)) {
return false;
}
const id = mirror.getId(target);
if (!mirror.has(id)) {
return true;
}
if (
target.parentNode &&
target.parentNode.nodeType === target.DOCUMENT_NODE
) {
return false;
}
// if the root is not document, it means the node is not in the DOM tree anymore
if (!target.parentNode) {
return true;
}
return isAncestorRemoved((target.parentNode as unknown) as INode, mirror);
}
export function isTouchEvent(
event: MouseEvent | TouchEvent,
): event is TouchEvent {
return Boolean((event as TouchEvent).changedTouches);
}
export function polyfill(win = window) {
if ('NodeList' in win && !win.NodeList.prototype.forEach) {
win.NodeList.prototype.forEach = (Array.prototype
.forEach as unknown) as NodeList['forEach'];
}
if ('DOMTokenList' in win && !win.DOMTokenList.prototype.forEach) {
win.DOMTokenList.prototype.forEach = (Array.prototype
.forEach as unknown) as DOMTokenList['forEach'];
}
// https://github.com/Financial-Times/polyfill-service/pull/183
if (!Node.prototype.contains) {
Node.prototype.contains = function contains(node) {
if (!(0 in arguments)) {
throw new TypeError('1 argument is required');
}
do {
if (this === node) {
return true;
}
// tslint:disable-next-line: no-conditional-assignment
} while ((node = node && node.parentNode));
return false;
};
}
}
export function needCastInSyncMode(event: eventWithTime): boolean {
switch (event.type) {
case EventType.DomContentLoaded:
case EventType.Load:
case EventType.Custom:
return false;
case EventType.FullSnapshot:
case EventType.Meta:
case EventType.Plugin:
return true;
default:
break;
}
switch (event.data.source) {
case IncrementalSource.MouseMove:
case IncrementalSource.MouseInteraction:
case IncrementalSource.TouchMove:
case IncrementalSource.MediaInteraction:
return false;
case IncrementalSource.ViewportResize:
case IncrementalSource.StyleSheetRule:
case IncrementalSource.Scroll:
case IncrementalSource.Input:
return true;
default:
break;
}
return true;
}
export type TreeNode = {
id: number;
mutation: addedNodeMutation;
parent?: TreeNode;
children: Record<number, TreeNode>;
texts: textMutation[];
attributes: attributeMutation[];
};
export class TreeIndex {
public tree!: Record<number, TreeNode>;
private removeNodeMutations!: removedNodeMutation[];
private textMutations!: textMutation[];
private attributeMutations!: attributeMutation[];
private indexes!: Map<number, TreeNode>;
private removeIdSet!: Set<number>;
private scrollMap!: Map<number, scrollData>;
private inputMap!: Map<number, inputData>;
constructor() {
this.reset();
}
public add(mutation: addedNodeMutation) {
const parentTreeNode = this.indexes.get(mutation.parentId);
const treeNode: TreeNode = {
id: mutation.node.id,
mutation,
children: [],
texts: [],
attributes: [],
};
if (!parentTreeNode) {
this.tree[treeNode.id] = treeNode;
} else {
treeNode.parent = parentTreeNode;
parentTreeNode.children[treeNode.id] = treeNode;
}
this.indexes.set(treeNode.id, treeNode);
}
public remove(mutation: removedNodeMutation, mirror: Mirror) {
const parentTreeNode = this.indexes.get(mutation.parentId);
const treeNode = this.indexes.get(mutation.id);
const deepRemoveFromMirror = (id: number) => {
this.removeIdSet.add(id);
const node = mirror.getNode(id);
node?.childNodes.forEach((childNode) => {
if ('__sn' in childNode) {
deepRemoveFromMirror(((childNode as unknown) as INode).__sn.id);
}
});
};
const deepRemoveFromTreeIndex = (node: TreeNode) => {
this.removeIdSet.add(node.id);
Object.values(node.children).forEach((n) => deepRemoveFromTreeIndex(n));
const _treeNode = this.indexes.get(node.id);
if (_treeNode) {
const _parentTreeNode = _treeNode.parent;
if (_parentTreeNode) {
delete _treeNode.parent;
delete _parentTreeNode.children[_treeNode.id];
this.indexes.delete(mutation.id);
}
}
};
if (!treeNode) {
this.removeNodeMutations.push(mutation);
deepRemoveFromMirror(mutation.id);
} else if (!parentTreeNode) {
delete this.tree[treeNode.id];
this.indexes.delete(treeNode.id);
deepRemoveFromTreeIndex(treeNode);
} else {
delete treeNode.parent;
delete parentTreeNode.children[treeNode.id];
this.indexes.delete(mutation.id);
deepRemoveFromTreeIndex(treeNode);
}
}
public text(mutation: textMutation) {
const treeNode = this.indexes.get(mutation.id);
if (treeNode) {
treeNode.texts.push(mutation);
} else {
this.textMutations.push(mutation);
}
}
public attribute(mutation: attributeMutation) {
const treeNode = this.indexes.get(mutation.id);
if (treeNode) {
treeNode.attributes.push(mutation);
} else {
this.attributeMutations.push(mutation);
}
}
public scroll(d: scrollData) {
this.scrollMap.set(d.id, d);
}
public input(d: inputData) {
this.inputMap.set(d.id, d);
}
public flush(): {
mutationData: mutationData;
scrollMap: TreeIndex['scrollMap'];
inputMap: TreeIndex['inputMap'];
} {
const {
tree,
removeNodeMutations,
textMutations,
attributeMutations,
} = this;
const batchMutationData: mutationData = {
source: IncrementalSource.Mutation,
removes: removeNodeMutations,
texts: textMutations,
attributes: attributeMutations,
adds: [],
};
const walk = (treeNode: TreeNode, removed: boolean) => {
if (removed) {
this.removeIdSet.add(treeNode.id);
}
batchMutationData.texts = batchMutationData.texts
.concat(removed ? [] : treeNode.texts)
.filter((m) => !this.removeIdSet.has(m.id));
batchMutationData.attributes = batchMutationData.attributes
.concat(removed ? [] : treeNode.attributes)
.filter((m) => !this.removeIdSet.has(m.id));
if (
!this.removeIdSet.has(treeNode.id) &&
!this.removeIdSet.has(treeNode.mutation.parentId) &&
!removed
) {
batchMutationData.adds.push(treeNode.mutation);
if (treeNode.children) {
Object.values(treeNode.children).forEach((n) => walk(n, false));
}
} else {
Object.values(treeNode.children).forEach((n) => walk(n, true));
}
};
Object.values(tree).forEach((n) => walk(n, false));
for (const id of this.scrollMap.keys()) {
if (this.removeIdSet.has(id)) {
this.scrollMap.delete(id);
}
}
for (const id of this.inputMap.keys()) {
if (this.removeIdSet.has(id)) {
this.inputMap.delete(id);
}
}
const scrollMap = new Map(this.scrollMap);
const inputMap = new Map(this.inputMap);
this.reset();
return {
mutationData: batchMutationData,
scrollMap,
inputMap,
};
}
private reset() {
this.tree = [];
this.indexes = new Map();
this.removeNodeMutations = [];
this.textMutations = [];
this.attributeMutations = [];
this.removeIdSet = new Set();
this.scrollMap = new Map();
this.inputMap = new Map();
}
public idRemoved(id: number): boolean {
return this.removeIdSet.has(id);
}
}
type ResolveTree = {
value: addedNodeMutation;
children: ResolveTree[];
parent: ResolveTree | null;
};
export function queueToResolveTrees(queue: addedNodeMutation[]): ResolveTree[] {
const queueNodeMap: Record<number, ResolveTree> = {};
const putIntoMap = (
m: addedNodeMutation,
parent: ResolveTree | null,
): ResolveTree => {
const nodeInTree: ResolveTree = {
value: m,
parent,
children: [],
};
queueNodeMap[m.node.id] = nodeInTree;
return nodeInTree;
};
const queueNodeTrees: ResolveTree[] = [];
for (const mutation of queue) {
const { nextId, parentId } = mutation;
if (nextId && nextId in queueNodeMap) {
const nextInTree = queueNodeMap[nextId];
if (nextInTree.parent) {
const idx = nextInTree.parent.children.indexOf(nextInTree);
nextInTree.parent.children.splice(
idx,
0,
putIntoMap(mutation, nextInTree.parent),
);
} else {
const idx = queueNodeTrees.indexOf(nextInTree);
queueNodeTrees.splice(idx, 0, putIntoMap(mutation, null));
}
continue;
}
if (parentId in queueNodeMap) {
const parentInTree = queueNodeMap[parentId];
parentInTree.children.push(putIntoMap(mutation, parentInTree));
continue;
}
queueNodeTrees.push(putIntoMap(mutation, null));
}
return queueNodeTrees;
}
export function iterateResolveTree(
tree: ResolveTree,
cb: (mutation: addedNodeMutation) => unknown,
) {
cb(tree.value);
/**
* The resolve tree was designed to reflect the DOM layout,
* but we need append next sibling first, so we do a reverse
* loop here.
*/
for (let i = tree.children.length - 1; i >= 0; i--) {
iterateResolveTree(tree.children[i], cb);
}
}
type HTMLIFrameINode = HTMLIFrameElement & {
__sn: serializedNodeWithId;
};
export type AppendedIframe = {
mutationInQueue: addedNodeMutation;
builtNode: HTMLIFrameINode;
};
export function isIframeINode(
node: INode | ShadowRoot,
): node is HTMLIFrameINode {
if ('__sn' in node) {
return (
node.__sn.type === NodeType.Element && node.__sn.tagName === 'iframe'
);
}
// node can be document fragment when using the virtual parent feature
return false;
}
export function getBaseDimension(
node: Node,
rootIframe: Node,
): DocumentDimension {
const frameElement = node.ownerDocument?.defaultView?.frameElement;
if (!frameElement || frameElement === rootIframe) {
return {
x: 0,
y: 0,
relativeScale: 1,
absoluteScale: 1,
};
}
const frameDimension = frameElement.getBoundingClientRect();
const frameBaseDimension = getBaseDimension(frameElement, rootIframe);
// the iframe element may have a scale transform
const relativeScale = frameDimension.height / frameElement.clientHeight;
return {
x:
frameDimension.x * frameBaseDimension.relativeScale +
frameBaseDimension.x,
y:
frameDimension.y * frameBaseDimension.relativeScale +
frameBaseDimension.y,
relativeScale,
absoluteScale: frameBaseDimension.absoluteScale * relativeScale,
};
}
export function hasShadowRoot<T extends Node>(
n: T,
): n is T & { shadowRoot: ShadowRoot } {
return Boolean(((n as unknown) as Element)?.shadowRoot);
}

16
packages/rrweb/test.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
declare module 'jest-snapshot' {
export class SnapshotState {
constructor(testFile: string, options: any);
save(): any;
}
type matchResult = {
pass: boolean;
report(): string;
};
export function toMatchSnapshot(
received: any,
propertyMatchers?: any,
testName?: string,
): matchResult;
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,461 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`async-checkout 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 7
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [
{
\\"parentId\\": 4,
\\"id\\": 6
}
],
\\"adds\\": [
{
\\"parentId\\": 4,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"p\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 8
}
},
{
\\"parentId\\": 8,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"span\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"test\\",
\\"id\\": 10
}
}
]
}
},
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"p\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"span\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"test\\",
\\"id\\": 10
}
],
\\"id\\": 9
}
],
\\"id\\": 8
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [
{
\\"parentId\\": 8,
\\"id\\": 9
}
],
\\"adds\\": [
{
\\"parentId\\": 4,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"span\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9
}
},
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"test\\",
\\"id\\": 10
}
}
]
}
}
]"
`;
exports[`custom-event 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 7
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 5,
\\"data\\": {
\\"tag\\": \\"tag1\\",
\\"payload\\": 1
}
},
{
\\"type\\": 5,
\\"data\\": {
\\"tag\\": \\"tag2\\",
\\"payload\\": {
\\"a\\": \\"b\\"
}
}
}
]"
`;
exports[`stylesheet-rules 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 3
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 7
}
],
\\"id\\": 4
}
],
\\"id\\": 2
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 3,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"style\\",
\\"attributes\\": {
\\"_cssText\\": \\"body { background: rgb(0, 0, 0); }\\"
},
\\"childNodes\\": [],
\\"id\\": 8
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 8,
\\"id\\": 8,
\\"adds\\": [
{
\\"rule\\": \\"body { color: #fff; }\\"
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 8,
\\"id\\": 8,
\\"removes\\": [
{
\\"index\\": 0
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 8,
\\"id\\": 8,
\\"adds\\": [
{
\\"rule\\": \\"body { color: #ccc; }\\"
}
]
}
}
]"
`;

View File

@@ -0,0 +1,278 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`style-sheet-remove-events-play-at-2500 1`] = `
"file-frame-1
<html>
<head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
</head>
<body>
<div class=\\"replayer-wrapper\\">
<div class=\\"replayer-mouse\\"></div>
<canvas
class=\\"replayer-mouse-tail\\"
width=\\"1000\\"
height=\\"800\\"
style=\\"display: inherit\\"
></canvas
><iframe
sandbox=\\"allow-same-origin\\"
scrolling=\\"no\\"
width=\\"1000\\"
height=\\"800\\"
style=\\"display: inherit; pointer-events: none\\"
></iframe>
</div>
</body>
</html>
file-frame-2
<html>
<head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-0\\" />
</head>
<body></body>
</html>
file-cid-0
@charset \\"utf-8\\";
.rr-block { background: rgb(204, 204, 204); }
noscript { display: none !important; }
html.rrweb-paused * { animation-play-state: paused !important; }
"
`;
exports[`style-sheet-rule-events-pause-at-2500 1`] = `
"file-frame-4
<html>
<head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\">
<title></title>
</head>
<body>
<div class=\\"replayer-wrapper\\">
<div class=\\"replayer-mouse\\"></div>
<canvas class=\\"replayer-mouse-tail\\" width=\\"1000\\" height=\\"800\\" style=
\\"display: inherit;\\"></canvas><iframe sandbox=\\"allow-same-origin\\" scrolling=
\\"no\\" width=\\"1000\\" height=\\"800\\" style=
\\"display: inherit; pointer-events: none;\\"></iframe>
</div>
</body>
</html>
file-frame-5
<!DOCTYPE html>
<html lang=\\"en\\">
<head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\">
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-0\\">
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-1\\">
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-2\\">
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-3\\">
<title></title>
</head>
<body>
<a class=\\"css-added-at-1000-deleted-at-2500\\">string</a>
</body>
</html>
file-cid-0
@charset \\"utf-8\\";
.rr-block { background: rgb(204, 204, 204); }
noscript { display: none !important; }
html.rrweb-paused * { animation-play-state: paused !important; }
file-cid-1
@charset \\"utf-8\\";
.c011xx { padding: 1.3125rem; flex: 0 0 auto; width: 100%; }
file-cid-2
@charset \\"utf-8\\";
.c01x { opacity: 1; transform: translateX(0px); }
.css-added-at-400 { border: 1px solid blue; }
file-cid-3
@charset \\"utf-8\\";
.css-1uxxxx3 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }
.css-1c9xxxx { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }
.css-lsxxx { padding-left: 4rem; }
"
`;
exports[`style-sheet-rule-events-play-at-1500 1`] = `
"file-frame-4
<html>
<head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
</head>
<body>
<div class=\\"replayer-wrapper\\">
<div class=\\"replayer-mouse\\"></div>
<canvas
class=\\"replayer-mouse-tail\\"
width=\\"1000\\"
height=\\"800\\"
style=\\"display: inherit\\"
></canvas
><iframe
sandbox=\\"allow-same-origin\\"
scrolling=\\"no\\"
width=\\"1000\\"
height=\\"800\\"
style=\\"display: inherit; pointer-events: none\\"
></iframe>
</div>
</body>
</html>
file-frame-5
<!DOCTYPE html>
<html lang=\\"en\\">
<head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-0\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-1\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-2\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-3\\" />
</head>
<body>
<a class=\\"css-added-at-1000-deleted-at-2500\\">string</a>
</body>
</html>
file-cid-0
@charset \\"utf-8\\";
.rr-block { background: rgb(204, 204, 204); }
noscript { display: none !important; }
html.rrweb-paused * { animation-play-state: paused !important; }
file-cid-1
@charset \\"utf-8\\";
.c011xx { padding: 1.3125rem; flex: 0 0 auto; width: 100%; }
file-cid-2
@charset \\"utf-8\\";
.c01x { opacity: 1; transform: translateX(0px); }
.css-added-at-400 { border: 1px solid blue; }
file-cid-3
@charset \\"utf-8\\";
.css-1uxxxx3 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }
.css-1c9xxxx { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }
.css-added-at-1000-deleted-at-2500 { display: flex; flex-direction: column; min-width: 60rem; min-height: 100vh; color: blue; }
.css-lsxxx { padding-left: 4rem; }
"
`;
exports[`style-sheet-rule-events-play-at-2500 1`] = `
"file-frame-4
<html>
<head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
</head>
<body>
<div class=\\"replayer-wrapper\\">
<div class=\\"replayer-mouse\\"></div>
<canvas
class=\\"replayer-mouse-tail\\"
width=\\"1000\\"
height=\\"800\\"
style=\\"display: inherit\\"
></canvas
><iframe
sandbox=\\"allow-same-origin\\"
scrolling=\\"no\\"
width=\\"1000\\"
height=\\"800\\"
style=\\"display: inherit; pointer-events: none\\"
></iframe>
</div>
</body>
</html>
file-frame-5
<!DOCTYPE html>
<html lang=\\"en\\">
<head>
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-0\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-1\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-2\\" />
<link rel=\\"stylesheet\\" type=\\"text/css\\" href=\\"file-cid-3\\" />
</head>
<body>
<a class=\\"css-added-at-1000-deleted-at-2500\\">string</a>
</body>
</html>
file-cid-0
@charset \\"utf-8\\";
.rr-block { background: rgb(204, 204, 204); }
noscript { display: none !important; }
html.rrweb-paused * { animation-play-state: paused !important; }
file-cid-1
@charset \\"utf-8\\";
.c011xx { padding: 1.3125rem; flex: 0 0 auto; width: 100%; }
file-cid-2
@charset \\"utf-8\\";
.c01x { opacity: 1; transform: translateX(0px); }
.css-added-at-400 { border: 1px solid blue; }
file-cid-3
@charset \\"utf-8\\";
.css-1uxxxx3 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }
.css-1c9xxxx { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }
.css-lsxxx { padding-left: 4rem; }
"
`;

View File

@@ -0,0 +1,189 @@
import { EventType, eventWithTime, IncrementalSource } from '../../src/types';
const now = Date.now();
const events: eventWithTime[] = [
{
type: EventType.DomContentLoaded,
data: {},
timestamp: now,
},
{
type: EventType.Load,
data: {},
timestamp: now + 100,
},
{
type: EventType.Meta,
data: {
href: 'http://localhost',
width: 1000,
height: 800,
},
timestamp: now + 100,
},
// full snapshot:
{
data: {
node: {
id: 1,
type: 0,
childNodes: [
{ id: 2, name: 'html', type: 1, publicId: '', systemId: '' },
{
id: 3,
type: 2,
tagName: 'html',
attributes: { lang: 'en' },
childNodes: [
{
id: 4,
type: 2,
tagName: 'head',
attributes: {},
childNodes: [
{
id: 101,
type: 2,
tagName: 'style',
attributes: {
'data-meta': 'from full-snapshot, gets rule added at 500',
},
childNodes: [
{
id: 102,
type: 3,
isStyle: true,
textContent:
'\n.c01x {\n opacity: 1;\n transform: translateX(0);\n}\n',
},
],
},
{
id: 105,
type: 2,
tagName: 'style',
attributes: {
_cssText:
'.css-1uxxxx3 { position: fixed; top: 0px; right: 0px; left: 4rem; z-index: 15; flex-shrink: 0; height: 0.25rem; overflow: hidden; background-color: rgb(17, 171, 209); }.css-1c9xxxx { height: 0.25rem; background-color: rgb(190, 232, 242); opacity: 0; transition: opacity 0.5s ease 0s; }.css-lsxxx { padding-left: 4rem; }',
'data-emotion': 'css',
},
childNodes: [
{ id: 106, type: 3, isStyle: true, textContent: '' },
],
},
],
},
{
id: 107,
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{
id: 108,
type: 2,
tagName: 'a',
attributes: {
class: 'css-added-at-1000-deleted-at-2500',
},
childNodes: [
{
id: 109,
type: 3,
textContent: 'string',
},
],
},
],
},
],
},
],
},
initialOffset: { top: 0, left: 0 },
},
type: EventType.FullSnapshot,
timestamp: now + 100,
},
// mutation that adds style rule to existing stylesheet
{
data: {
id: 101,
adds: [
{
rule: '.css-added-at-400{border: 1px solid blue;}',
index: 1,
},
],
source: IncrementalSource.StyleSheetRule,
},
type: EventType.IncrementalSnapshot,
timestamp: now + 400,
},
// mutation that adds stylesheet
{
data: {
adds: [
{
node: {
id: 255,
type: 2,
tagName: 'style',
attributes: { 'data-jss': '', 'data-meta': 'Col, Themed, Dynamic' },
childNodes: [],
},
nextId: 101,
parentId: 4,
},
{
node: {
id: 256,
type: 3,
isStyle: true,
textContent:
'\n.c011xx {\n padding: 1.3125rem;\n flex: none;\n width: 100%;\n}\n',
},
nextId: null,
parentId: 255,
},
],
texts: [],
source: IncrementalSource.Mutation,
removes: [],
attributes: [],
},
type: EventType.IncrementalSnapshot,
timestamp: now + 500,
},
// adds StyleSheetRule
{
data: {
id: 105,
adds: [
{
rule:
'.css-added-at-1000-deleted-at-2500{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;min-width:60rem;min-height:100vh;color:blue;}',
index: 2,
},
],
source: IncrementalSource.StyleSheetRule,
},
type: EventType.IncrementalSnapshot,
timestamp: now + 1000,
},
{
data: {
id: 105,
removes: [
{
index: 2,
},
],
source: IncrementalSource.StyleSheetRule,
},
type: EventType.IncrementalSnapshot,
timestamp: now + 2500,
},
];
export default events;

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Block record</title>
</head>
<body>
<div class="rr-block" style="width: 50px; height: 50px;">
<input type="text" /> <span id="text"></span>
</div>
</body>
</html>

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>canvas</title>
</head>
<body>
<canvas
id="myCanvas"
width="200"
height="100"
style="border: 1px solid #000000;"
>
</canvas>
<script>
var c = document.getElementById('myCanvas');
var ctx = c.getContext('2d');
// Create gradient
var grd = ctx.createLinearGradient(0, 0, 200, 0);
grd.addColorStop(0, 'red');
grd.addColorStop(1, 'white');
// Fill with gradient
ctx.fillStyle = grd;
ctx.fillRect(10, 10, 150, 80);
setTimeout(() => {
ctx.moveTo(0, 0);
ctx.lineTo(200, 100);
ctx.stroke();
}, 10);
</script>
</body>
</html>

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>form fields</title>
</head>
<body>
<form>
<label for="text">
<input type="text" />
</label>
<label>
<input type="radio" name="toggle" value="on" />
</label>
<label>
<input type="radio" name="toggle" value="off" checked />
</label>
<label for="checkbox">
<input type="checkbox" />
</label>
<label for="textarea">
<textarea name="" id="" cols="30" rows="10"></textarea>
</label>
<label for="select">
<select name="" id="">
<option value="1">1</option>
<option value="2">2</option>
</select>
</label>
<label for="password">
<input type="password" />
</label>
</form>
</body>
</html>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frame 1</title>
</head>
<body>
frame 1
<iframe id="three" frameborder="0"></iframe>
<iframe id="four" src="./frame2.html" frameborder="0"></iframe>
</body>
</html>

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Frame 2</title>
</head>
<body>
frame 2
</body>
<script>
const iframe5 = document.createElement('iframe');
iframe5.id = 'five';
setTimeout(() => {
document.body.appendChild(iframe5);
}, 10);
</script>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>ignore fields</title>
</head>
<body>
<form>
<label for="ignore text"> <input type="text" class="rr-ignore" /> </label>
</form>
</body>
</html>

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Log record</title>
</head>
<body></body>
</html>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Main</title>
<style>
iframe {
width: 500px;
height: 500px;
}
</style>
</head>
<body>
<iframe id="one"></iframe>
</body>
<script>
const iframe2 = document.createElement('iframe');
iframe2.id = 'two';
iframe2.src = './html/frame1.html';
setTimeout(() => {
document.body.appendChild(iframe2);
}, 10);
</script>
</html>

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Mask text</title>
</head>
<body>
<p class="rr-mask">mask1</p>
<div class="rr-mask">
<span>mask2</span>
</div>
<div data-masking="true">
<div>
<div>mask3</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<html>
<body>
<div>
<p></p>
</div>
<span>
<i>
<b>1</b>
</i>
</span>
</body>
</html>

View File

@@ -0,0 +1,6 @@
<body>
<p>mutation observer</p>
<ul>
<li></li>
</ul>
</body>

View File

@@ -0,0 +1,17 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<input type="password" id="password" />
<script>
const password = document.getElementById('password');
password.addEventListener('keyup', (event) => {
password.setAttribute('value', password.value);
});
</script>
</body>
</html>

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>react styled components</title>
</head>
<body>
<div id="app"></div>
<script src="https://cdn.jsdelivr.net/npm/react@16/umd/react.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@16/umd/react-dom.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/react-is@16.13.1/umd/react-is.production.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/styled-components@5.0.1/dist/styled-components.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babel-standalone@6/babel.min.js"></script>
<script type="text/babel">
const e = React.createElement;
const Title = styled.h1`
font-size: 1.5em;
text-align: center;
color: ${(props) => props.color || 'palevioletred'};
`;
const Wrapper = styled.section`
padding: 4em;
background: papayawhip;
`;
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
color: 'rebeccapurple',
};
}
toggle = () => {
this.setState(({ color }) => ({
color: color === 'rebeccapurple' ? 'pink' : 'rebeccapurple',
}));
};
render() {
return (
<styled.StyleSheetManager disableCSSOMInjection={false}>
<Wrapper>
<Title>Hello World!</Title>
<Title
className="toggle"
color={this.state.color}
onClick={this.toggle}
>
Hello World!
</Title>
</Wrapper>
</styled.StyleSheetManager>
);
}
}
const domContainer = document.querySelector('#app');
ReactDOM.render(e(MyComponent), domContainer);
</script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Select2 3.5</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2@3.5.1/select2.css">
</head>
<body>
<blockquote>
Select2 is a jQuery replacement for select boxes.
<br>
In the 3.5 version it use a quite complicated DOM generation strategy which is a good battle-test for rrweb's recorder.
</blockquote>
<select id="el">
<option value="a">A</option>
<option value="b">B</option>
</select>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@3.5.2-browserify/select2.min.js"></script>
<script>
$('#el').select2();
</script>
</body>
</html>

View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadow DOM Observer</title>
<style>
.my-element {
margin: 0 0 1rem 0;
}
iframe {
border: 0;
width: 100%;
padding: 0;
}
body {
max-width: 400px;
margin: 1rem auto;
padding: 0 1rem;
font-family: 'comic sans ms';
}
</style>
</head>
<body>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Repellat odit
officiis necessitatibus laborum asperiores et adipisci dolores corporis,
vero distinctio voluptas, suscipit commodi architecto, aliquam fugit.
Nesciunt labore reiciendis blanditiis!
</p>
<div class="my-element">
<!-- Also could be a
<custom-element />
-->
</div>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Repellat odit
officiis necessitatibus laborum asperiores et adipisci dolores corporis,
vero distinctio voluptas, suscipit commodi architecto, aliquam fugit.
Nesciunt labore reiciendis blanditiis!
</p>
<script>
let content = `
<style>
body { /* for fallback iframe */
margin: 0;
}
p {
border: 1px solid #ccc;
padding: 1rem;
color: red;
font-family: sans-serif;
}
</style>
<p>Element with Shadow DOM</p>
`;
let myElements = document.querySelectorAll('.my-element');
if (document.body.attachShadow) {
myElements.forEach((el) => {
var shadow = el.attachShadow({
mode: 'open',
});
shadow.innerHTML = content;
});
} else {
let newiframe = document.createElement('iframe');
newiframe.srcdoc = content;
myElements.forEach((el) => {
let parent = el.parentNode;
parent.replaceChild(newiframe, el);
});
}
</script>
</script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>shuffle</title>
</head>
<body>
<!-- prettier-ignore -->
<ul><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li></ul>
</body>
</html>

View File

@@ -0,0 +1,552 @@
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as url from 'url';
import * as puppeteer from 'puppeteer';
import { assertSnapshot, launchPuppeteer } from './utils';
import { Suite } from 'mocha';
import { expect } from 'chai';
import { recordOptions, eventWithTime, EventType } from '../src/types';
import { visitSnapshot, NodeType } from 'rrweb-snapshot';
interface ISuite extends Suite {
server: http.Server;
code: string;
browser: puppeteer.Browser;
}
interface IMimeType {
[key: string]: string;
}
const server = () =>
new Promise<http.Server>((resolve) => {
const mimeType: IMimeType = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
};
const s = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url!);
const sanitizePath = path
.normalize(parsedUrl.pathname!)
.replace(/^(\.\.[\/\\])+/, '');
let pathname = path.join(__dirname, sanitizePath);
try {
const data = fs.readFileSync(pathname);
const ext = path.parse(pathname).ext;
res.setHeader('Content-type', mimeType[ext] || 'text/plain');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET');
res.setHeader('Access-Control-Allow-Headers', 'Content-type');
setTimeout(() => {
res.end(data);
// mock delay
}, 100);
} catch (error) {
res.end();
}
});
s.listen(3030).on('listening', () => {
resolve(s);
});
});
describe('record integration tests', function (this: ISuite) {
this.timeout(10_000);
const getHtml = (
fileName: string,
options: recordOptions<eventWithTime> = {},
): string => {
const filePath = path.resolve(__dirname, `./html/${fileName}`);
const html = fs.readFileSync(filePath, 'utf8');
return html.replace(
'</body>',
`
<script>
${this.code}
window.Date.now = () => new Date(Date.UTC(2018, 10, 15, 8)).valueOf();
window.snapshots = [];
rrweb.record({
emit: event => {
window.snapshots.push(event);
},
maskTextSelector: ${JSON.stringify(options.maskTextSelector)},
maskAllInputs: ${options.maskAllInputs},
maskInputOptions: ${JSON.stringify(options.maskAllInputs)},
userTriggeredOnInput: ${options.userTriggeredOnInput},
maskTextFn: ${options.maskTextFn},
recordCanvas: ${options.recordCanvas},
plugins: ${options.plugins}
});
</script>
</body>
`,
);
};
before(async () => {
this.server = await server();
this.browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
const pluginsCode = [
path.resolve(__dirname, '../dist/plugins/console-record.min.js'),
]
.map((path) => fs.readFileSync(path, 'utf8'))
.join();
this.code = fs.readFileSync(bundlePath, 'utf8') + pluginsCode;
});
after(async () => {
await this.browser.close();
this.server.close();
});
it('can record form interactions', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'form.html'));
await page.type('input[type="text"]', 'test');
await page.click('input[type="radio"]');
await page.click('input[type="checkbox"]');
await page.type('textarea', 'textarea test');
await page.select('select', '1');
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'form');
});
it('can record childList mutations', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
await page.evaluate(() => {
const li = document.createElement('li');
const ul = document.querySelector('ul') as HTMLUListElement;
ul.appendChild(li);
document.body.removeChild(ul);
const p = document.querySelector('p') as HTMLParagraphElement;
p.appendChild(document.createElement('span'));
});
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'child-list');
});
it('can record character data muatations', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
await page.evaluate(() => {
const li = document.createElement('li');
const ul = document.querySelector('ul') as HTMLUListElement;
ul.appendChild(li);
li.innerText = 'new list item';
li.innerText = 'new list item edit';
document.body.removeChild(ul);
const p = document.querySelector('p') as HTMLParagraphElement;
p.innerText = 'mutated';
});
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'character-data');
});
it('can record attribute mutation', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
await page.evaluate(() => {
const li = document.createElement('li');
const ul = document.querySelector('ul') as HTMLUListElement;
ul.appendChild(li);
li.setAttribute('foo', 'bar');
document.body.removeChild(ul);
document.body.setAttribute('test', 'true');
});
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'attributes');
});
it('can record node mutations', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'select2.html'), {
waitUntil: 'networkidle0',
});
// toggle the select box
await page.click('.select2-container', { clickCount: 2, delay: 100 });
// test storage of !important style
await page.evaluate('document.getElementById("select2-drop").setAttribute("style", document.getElementById("select2-drop").style.cssText + "color:black !important")');
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'select2');
});
it('can freeze mutations', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
await page.evaluate(() => {
const li = document.createElement('li');
const ul = document.querySelector('ul') as HTMLUListElement;
ul.appendChild(li);
li.setAttribute('foo', 'bar');
document.body.setAttribute('test', 'true');
});
await page.evaluate('rrweb.freezePage()');
await page.evaluate(() => {
document.body.setAttribute('test', 'bad');
const ul = document.querySelector('ul') as HTMLUListElement;
const li = document.createElement('li');
li.setAttribute('bad-attr', 'bad');
li.innerText = 'bad text';
ul.appendChild(li);
document.body.removeChild(ul);
});
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'frozen');
});
it('should not record input events on ignored elements', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'ignore.html'));
await page.type('.rr-ignore', 'secret');
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'ignore');
});
it('should not record input values if maskAllInputs is enabled', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'form.html', { maskAllInputs: true }),
);
await page.type('input[type="text"]', 'test');
await page.click('input[type="radio"]');
await page.click('input[type="checkbox"]');
await page.type('input[type="password"]', 'password');
await page.type('textarea', 'textarea test');
await page.select('select', '1');
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'mask');
});
it('can use maskInputOptions to configure which type of inputs should be masked', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'form.html', {
maskInputOptions: {
text: false,
textarea: false,
password: true,
},
}),
);
await page.type('input[type="text"]', 'test');
await page.click('input[type="radio"]');
await page.click('input[type="checkbox"]');
await page.type('textarea', 'textarea test');
await page.type('input[type="password"]', 'password');
await page.select('select', '1');
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'maskInputOptions');
});
it('should mask value attribute with maskInputOptions', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'password.html', {
maskInputOptions: {
password: true,
},
}),
);
await page.type('input[type="password"]', 'secr3t');
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'maskPassword');
});
it('should record input userTriggered values if userTriggeredOnInput is enabled', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'form.html', { userTriggeredOnInput: true }),
);
await page.type('input[type="text"]', 'test');
await page.click('input[type="radio"]');
await page.click('input[type="checkbox"]');
await page.type('input[type="password"]', 'password');
await page.type('textarea', 'textarea test');
await page.select('select', '1');
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'userTriggered');
});
it('should not record blocked elements and its child nodes', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'block.html'));
await page.type('input', 'should not be record');
await page.evaluate(`document.getElementById('text').innerText = '1'`);
await page.click('#text');
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'block');
});
it('should record DOM node movement 1', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'move-node.html'));
await page.evaluate(() => {
const div = document.querySelector('div')!;
const p = document.querySelector('p')!;
const span = document.querySelector('span')!;
document.body.removeChild(span);
p.appendChild(span);
p.removeChild(span);
div.appendChild(span);
});
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'move-node-1');
});
it('should record DOM node movement 2', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'move-node.html'));
await page.evaluate(() => {
const div = document.createElement('div');
const span = document.querySelector('span')!;
document.body.appendChild(div);
div.appendChild(span);
});
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'move-node-2');
});
it('should record dynamic CSS changes', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'react-styled-components.html'));
await page.click('.toggle');
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'react-styled-components');
});
it('should record canvas mutations', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'canvas.html', {
recordCanvas: true,
}),
);
await page.waitForTimeout(50);
const snapshots = await page.evaluate('window.snapshots');
for (const event of snapshots) {
if (event.type === EventType.FullSnapshot) {
visitSnapshot(event.data.node, (n) => {
if (n.type === NodeType.Element && n.attributes.rr_dataURL) {
n.attributes.rr_dataURL = `LOOKS LIKE WE COULD NOT GET STABLE BASE64 FROM SAME IMAGE.`;
}
});
}
}
assertSnapshot(snapshots, __filename, 'canvas');
});
it('will serialize node before record', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
await page.evaluate(() => {
const ul = document.querySelector('ul') as HTMLUListElement;
let count = 3;
while (count > 0) {
count--;
const li = document.createElement('li');
ul.appendChild(li);
}
});
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'serialize-before-record');
});
it('will defer missing next node mutation', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'shuffle.html'));
const text = await page.evaluate(() => {
const els = Array.prototype.slice.call(document.querySelectorAll('li'));
const parent = document.querySelector('ul')!;
parent.removeChild(els[3]);
parent.removeChild(els[2]);
parent.removeChild(els[1]);
parent.removeChild(els[0]);
parent.insertBefore(els[3], els[4]);
parent.insertBefore(els[2], els[4]);
parent.insertBefore(els[1], els[4]);
parent.insertBefore(els[0], els[4]);
return parent.innerText;
});
expect(text).to.equal('4\n3\n2\n1\n5');
});
it('should record console messages', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'log.html', {
plugins: '[rrwebConsoleRecord.getRecordConsolePlugin()]',
}),
);
await page.evaluate(() => {
console.assert(0 == 0, 'assert');
console.count('count');
console.countReset('count');
console.debug('debug');
console.dir('dir');
console.dirxml('dirxml');
console.group();
console.groupCollapsed();
console.info('info');
console.log('log');
console.table('table');
console.time();
console.timeEnd();
console.timeLog();
console.trace('trace');
console.warn('warn');
console.clear();
});
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'log');
});
it('should nest record iframe', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto(`http://localhost:3030/html`);
await page.setContent(getHtml.call(this, 'main.html'));
await page.waitForTimeout(500);
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'iframe');
});
it('should record shadow DOM', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'shadow-dom.html'));
await page.evaluate(() => {
const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
const el = document.querySelector('.my-element') as HTMLDivElement;
const shadowRoot = el.shadowRoot as ShadowRoot;
shadowRoot.appendChild(document.createElement('p'));
sleep(1)
.then(() => {
shadowRoot.lastChild!.appendChild(document.createElement('p'));
return sleep(1);
})
.then(() => {
const firstP = shadowRoot.querySelector('p') as HTMLParagraphElement;
shadowRoot.removeChild(firstP);
return sleep(1);
})
.then(() => {
(shadowRoot.lastChild!.childNodes[0] as HTMLElement).innerText = 'hi';
return sleep(1);
})
.then(() => {
(shadowRoot.lastChild!.childNodes[0] as HTMLElement).innerText =
'123';
});
});
await page.waitForTimeout(50);
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'shadow-dom');
});
it('should mask texts', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'mask-text.html', {
maskTextSelector: '[data-masking="true"]',
}),
);
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'mask-text');
});
it('should mask texts using maskTextFn', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'mask-text.html', {
maskTextSelector: '[data-masking="true"]',
maskTextFn: (t: string) => t.replace(/[a-z]/g, '*'),
}),
);
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'mask-text-fn');
});
it('can mask character data mutations', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
await page.evaluate(() => {
const li = document.createElement('li');
const ul = document.querySelector('ul') as HTMLUListElement;
const p = document.querySelector('p') as HTMLParagraphElement;
[li, p].forEach((element) => {
element.className = 'rr-mask';
});
ul.appendChild(li);
li.innerText = 'new list item';
p.innerText = 'mutated';
});
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'mask-character-data');
});
});

View File

@@ -0,0 +1,48 @@
import { expect } from 'chai';
import { discardPriorSnapshots } from '../src/replay/machine';
import { sampleEvents } from './utils';
import { EventType } from '../src/types';
const events = sampleEvents.filter(
(e) => ![EventType.DomContentLoaded, EventType.Load].includes(e.type),
);
const nextEvents = events.map((e) => ({
...e,
timestamp: e.timestamp + 1000,
}));
const nextNextEvents = nextEvents.map((e) => ({
...e,
timestamp: e.timestamp + 1000,
}));
describe('get last session', () => {
it('will return all the events when there is only one session', () => {
expect(discardPriorSnapshots(events, events[0].timestamp)).to.deep.equal(events);
});
it('will return last session when there is more than one in the events', () => {
const multiple = events.concat(nextEvents).concat(nextNextEvents);
expect(
discardPriorSnapshots(
multiple,
nextNextEvents[nextNextEvents.length - 1].timestamp,
),
).to.deep.equal(nextNextEvents);
});
it('will return last session when baseline time is future time', () => {
const multiple = events.concat(nextEvents).concat(nextNextEvents);
expect(
discardPriorSnapshots(
multiple,
nextNextEvents[nextNextEvents.length - 1].timestamp + 1000,
),
).to.deep.equal(nextNextEvents);
});
it('will return all sessions when baseline time is prior time', () => {
expect(discardPriorSnapshots(events, events[0].timestamp - 1000)).to.deep.equal(
events,
);
});
});

View File

@@ -0,0 +1,44 @@
import { expect } from 'chai';
import { matchSnapshot } from './utils';
import { pack, unpack } from '../src/packer';
import { eventWithTime, EventType } from '../src/types';
import { MARK } from '../src/packer/base';
const event: eventWithTime = {
type: EventType.DomContentLoaded,
data: {},
timestamp: new Date('2020-01-01').getTime(),
};
describe('pack', () => {
it('can pack event', () => {
const packedData = pack(event);
const result = matchSnapshot(packedData, __filename, 'pack');
expect(result.pass).to.true;
});
});
describe('unpack', () => {
it('is compatible with unpacked data 1', () => {
const result = unpack((event as unknown) as string);
expect(result).to.deep.equal(event);
});
it('is compatible with unpacked data 2', () => {
const result = unpack(JSON.stringify(event));
expect(result).to.deep.equal(event);
});
it('stop on unknown data format', () => {
expect(() => unpack('[""]')).to.throw('');
});
it('can unpack packed data', () => {
const packedData = pack(event);
const result = unpack(packedData);
expect(result).to.deep.equal({
...event,
v: MARK,
});
});
});

View File

@@ -0,0 +1,292 @@
/* tslint:disable no-console */
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import { expect } from 'chai';
import {
recordOptions,
listenerHandler,
eventWithTime,
EventType,
IncrementalSource,
styleSheetRuleData,
} from '../src/types';
import { assertSnapshot, launchPuppeteer } from './utils';
import { Suite } from 'mocha';
interface ISuite extends Suite {
code: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
events: eventWithTime[];
}
interface IWindow extends Window {
rrweb: {
record: (
options: recordOptions<eventWithTime>,
) => listenerHandler | undefined;
addCustomEvent<T>(tag: string, payload: T): void;
};
emit: (e: eventWithTime) => undefined;
}
const setup = async function (this: ISuite, content: string) {
before(async () => {
this.browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
this.code = fs.readFileSync(bundlePath, 'utf8');
});
beforeEach(async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(content);
await page.evaluate(this.code);
this.page = page;
this.events = [];
await this.page.exposeFunction('emit', (e: eventWithTime) => {
if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) {
return;
}
this.events.push(e);
});
page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
});
afterEach(async () => {
await this.page.close();
});
after(async () => {
await this.browser.close();
});
};
describe('record', function (this: ISuite) {
this.timeout(10_000);
setup.call(
this,
`
<html>
<body>
<input type="text" size="40" />
</body>
</html>
`,
);
it('will only have one full snapshot without checkout config', async () => {
await this.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
});
});
let count = 30;
while (count--) {
await this.page.type('input', 'a');
}
await this.page.waitForTimeout(10);
expect(this.events.length).to.equal(33);
expect(
this.events.filter(
(event: eventWithTime) => event.type === EventType.Meta,
).length,
).to.equal(1);
expect(
this.events.filter(
(event: eventWithTime) => event.type === EventType.FullSnapshot,
).length,
).to.equal(1);
});
it('can checkout full snapshot by count', async () => {
await this.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
checkoutEveryNth: 10,
});
});
let count = 30;
while (count--) {
await this.page.type('input', 'a');
}
await this.page.waitForTimeout(10);
expect(this.events.length).to.equal(39);
expect(
this.events.filter(
(event: eventWithTime) => event.type === EventType.Meta,
).length,
).to.equal(4);
expect(
this.events.filter(
(event: eventWithTime) => event.type === EventType.FullSnapshot,
).length,
).to.equal(4);
expect(this.events[1].type).to.equal(EventType.FullSnapshot);
expect(this.events[13].type).to.equal(EventType.FullSnapshot);
expect(this.events[25].type).to.equal(EventType.FullSnapshot);
expect(this.events[37].type).to.equal(EventType.FullSnapshot);
});
it('can checkout full snapshot by time', async () => {
await this.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
checkoutEveryNms: 500,
});
});
let count = 30;
while (count--) {
await this.page.type('input', 'a');
}
await this.page.waitForTimeout(300);
expect(this.events.length).to.equal(33); // before first automatic snapshot
await this.page.waitForTimeout(200); // could be 33 or 35 events by now depending on speed of test env
await this.page.type('input', 'a');
await this.page.waitForTimeout(10);
expect(this.events.length).to.equal(36); // additionally includes the 2 checkout events
expect(
this.events.filter(
(event: eventWithTime) => event.type === EventType.Meta,
).length,
).to.equal(2);
expect(
this.events.filter(
(event: eventWithTime) => event.type === EventType.FullSnapshot,
).length,
).to.equal(2);
expect(this.events[1].type).to.equal(EventType.FullSnapshot);
expect(this.events[35].type).to.equal(EventType.FullSnapshot);
});
it('is safe to checkout during async callbacks', async () => {
await this.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
checkoutEveryNth: 2,
});
const p = document.createElement('p');
const span = document.createElement('span');
setTimeout(() => {
document.body.appendChild(p);
p.appendChild(span);
document.body.removeChild(document.querySelector('input')!);
}, 0);
setTimeout(() => {
span.innerText = 'test';
}, 10);
setTimeout(() => {
p.removeChild(span);
document.body.appendChild(span);
}, 10);
});
await this.page.waitForTimeout(100);
assertSnapshot(this.events, __filename, 'async-checkout');
});
it('can add custom event', async () => {
await this.page.evaluate(() => {
const { record, addCustomEvent } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
});
addCustomEvent<number>('tag1', 1);
addCustomEvent<{ a: string }>('tag2', {
a: 'b',
});
});
await this.page.waitForTimeout(50);
assertSnapshot(this.events, __filename, 'custom-event');
});
it('captures stylesheet rules', async () => {
await this.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
});
const styleElement = document.createElement('style');
document.head.appendChild(styleElement);
const styleSheet = <CSSStyleSheet>styleElement.sheet;
const ruleIdx0 = styleSheet.insertRule('body { background: #000; }');
const ruleIdx1 = styleSheet.insertRule('body { background: #111; }');
styleSheet.deleteRule(ruleIdx1);
setTimeout(() => {
styleSheet.insertRule('body { color: #fff; }');
}, 0);
setTimeout(() => {
styleSheet.deleteRule(ruleIdx0);
}, 5);
setTimeout(() => {
styleSheet.insertRule('body { color: #ccc; }');
}, 10);
});
await this.page.waitForTimeout(50);
const styleSheetRuleEvents = this.events.filter(
(e) =>
e.type === EventType.IncrementalSnapshot &&
e.data.source === IncrementalSource.StyleSheetRule,
);
const addRuleCount = styleSheetRuleEvents.filter((e) =>
Boolean((e.data as styleSheetRuleData).adds),
).length;
const removeRuleCount = styleSheetRuleEvents.filter((e) =>
Boolean((e.data as styleSheetRuleData).removes),
).length;
// sync insert/delete should be ignored
expect(addRuleCount).to.equal(2);
expect(removeRuleCount).to.equal(1);
assertSnapshot(this.events, __filename, 'stylesheet-rules');
});
});
describe('record iframes', function (this: ISuite) {
this.timeout(10_000);
setup.call(
this,
`
<html>
<body>
<iframe srcdoc="<button>Mysterious Button</button>" />
</body>
</html>
`,
);
it('captures iframe content in correct order', async () => {
await this.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
emit: ((window as unknown) as IWindow).emit,
});
});
await this.page.waitForTimeout(10);
// console.log(JSON.stringify(this.events));
expect(this.events.length).to.equal(3);
const eventTypes = this.events
.filter(
(e) =>
e.type === EventType.IncrementalSnapshot ||
e.type === EventType.FullSnapshot,
)
.map((e) => e.type);
expect(eventTypes).to.have.ordered.members([
EventType.FullSnapshot,
EventType.IncrementalSnapshot,
]);
});
});

View File

@@ -0,0 +1,113 @@
import { expect } from 'chai';
import { JSDOM } from 'jsdom';
import {
applyVirtualStyleRulesToNode,
StyleRuleType,
VirtualStyleRules,
} from '../../src/replay/virtual-styles';
describe('virtual styles', () => {
describe('applyVirtualStyleRulesToNode', () => {
it('should insert rule at index 0 in empty sheet', () => {
const dom = new JSDOM(`
<style></style>
`);
const styleEl = dom.window.document.getElementsByTagName('style')[0];
const cssText = '.added-rule {border: 1px solid yellow;}';
const virtualStyleRules: VirtualStyleRules = [
{ cssText, index: 0, type: StyleRuleType.Insert },
];
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
expect(styleEl.sheet?.cssRules?.length).to.equal(1);
expect(styleEl.sheet?.cssRules[0].cssText).to.equal(cssText);
});
it('should insert rule at index 0 and keep exsisting rules', () => {
const dom = new JSDOM(`
<style>
a {color: blue}
div {color: black}
</style>
`);
const styleEl = dom.window.document.getElementsByTagName('style')[0];
const cssText = '.added-rule {border: 1px solid yellow;}';
const virtualStyleRules: VirtualStyleRules = [
{ cssText, index: 0, type: StyleRuleType.Insert },
];
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
expect(styleEl.sheet?.cssRules?.length).to.equal(3);
expect(styleEl.sheet?.cssRules[0].cssText).to.equal(cssText);
});
it('should delete rule at index 1', () => {
const dom = new JSDOM(`
<style>
a {color: blue;}
div {color: black;}
</style>
`);
const styleEl = dom.window.document.getElementsByTagName('style')[0];
const virtualStyleRules: VirtualStyleRules = [
{ index: 0, type: StyleRuleType.Remove },
];
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
expect(styleEl.sheet?.cssRules?.length).to.equal(1);
expect(styleEl.sheet?.cssRules[0].cssText).to.equal(
'div {color: black;}',
);
});
it('should restore a snapshot by inserting missing rules', () => {
const dom = new JSDOM(`
<style>
a {color: blue;}
.deleted-rule {color: pink;}
div {color: black;}
</style>
`);
const styleEl = dom.window.document.getElementsByTagName('style')[0];
const virtualStyleRules: VirtualStyleRules = [
{
cssTexts: ['a {color: blue;}', 'div {color: black;}'],
type: StyleRuleType.Snapshot,
},
];
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
expect(styleEl.sheet?.cssRules?.length).to.equal(2);
});
it('should restore a snapshot by fixing order of rules', () => {
const dom = new JSDOM(`
<style>
div {color: black;}
a {color: blue;}
</style>
`);
const styleEl = dom.window.document.getElementsByTagName('style')[0];
const cssTexts = ['a {color: blue;}', 'div {color: black;}'];
const virtualStyleRules: VirtualStyleRules = [
{
cssTexts,
type: StyleRuleType.Snapshot,
},
];
applyVirtualStyleRulesToNode(virtualStyleRules, styleEl);
expect(styleEl.sheet?.cssRules?.length).to.equal(2);
expect(
Array.from(styleEl.sheet?.cssRules || []).map((rule) => rule.cssText),
).to.have.ordered.members(cssTexts);
});
});
});

View File

@@ -0,0 +1,250 @@
/* tslint:disable no-string-literal no-console */
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import { expect } from 'chai';
import { Suite } from 'mocha';
import {
assertDomSnapshot,
launchPuppeteer,
sampleEvents as events,
sampleStyleSheetRemoveEvents as stylesheetRemoveEvents,
} from './utils';
import styleSheetRuleEvents from './events/style-sheet-rule-events';
interface ISuite extends Suite {
code: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
}
describe('replayer', function (this: ISuite) {
this.timeout(10_000);
before(async () => {
this.browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
this.code = fs.readFileSync(bundlePath, 'utf8');
});
beforeEach(async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.evaluate(this.code);
await page.evaluate(`let events = ${JSON.stringify(events)}`);
this.page = page;
page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
});
afterEach(async () => {
await this.page.close();
});
after(async () => {
await this.browser.close();
});
it('can get meta data', async () => {
const meta = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.getMetaData();
`);
expect(meta).to.deep.equal({
startTime: events[0].timestamp,
endTime: events[events.length - 1].timestamp,
totalTime: events[events.length - 1].timestamp - events[0].timestamp,
});
});
it('will start actions when play', async () => {
const actionLength = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play();
replayer['timer']['actions'].length;
`);
expect(actionLength).to.equal(events.length);
});
it('will clean actions when pause', async () => {
const actionLength = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play();
replayer.pause();
replayer['timer']['actions'].length;
`);
expect(actionLength).to.equal(0);
});
it('can play at any time offset', async () => {
const actionLength = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(1500);
replayer['timer']['actions'].length;
`);
expect(actionLength).to.equal(
events.filter((e) => e.timestamp - events[0].timestamp >= 1500).length,
);
});
it('can play a second time in the future', async () => {
const actionLength = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(500);
replayer.play(1500);
replayer['timer']['actions'].length;
`);
expect(actionLength).to.equal(
events.filter((e) => e.timestamp - events[0].timestamp >= 1500).length,
);
});
it('can play a second time to the past', async () => {
const actionLength = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(1500);
replayer.play(500);
replayer['timer']['actions'].length;
`);
expect(actionLength).to.equal(
events.filter((e) => e.timestamp - events[0].timestamp >= 500).length,
);
});
it('can pause at any time offset', async () => {
const actionLength = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(2500);
replayer['timer']['actions'].length;
`);
const currentTime = await this.page.evaluate(`
replayer.getCurrentTime();
`);
const currentState = await this.page.evaluate(`
replayer['service']['state']['value'];
`);
expect(actionLength).to.equal(0);
expect(currentTime).to.equal(2500);
expect(currentState).to.equal('paused');
});
it('can fast forward past StyleSheetRule changes on virtual elements', async () => {
await this.page.evaluate(
`events = ${JSON.stringify(styleSheetRuleEvents)}`,
);
const actionLength = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(1500);
replayer['timer']['actions'].length;
`);
expect(actionLength).to.equal(
styleSheetRuleEvents.filter(
(e) => e.timestamp - styleSheetRuleEvents[0].timestamp >= 1500,
).length,
);
await assertDomSnapshot(
this.page,
__filename,
'style-sheet-rule-events-play-at-1500',
);
});
it('should apply fast forwarded StyleSheetRules that where added', async () => {
await this.page.evaluate(
`events = ${JSON.stringify(styleSheetRuleEvents)}`,
);
const result = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(1500);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-1000-deleted-at-2500');
`);
expect(result).to.equal(true);
});
it('can handle removing style elements', async () => {
await this.page.evaluate(
`events = ${JSON.stringify(stylesheetRemoveEvents)}`,
);
const actionLength = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(2500);
replayer['timer']['actions'].length;
`);
expect(actionLength).to.equal(
stylesheetRemoveEvents.filter(
(e) => e.timestamp - stylesheetRemoveEvents[0].timestamp >= 2500,
).length,
);
await assertDomSnapshot(
this.page,
__filename,
'style-sheet-remove-events-play-at-2500',
);
});
it('can fast forward past StyleSheetRule deletion on virtual elements', async () => {
await this.page.evaluate(
`events = ${JSON.stringify(styleSheetRuleEvents)}`,
);
const actionLength = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.play(2500);
replayer['timer']['actions'].length;
`);
await assertDomSnapshot(
this.page,
__filename,
'style-sheet-rule-events-play-at-2500',
);
});
it('should delete fast forwarded StyleSheetRules that where removed', async () => {
await this.page.evaluate(
`events = ${JSON.stringify(styleSheetRuleEvents)}`,
);
const result = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events);
replayer.pause(3000);
const rules = [...replayer.iframe.contentDocument.styleSheets].map(
(sheet) => [...sheet.rules],
).flat();
rules.some((x) => x.selectorText === '.css-added-at-1000-deleted-at-2500');
`);
expect(result).to.equal(false);
});
it('can stream events in live mode', async () => {
const status = await this.page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events, {
liveMode: true
});
replayer.startLive();
replayer.service.state.value;
`);
expect(status).to.equal('live');
});
});

View File

@@ -0,0 +1,366 @@
import { SnapshotState, toMatchSnapshot } from 'jest-snapshot';
import { NodeType } from 'rrweb-snapshot';
import { assert } from 'chai';
import {
EventType,
IncrementalSource,
eventWithTime,
MouseInteractions,
} from '../src/types';
import * as puppeteer from 'puppeteer';
import { format } from 'prettier';
export async function launchPuppeteer() {
return await puppeteer.launch({
headless: process.env.PUPPETEER_HEADLESS ? true : false,
defaultViewport: {
width: 1920,
height: 1080,
},
args: ['--no-sandbox'],
});
}
export function matchSnapshot(
actual: string,
testFile: string,
testTitle: string,
) {
const snapshotState = new SnapshotState(testFile, {
updateSnapshot: process.env.SNAPSHOT_UPDATE ? 'all' : 'new',
});
const matcher = toMatchSnapshot.bind({
snapshotState,
currentTestName: testTitle,
});
const result = matcher(actual);
snapshotState.save();
return result;
}
/**
* Puppeteer may cast random mouse move which make our tests flaky.
* So we only do snapshot test with filtered events.
* Also remove timestamp from event.
* @param snapshots incrementalSnapshotEvent[]
*/
function stringifySnapshots(snapshots: eventWithTime[]): string {
return JSON.stringify(
snapshots
.filter((s) => {
if (
s.type === EventType.IncrementalSnapshot &&
s.data.source === IncrementalSource.MouseMove
) {
return false;
}
return true;
})
.map((s) => {
if (s.type === EventType.Meta) {
s.data.href = 'about:blank';
}
// FIXME: travis coordinates seems different with my laptop
const coordinatesReg = /(bottom|top|left|right|width|height): \d+(\.\d+)?px/g;
if (
s.type === EventType.IncrementalSnapshot &&
s.data.source === IncrementalSource.MouseInteraction
) {
delete s.data.x;
delete s.data.y;
}
if (
s.type === EventType.IncrementalSnapshot &&
s.data.source === IncrementalSource.Mutation
) {
s.data.attributes.forEach((a) => {
if (
'style' in a.attributes &&
a.attributes.style &&
typeof a.attributes.style === 'object'
) {
for (const [k, v] of Object.entries(a.attributes.style)) {
if (Array.isArray(v)) {
if (coordinatesReg.test(k + ': ' + v[0])) {
// TODO: could round the number here instead depending on what's coming out of various test envs
a.attributes.style[k] = ['Npx', v[1]];
}
} else if (typeof v === 'string') {
if (coordinatesReg.test(k + ': ' + v)) {
a.attributes.style[k] = 'Npx';
}
}
coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript
}
}
});
s.data.adds.forEach((add) => {
if (
add.node.type === NodeType.Element &&
'style' in add.node.attributes &&
typeof add.node.attributes.style === 'string' &&
coordinatesReg.test(add.node.attributes.style)
) {
add.node.attributes.style = add.node.attributes.style.replace(
coordinatesReg,
'$1: Npx',
);
}
coordinatesReg.lastIndex = 0; // wow, a real wart in ECMAScript
});
}
delete s.timestamp;
return s;
}),
null,
2,
);
}
function stringifyDomSnapshot(mhtml: string): string {
const { Parser } = require('fast-mhtml');
const resources: string[] = [];
const p = new Parser({
rewriteFn: (filename: string): string => {
const index = resources.indexOf(filename);
const prefix = /^\w+/.exec(filename);
if (index !== -1) {
return `file-${prefix}-${index}`;
} else {
return `file-${prefix}-${resources.push(filename) - 1}`;
}
},
});
const result = p
.parse(mhtml) // parse file
.rewrite() // rewrite all links
.spit(); // return all contents
const newResult: { filename: string; content: string }[] = result.map(
(asset: { filename: string; content: string }) => {
let { filename, content } = asset;
let res: string | undefined;
if (filename.includes('frame')) {
res = format(content, {
parser: 'html',
});
}
return { filename, content: res || content };
},
);
return newResult.map((asset) => Object.values(asset).join('\n')).join('\n\n');
}
export function assertSnapshot(
snapshots: eventWithTime[],
filename: string,
name: string,
) {
const result = matchSnapshot(stringifySnapshots(snapshots), filename, name);
assert(result.pass, result.pass ? '' : result.report());
}
export async function assertDomSnapshot(
page: puppeteer.Page,
filename: string,
name: string,
) {
const cdp = await page.target().createCDPSession();
const { data } = await cdp.send('Page.captureSnapshot', {
format: 'mhtml',
});
const result = matchSnapshot(stringifyDomSnapshot(data), filename, name);
assert(result.pass, result.pass ? '' : result.report());
}
const now = Date.now();
export const sampleEvents: eventWithTime[] = [
{
type: EventType.DomContentLoaded,
data: {},
timestamp: now,
},
{
type: EventType.Load,
data: {},
timestamp: now + 1000,
},
{
type: EventType.Meta,
data: {
href: 'http://localhost',
width: 1000,
height: 800,
},
timestamp: now + 1000,
},
{
type: EventType.FullSnapshot,
data: {
node: {
type: 0,
childNodes: [
{
type: 2,
tagName: 'html',
attributes: {},
childNodes: [
{
type: 2,
tagName: 'head',
attributes: {},
childNodes: [],
id: 3,
},
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [],
id: 4,
},
],
id: 2,
},
],
id: 1,
},
initialOffset: {
top: 0,
left: 0,
},
},
timestamp: now + 1000,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.MouseInteraction,
type: MouseInteractions.Click,
id: 1,
x: 0,
y: 0,
},
timestamp: now + 2000,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.MouseInteraction,
type: MouseInteractions.Click,
id: 1,
x: 0,
y: 0,
},
timestamp: now + 3000,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.MouseInteraction,
type: MouseInteractions.Click,
id: 1,
x: 0,
y: 0,
},
timestamp: now + 4000,
},
];
export const sampleStyleSheetRemoveEvents: eventWithTime[] = [
{
type: EventType.DomContentLoaded,
data: {},
timestamp: now,
},
{
type: EventType.Load,
data: {},
timestamp: now + 1000,
},
{
type: EventType.Meta,
data: {
href: 'http://localhost',
width: 1000,
height: 800,
},
timestamp: now + 1000,
},
{
type: EventType.FullSnapshot,
data: {
node: {
type: 0,
childNodes: [
{
type: 2,
tagName: 'html',
attributes: {},
childNodes: [
{
type: 2,
tagName: 'head',
attributes: {},
childNodes: [
{
type: 2,
tagName: 'style',
attributes: {
'data-jss': '',
'data-meta': 'OverlayDrawer',
_cssText:
'.OverlayDrawer-modal-187 { }.OverlayDrawer-paper-188 { width: 100%; }@media (min-width: 48em) {\n .OverlayDrawer-paper-188 { width: 38rem; }\n}@media (min-width: 48em) {\n}@media (min-width: 48em) {\n}',
},
childNodes: [
{
type: 3,
textContent: '\n',
isStyle: true,
id: 5,
},
],
id: 4,
},
],
id: 3,
},
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [],
id: 6,
},
],
id: 2,
},
],
id: 1,
},
initialOffset: {
top: 0,
left: 0,
},
},
timestamp: now + 1000,
},
{
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.Mutation,
texts: [],
attributes: [],
removes: [
{
parentId: 3,
id: 4,
},
],
adds: [],
},
timestamp: now + 2000,
},
];

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES5",
"noImplicitAny": true,
"strictNullChecks": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "build",
"lib": ["es6", "dom"],
"downlevelIteration": true
},
"compileOnSave": true,
"exclude": ["test"],
"include": [
"src",
"test.d.ts",
"node_modules/@types/css-font-loading-module/index.d.ts"
]
}

View File

@@ -0,0 +1,27 @@
{
"defaultSeverity": "error",
"extends": ["tslint:recommended"],
"jsRules": {},
"rules": {
"no-any": true,
"quotemark": [true, "single"],
"ordered-imports": false,
"object-literal-sort-keys": false,
"no-unused-variable": true,
"object-literal-key-quotes": false,
"variable-name": [
true,
"ban-keywords",
"check-format",
"allow-leading-underscore"
],
"arrow-parens": false,
"only-arrow-functions": false,
"max-line-length": false,
"no-empty": false,
"max-classes-per-file": false,
"semicolon": false,
"trailing-comma": false
},
"rulesDirectory": []
}

2
packages/rrweb/typings/boost.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export * from './index';
export * from './packer';

View File

@@ -0,0 +1,4 @@
export * from '../index';
export * from '../packer';
export * from '../plugins/console/record';
export * from '../plugins/console/replay';

View File

@@ -0,0 +1,2 @@
export * from '../record/index';
export * from '../packer/pack';

View File

@@ -0,0 +1,2 @@
export * from '../replay';
export * from '../packer/unpack';

8
packages/rrweb/typings/index.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import record from './record';
import { Replayer } from './replay';
import { _mirror } from './utils';
import * as utils from './utils';
export { EventType, IncrementalSource, MouseInteractions, ReplayerEvents, } from './types';
declare const addCustomEvent: <T>(tag: string, payload: T) => void;
declare const freezePage: () => void;
export { record, addCustomEvent, freezePage, Replayer, _mirror as mirror, utils, };

View File

@@ -0,0 +1,7 @@
import { eventWithTime } from '../types';
export declare type PackFn = (event: eventWithTime) => string;
export declare type UnpackFn = (raw: string) => eventWithTime;
export declare type eventWithTimeAndPacker = eventWithTime & {
v: string;
};
export declare const MARK = "v1";

View File

@@ -0,0 +1,2 @@
export { pack } from './pack';
export { unpack } from './unpack';

View File

@@ -0,0 +1,2 @@
import { PackFn } from './base';
export declare const pack: PackFn;

View File

@@ -0,0 +1,2 @@
import { UnpackFn } from './base';
export declare const unpack: UnpackFn;

View File

@@ -0,0 +1,37 @@
export declare class StackFrame {
private fileName;
private functionName;
private lineNumber?;
private columnNumber?;
constructor(obj: {
fileName?: string;
functionName?: string;
lineNumber?: number;
columnNumber?: number;
});
toString(): string;
}
export declare const ErrorStackParser: {
parse: (error: Error) => StackFrame[];
extractLocation: (urlLike: string) => (string | undefined)[];
parseV8OrIE: (error: {
stack: string;
}) => StackFrame[];
parseFFOrSafari: (error: {
stack: string;
}) => StackFrame[];
parseOpera: (e: {
stacktrace?: string;
message: string;
stack?: string;
}) => StackFrame[];
parseOpera9: (e: {
message: string;
}) => StackFrame[];
parseOpera10: (e: {
stacktrace: string;
}) => StackFrame[];
parseOpera11: (error: {
stack: string;
}) => StackFrame[];
};

View File

@@ -0,0 +1,41 @@
import { RecordPlugin } from '../../../types';
export declare type StringifyOptions = {
stringLengthLimit?: number;
numOfKeysLimit: number;
};
declare type LogRecordOptions = {
level?: LogLevel[] | undefined;
lengthThreshold?: number;
stringifyOptions?: StringifyOptions;
logger?: Logger;
};
export declare type LogData = {
level: LogLevel;
trace: string[];
payload: string[];
};
export declare type LogLevel = 'assert' | 'clear' | 'count' | 'countReset' | 'debug' | 'dir' | 'dirxml' | 'error' | 'group' | 'groupCollapsed' | 'groupEnd' | 'info' | 'log' | 'table' | 'time' | 'timeEnd' | 'timeLog' | 'trace' | 'warn';
export declare type Logger = {
assert?: typeof console.assert;
clear?: typeof console.clear;
count?: typeof console.count;
countReset?: typeof console.countReset;
debug?: typeof console.debug;
dir?: typeof console.dir;
dirxml?: typeof console.dirxml;
error?: typeof console.error;
group?: typeof console.group;
groupCollapsed?: typeof console.groupCollapsed;
groupEnd?: () => void;
info?: typeof console.info;
log?: typeof console.log;
table?: typeof console.table;
time?: typeof console.time;
timeEnd?: typeof console.timeEnd;
timeLog?: typeof console.timeLog;
trace?: typeof console.trace;
warn?: typeof console.warn;
};
export declare const PLUGIN_NAME = "rrweb/console@1";
export declare const getRecordConsolePlugin: (options?: LogRecordOptions) => RecordPlugin;
export {};

View File

@@ -0,0 +1,2 @@
import { StringifyOptions } from './index';
export declare function stringify(obj: any, stringifyOptions?: StringifyOptions): string;

View File

@@ -0,0 +1,9 @@
import { LogLevel, LogData } from '../record';
import { ReplayPlugin } from '../../../types';
declare type ReplayLogger = Partial<Record<LogLevel, (data: LogData) => void>>;
declare type LogReplayConfig = {
level?: LogLevel[] | undefined;
replayLogger: ReplayLogger | undefined;
};
export declare const getReplayConsolePlugin: (options?: LogReplayConfig) => ReplayPlugin;
export {};

View File

@@ -0,0 +1,4 @@
import { removedNodeMutation } from '../types';
export declare function deepDelete(addsSet: Set<Node>, n: Node): void;
export declare function isParentRemoved(removes: removedNodeMutation[], n: Node): boolean;
export declare function isAncestorInSet(set: Set<Node>, n: Node): boolean;

View File

@@ -0,0 +1,37 @@
export declare class StackFrame {
private fileName;
private functionName;
private lineNumber?;
private columnNumber?;
constructor(obj: {
fileName?: string;
functionName?: string;
lineNumber?: number;
columnNumber?: number;
});
toString(): string;
}
export declare const ErrorStackParser: {
parse: (error: Error) => StackFrame[];
extractLocation: (urlLike: string) => (string | undefined)[];
parseV8OrIE: (error: {
stack: string;
}) => StackFrame[];
parseFFOrSafari: (error: {
stack: string;
}) => StackFrame[];
parseOpera: (e: {
stacktrace?: string;
message: string;
stack?: string;
}) => StackFrame[];
parseOpera9: (e: {
message: string;
}) => StackFrame[];
parseOpera10: (e: {
stacktrace: string;
}) => StackFrame[];
parseOpera11: (error: {
stack: string;
}) => StackFrame[];
};

View File

@@ -0,0 +1,13 @@
import { serializedNodeWithId, INode } from 'rrweb-snapshot';
import { mutationCallBack } from '../types';
export declare class IframeManager {
private iframes;
private mutationCb;
private loadListener?;
constructor(options: {
mutationCb: mutationCallBack;
});
addIframe(iframeEl: HTMLIFrameElement): void;
addLoadListener(cb: (iframeEl: HTMLIFrameElement) => unknown): void;
attachIframe(iframeEl: INode, childSn: serializedNodeWithId): void;
}

View File

@@ -0,0 +1,9 @@
import { eventWithTime, recordOptions, listenerHandler } from '../types';
declare function record<T = eventWithTime>(options?: recordOptions<T>): listenerHandler | undefined;
declare namespace record {
var addCustomEvent: <T>(tag: string, payload: T) => void;
var freezePage: () => void;
var takeFullSnapshot: (isCheckout?: boolean | undefined) => void;
var mirror: import("../types").Mirror;
}
export default record;

View File

@@ -0,0 +1,41 @@
import { MaskInputOptions, SlimDOMOptions, MaskTextFn, MaskInputFn } from 'rrweb-snapshot';
import { mutationRecord, blockClass, maskTextClass, mutationCallBack, Mirror } from '../types';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
export default class MutationBuffer {
private frozen;
private locked;
private texts;
private attributes;
private removes;
private mapRemoves;
private movedMap;
private addedSet;
private movedSet;
private droppedSet;
private emissionCallback;
private blockClass;
private blockSelector;
private maskTextClass;
private maskTextSelector;
private inlineStylesheet;
private maskInputOptions;
private maskTextFn;
private maskInputFn;
private recordCanvas;
private slimDOMOptions;
private doc;
private mirror;
private iframeManager;
private shadowDomManager;
init(cb: mutationCallBack, blockClass: blockClass, blockSelector: string | null, maskTextClass: maskTextClass, maskTextSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, maskTextFn: MaskTextFn | undefined, maskInputFn: MaskInputFn | undefined, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, doc: Document, mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager): void;
freeze(): void;
unfreeze(): void;
isFrozen(): boolean;
lock(): void;
unlock(): void;
processMutations: (mutations: mutationRecord[]) => void;
emit: () => void;
private processMutation;
private genAdds;
}

View File

@@ -0,0 +1,10 @@
import { MaskInputOptions, SlimDOMOptions, MaskInputFn, MaskTextFn } from 'rrweb-snapshot';
import { mutationCallBack, observerParam, listenerHandler, scrollCallback, blockClass, maskTextClass, hooksParam, SamplingStrategy, Mirror } from '../types';
import MutationBuffer from './mutation';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
export declare const mutationBuffers: MutationBuffer[];
export declare function initMutationObserver(cb: mutationCallBack, doc: Document, blockClass: blockClass, blockSelector: string | null, maskTextClass: maskTextClass, maskTextSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, maskTextFn: MaskTextFn | undefined, maskInputFn: MaskInputFn | undefined, recordCanvas: boolean, slimDOMOptions: SlimDOMOptions, mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager, rootEl: Node): MutationObserver;
export declare function initScrollObserver(cb: scrollCallback, doc: Document, mirror: Mirror, blockClass: blockClass, sampling: SamplingStrategy): listenerHandler;
export declare const INPUT_TAGS: string[];
export declare function initObservers(o: observerParam, hooks?: hooksParam): listenerHandler;

View File

@@ -0,0 +1,31 @@
import { mutationCallBack, blockClass, maskTextClass, Mirror, scrollCallback, SamplingStrategy } from '../types';
import { MaskInputOptions, SlimDOMOptions, MaskTextFn, MaskInputFn } from 'rrweb-snapshot';
import { IframeManager } from './iframe-manager';
declare type BypassOptions = {
blockClass: blockClass;
blockSelector: string | null;
maskTextClass: maskTextClass;
maskTextSelector: string | null;
inlineStylesheet: boolean;
maskInputOptions: MaskInputOptions;
maskTextFn: MaskTextFn | undefined;
maskInputFn: MaskInputFn | undefined;
recordCanvas: boolean;
sampling: SamplingStrategy;
slimDOMOptions: SlimDOMOptions;
iframeManager: IframeManager;
};
export declare class ShadowDomManager {
private mutationCb;
private scrollCb;
private bypassOptions;
private mirror;
constructor(options: {
mutationCb: mutationCallBack;
scrollCb: scrollCallback;
bypassOptions: BypassOptions;
mirror: Mirror;
});
addShadowRoot(shadowRoot: ShadowRoot, doc: Document): void;
}
export {};

View File

@@ -0,0 +1,2 @@
import { StringifyOptions } from '../types';
export declare function stringify(obj: any, stringifyOptions?: StringifyOptions): string;

View File

@@ -0,0 +1,69 @@
import { Timer } from './timer';
import { createPlayerService, createSpeedService } from './machine';
import { eventWithTime, playerConfig, playerMetaData, Handler, Mirror } from '../types';
import './styles/style.css';
export declare class Replayer {
wrapper: HTMLDivElement;
iframe: HTMLIFrameElement;
service: ReturnType<typeof createPlayerService>;
speedService: ReturnType<typeof createSpeedService>;
get timer(): Timer;
config: playerConfig;
private mouse;
private mouseTail;
private tailPositions;
private emitter;
private nextUserInteractionEvent;
private legacy_missingNodeRetryMap;
private treeIndex;
private fragmentParentMap;
private elementStateMap;
private virtualStyleRulesMap;
private imageMap;
private mirror;
private firstFullSnapshot;
private newDocumentQueue;
constructor(events: Array<eventWithTime | string>, config?: Partial<playerConfig>);
on(event: string, handler: Handler): this;
off(event: string, handler: Handler): this;
setConfig(config: Partial<playerConfig>): void;
getMetaData(): playerMetaData;
getCurrentTime(): number;
getTimeOffset(): number;
getMirror(): Mirror;
play(timeOffset?: number): void;
pause(timeOffset?: number): void;
resume(timeOffset?: number): void;
startLive(baselineTime?: number): void;
addEvent(rawEvent: eventWithTime | string): void;
enableInteract(): void;
disableInteract(): void;
private setupDom;
private handleResize;
private getCastFn;
private rebuildFullSnapshot;
private insertStyleRules;
private attachDocumentToIframe;
private collectIframeAndAttachDocument;
private waitForStylesheetLoad;
private preloadAllImages;
private applyIncremental;
private applyMutation;
private applyScroll;
private applyInput;
private legacy_resolveMissingNode;
private moveAndHover;
private drawMouseTail;
private hoverElements;
private isUserInteraction;
private backToNormal;
private restoreRealParent;
private storeState;
private restoreState;
private restoreNodeSheet;
private warnNodeNotFound;
private warnCanvasMutationFailed;
private debugNodeNotFound;
private warn;
private debug;
}

View File

@@ -0,0 +1,79 @@
import { StateMachine } from '@xstate/fsm';
import { playerConfig, eventWithTime, Emitter } from '../types';
import { Timer } from './timer';
export declare type PlayerContext = {
events: eventWithTime[];
timer: Timer;
timeOffset: number;
baselineTime: number;
lastPlayedEvent: eventWithTime | null;
};
export declare type PlayerEvent = {
type: 'PLAY';
payload: {
timeOffset: number;
};
} | {
type: 'CAST_EVENT';
payload: {
event: eventWithTime;
};
} | {
type: 'PAUSE';
} | {
type: 'TO_LIVE';
payload: {
baselineTime?: number;
};
} | {
type: 'ADD_EVENT';
payload: {
event: eventWithTime;
};
} | {
type: 'END';
};
export declare type PlayerState = {
value: 'playing';
context: PlayerContext;
} | {
value: 'paused';
context: PlayerContext;
} | {
value: 'live';
context: PlayerContext;
};
export declare function discardPriorSnapshots(events: eventWithTime[], baselineTime: number): eventWithTime[];
declare type PlayerAssets = {
emitter: Emitter;
getCastFn(event: eventWithTime, isSync: boolean): () => void;
};
export declare function createPlayerService(context: PlayerContext, { getCastFn, emitter }: PlayerAssets): StateMachine.Service<PlayerContext, PlayerEvent, PlayerState>;
export declare type SpeedContext = {
normalSpeed: playerConfig['speed'];
timer: Timer;
};
export declare type SpeedEvent = {
type: 'FAST_FORWARD';
payload: {
speed: playerConfig['speed'];
};
} | {
type: 'BACK_TO_NORMAL';
} | {
type: 'SET_SPEED';
payload: {
speed: playerConfig['speed'];
};
};
export declare type SpeedState = {
value: 'normal';
context: SpeedContext;
} | {
value: 'skipping';
context: SpeedContext;
};
export declare function createSpeedService(context: SpeedContext): StateMachine.Service<SpeedContext, SpeedEvent, SpeedState>;
export declare type PlayerMachineState = StateMachine.State<PlayerContext, PlayerEvent, PlayerState>;
export declare type SpeedMachineState = StateMachine.State<SpeedContext, SpeedEvent, SpeedState>;
export {};

View File

@@ -0,0 +1 @@
export declare function polyfill(w?: Window, d?: Document): void;

View File

@@ -0,0 +1,2 @@
declare const rules: (blockClass: string) => string[];
export default rules;

View File

@@ -0,0 +1,18 @@
import { actionWithDelay, eventWithTime } from '../types';
export declare class Timer {
timeOffset: number;
speed: number;
private actions;
private raf;
private liveMode;
constructor(actions: actionWithDelay[] | undefined, speed: number);
addAction(action: actionWithDelay): void;
addActions(actions: actionWithDelay[]): void;
start(): void;
clear(): void;
setSpeed(speed: number): void;
toggleLiveMode(mode: boolean): void;
isActive(): boolean;
private findActionIndex;
}
export declare function addDelay(event: eventWithTime, baselineTime: number): number;

View File

@@ -0,0 +1,24 @@
import { INode } from 'rrweb-snapshot';
export declare enum StyleRuleType {
Insert = 0,
Remove = 1,
Snapshot = 2
}
declare type InsertRule = {
cssText: string;
type: StyleRuleType.Insert;
index?: number;
};
declare type RemoveRule = {
type: StyleRuleType.Remove;
index: number;
};
declare type SnapshotRule = {
type: StyleRuleType.Snapshot;
cssTexts: string[];
};
export declare type VirtualStyleRules = Array<InsertRule | RemoveRule | SnapshotRule>;
export declare type VirtualStyleRulesMap = Map<INode, VirtualStyleRules>;
export declare function applyVirtualStyleRulesToNode(storedRules: VirtualStyleRules, styleNode: HTMLStyleElement): void;
export declare function storeCSSRules(parentElement: HTMLStyleElement, virtualStyleRulesMap: VirtualStyleRulesMap): void;
export {};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,20 @@
export declare type AnyObject = {
[key: string]: any;
__rrdom__?: RRdomTreeNode;
};
export declare class RRdomTreeNode implements AnyObject {
parent: AnyObject | null;
previousSibling: AnyObject | null;
nextSibling: AnyObject | null;
firstChild: AnyObject | null;
lastChild: AnyObject | null;
childrenVersion: number;
childIndexCachedUpTo: AnyObject | null;
cachedIndex: number;
cachedIndexVersion: number;
get isAttached(): boolean;
get hasChildren(): boolean;
childrenChanged(): void;
getCachedIndex(parentNode: AnyObject): number;
setCachedIndex(parentNode: AnyObject, index: number): void;
}

427
packages/rrweb/typings/types.d.ts vendored Normal file
View File

@@ -0,0 +1,427 @@
import { serializedNodeWithId, idNodeMap, INode, MaskInputOptions, SlimDOMOptions, MaskInputFn, MaskTextFn } from 'rrweb-snapshot';
import { PackFn, UnpackFn } from './packer/base';
import { FontFaceDescriptors } from 'css-font-loading-module';
import { IframeManager } from './record/iframe-manager';
import { ShadowDomManager } from './record/shadow-dom-manager';
import type { Replayer } from './replay';
export declare enum EventType {
DomContentLoaded = 0,
Load = 1,
FullSnapshot = 2,
IncrementalSnapshot = 3,
Meta = 4,
Custom = 5,
Plugin = 6
}
export declare type domContentLoadedEvent = {
type: EventType.DomContentLoaded;
data: {};
};
export declare type loadedEvent = {
type: EventType.Load;
data: {};
};
export declare type fullSnapshotEvent = {
type: EventType.FullSnapshot;
data: {
node: serializedNodeWithId;
initialOffset: {
top: number;
left: number;
};
};
};
export declare type incrementalSnapshotEvent = {
type: EventType.IncrementalSnapshot;
data: incrementalData;
};
export declare type metaEvent = {
type: EventType.Meta;
data: {
href: string;
width: number;
height: number;
};
};
export declare type customEvent<T = unknown> = {
type: EventType.Custom;
data: {
tag: string;
payload: T;
};
};
export declare type pluginEvent<T = unknown> = {
type: EventType.Plugin;
data: {
plugin: string;
payload: T;
};
};
export declare type styleSheetEvent = {};
export declare enum IncrementalSource {
Mutation = 0,
MouseMove = 1,
MouseInteraction = 2,
Scroll = 3,
ViewportResize = 4,
Input = 5,
TouchMove = 6,
MediaInteraction = 7,
StyleSheetRule = 8,
CanvasMutation = 9,
Font = 10,
Log = 11,
Drag = 12
}
export declare type mutationData = {
source: IncrementalSource.Mutation;
} & mutationCallbackParam;
export declare type mousemoveData = {
source: IncrementalSource.MouseMove | IncrementalSource.TouchMove | IncrementalSource.Drag;
positions: mousePosition[];
};
export declare type mouseInteractionData = {
source: IncrementalSource.MouseInteraction;
} & mouseInteractionParam;
export declare type scrollData = {
source: IncrementalSource.Scroll;
} & scrollPosition;
export declare type viewportResizeData = {
source: IncrementalSource.ViewportResize;
} & viewportResizeDimension;
export declare type inputData = {
source: IncrementalSource.Input;
id: number;
} & inputValue;
export declare type mediaInteractionData = {
source: IncrementalSource.MediaInteraction;
} & mediaInteractionParam;
export declare type styleSheetRuleData = {
source: IncrementalSource.StyleSheetRule;
} & styleSheetRuleParam;
export declare type canvasMutationData = {
source: IncrementalSource.CanvasMutation;
} & canvasMutationParam;
export declare type fontData = {
source: IncrementalSource.Font;
} & fontParam;
export declare type incrementalData = mutationData | mousemoveData | mouseInteractionData | scrollData | viewportResizeData | inputData | mediaInteractionData | styleSheetRuleData | canvasMutationData | fontData;
export declare type event = domContentLoadedEvent | loadedEvent | fullSnapshotEvent | incrementalSnapshotEvent | metaEvent | customEvent | pluginEvent;
export declare type eventWithTime = event & {
timestamp: number;
delay?: number;
};
export declare type blockClass = string | RegExp;
export declare type maskTextClass = string | RegExp;
export declare type SamplingStrategy = Partial<{
mousemove: boolean | number;
mousemoveCallback: number;
mouseInteraction: boolean | Record<string, boolean | undefined>;
scroll: number;
input: 'all' | 'last';
}>;
export declare type RecordPlugin<TOptions = unknown> = {
name: string;
observer: (cb: Function, options: TOptions) => listenerHandler;
options: TOptions;
};
export declare type recordOptions<T> = {
emit?: (e: T, isCheckout?: boolean) => void;
checkoutEveryNth?: number;
checkoutEveryNms?: number;
blockClass?: blockClass;
blockSelector?: string;
ignoreClass?: string;
maskTextClass?: maskTextClass;
maskTextSelector?: string;
maskAllInputs?: boolean;
maskInputOptions?: MaskInputOptions;
maskInputFn?: MaskInputFn;
maskTextFn?: MaskTextFn;
slimDOMOptions?: SlimDOMOptions | 'all' | true;
inlineStylesheet?: boolean;
hooks?: hooksParam;
packFn?: PackFn;
sampling?: SamplingStrategy;
recordCanvas?: boolean;
userTriggeredOnInput?: boolean;
collectFonts?: boolean;
plugins?: RecordPlugin[];
mousemoveWait?: number;
keepIframeSrcFn?: KeepIframeSrcFn;
};
export declare type observerParam = {
mutationCb: mutationCallBack;
mousemoveCb: mousemoveCallBack;
mouseInteractionCb: mouseInteractionCallBack;
scrollCb: scrollCallback;
viewportResizeCb: viewportResizeCallback;
inputCb: inputCallback;
mediaInteractionCb: mediaInteractionCallback;
blockClass: blockClass;
blockSelector: string | null;
ignoreClass: string;
maskTextClass: maskTextClass;
maskTextSelector: string | null;
maskInputOptions: MaskInputOptions;
maskInputFn?: MaskInputFn;
maskTextFn?: MaskTextFn;
inlineStylesheet: boolean;
styleSheetRuleCb: styleSheetRuleCallback;
canvasMutationCb: canvasMutationCallback;
fontCb: fontCallback;
sampling: SamplingStrategy;
recordCanvas: boolean;
userTriggeredOnInput: boolean;
collectFonts: boolean;
slimDOMOptions: SlimDOMOptions;
doc: Document;
mirror: Mirror;
iframeManager: IframeManager;
shadowDomManager: ShadowDomManager;
plugins: Array<{
observer: Function;
callback: Function;
options: unknown;
}>;
};
export declare type hooksParam = {
mutation?: mutationCallBack;
mousemove?: mousemoveCallBack;
mouseInteraction?: mouseInteractionCallBack;
scroll?: scrollCallback;
viewportResize?: viewportResizeCallback;
input?: inputCallback;
mediaInteaction?: mediaInteractionCallback;
styleSheetRule?: styleSheetRuleCallback;
canvasMutation?: canvasMutationCallback;
font?: fontCallback;
};
export declare type mutationRecord = {
type: string;
target: Node;
oldValue: string | null;
addedNodes: NodeList;
removedNodes: NodeList;
attributeName: string | null;
};
export declare type textCursor = {
node: Node;
value: string | null;
};
export declare type textMutation = {
id: number;
value: string | null;
};
export declare type styleAttributeValue = {
[key: string]: styleValueWithPriority | string | false;
};
export declare type styleValueWithPriority = [string, string];
export declare type attributeCursor = {
node: Node;
attributes: {
[key: string]: string | styleAttributeValue | null;
};
};
export declare type attributeMutation = {
id: number;
attributes: {
[key: string]: string | styleAttributeValue | null;
};
};
export declare type removedNodeMutation = {
parentId: number;
id: number;
isShadow?: boolean;
};
export declare type addedNodeMutation = {
parentId: number;
previousId?: number | null;
nextId: number | null;
node: serializedNodeWithId;
};
export declare type mutationCallbackParam = {
texts: textMutation[];
attributes: attributeMutation[];
removes: removedNodeMutation[];
adds: addedNodeMutation[];
isAttachIframe?: true;
};
export declare type mutationCallBack = (m: mutationCallbackParam) => void;
export declare type mousemoveCallBack = (p: mousePosition[], source: IncrementalSource.MouseMove | IncrementalSource.TouchMove | IncrementalSource.Drag) => void;
export declare type mousePosition = {
x: number;
y: number;
id: number;
timeOffset: number;
};
export declare enum MouseInteractions {
MouseUp = 0,
MouseDown = 1,
Click = 2,
ContextMenu = 3,
DblClick = 4,
Focus = 5,
Blur = 6,
TouchStart = 7,
TouchMove_Departed = 8,
TouchEnd = 9
}
declare type mouseInteractionParam = {
type: MouseInteractions;
id: number;
x: number;
y: number;
};
export declare type mouseInteractionCallBack = (d: mouseInteractionParam) => void;
export declare type scrollPosition = {
id: number;
x: number;
y: number;
};
export declare type scrollCallback = (p: scrollPosition) => void;
export declare type styleSheetAddRule = {
rule: string;
index?: number;
};
export declare type styleSheetDeleteRule = {
index: number;
};
export declare type styleSheetRuleParam = {
id: number;
removes?: styleSheetDeleteRule[];
adds?: styleSheetAddRule[];
};
export declare type styleSheetRuleCallback = (s: styleSheetRuleParam) => void;
export declare type canvasMutationCallback = (p: canvasMutationParam) => void;
export declare type canvasMutationParam = {
id: number;
property: string;
args: Array<unknown>;
setter?: true;
};
export declare type fontParam = {
family: string;
fontSource: string;
buffer: boolean;
descriptors?: FontFaceDescriptors;
};
export declare type fontCallback = (p: fontParam) => void;
export declare type viewportResizeDimension = {
width: number;
height: number;
};
export declare type viewportResizeCallback = (d: viewportResizeDimension) => void;
export declare type inputValue = {
text: string;
isChecked: boolean;
userTriggered?: boolean;
};
export declare type inputCallback = (v: inputValue & {
id: number;
}) => void;
export declare const enum MediaInteractions {
Play = 0,
Pause = 1,
Seeked = 2
}
export declare type mediaInteractionParam = {
type: MediaInteractions;
id: number;
currentTime?: number;
};
export declare type mediaInteractionCallback = (p: mediaInteractionParam) => void;
export declare type DocumentDimension = {
x: number;
y: number;
relativeScale: number;
absoluteScale: number;
};
export declare type Mirror = {
map: idNodeMap;
getId: (n: INode) => number;
getNode: (id: number) => INode | null;
removeNodeFromMap: (n: INode) => void;
has: (id: number) => boolean;
reset: () => void;
};
export declare type throttleOptions = {
leading?: boolean;
trailing?: boolean;
};
export declare type listenerHandler = () => void;
export declare type hookResetter = () => void;
export declare type ReplayPlugin = {
handler: (event: eventWithTime, isSync: boolean, context: {
replayer: Replayer;
}) => void;
};
export declare type playerConfig = {
speed: number;
maxSpeed: number;
root: Element;
loadTimeout: number;
skipInactive: boolean;
showWarning: boolean;
showDebug: boolean;
blockClass: string;
liveMode: boolean;
insertStyleRules: string[];
triggerFocus: boolean;
UNSAFE_replayCanvas: boolean;
pauseAnimation?: boolean;
mouseTail: boolean | {
duration?: number;
lineCap?: string;
lineWidth?: number;
strokeStyle?: string;
};
unpackFn?: UnpackFn;
plugins?: ReplayPlugin[];
};
export declare type playerMetaData = {
startTime: number;
endTime: number;
totalTime: number;
};
export declare type missingNode = {
node: Node;
mutation: addedNodeMutation;
};
export declare type missingNodeMap = {
[id: number]: missingNode;
};
export declare type actionWithDelay = {
doAction: () => void;
delay: number;
};
export declare type Handler = (event?: unknown) => void;
export declare type Emitter = {
on(type: string, handler: Handler): void;
emit(type: string, event?: unknown): void;
off(type: string, handler: Handler): void;
};
export declare type Arguments<T> = T extends (...payload: infer U) => unknown ? U : unknown;
export declare enum ReplayerEvents {
Start = "start",
Pause = "pause",
Resume = "resume",
Resize = "resize",
Finish = "finish",
FullsnapshotRebuilded = "fullsnapshot-rebuilded",
LoadStylesheetStart = "load-stylesheet-start",
LoadStylesheetEnd = "load-stylesheet-end",
SkipStart = "skip-start",
SkipEnd = "skip-end",
MouseInteraction = "mouse-interaction",
EventCast = "event-cast",
CustomEvent = "custom-event",
Flush = "flush",
StateChange = "state-change",
PlayBack = "play-back"
}
export declare type ElementState = {
scroll?: [number, number];
};
export declare type KeepIframeSrcFn = (src: string) => boolean;
export {};

70
packages/rrweb/typings/utils.d.ts vendored Normal file
View File

@@ -0,0 +1,70 @@
import { Mirror, throttleOptions, listenerHandler, hookResetter, blockClass, eventWithTime, addedNodeMutation, removedNodeMutation, textMutation, attributeMutation, mutationData, scrollData, inputData, DocumentDimension } from './types';
import { INode, serializedNodeWithId } from 'rrweb-snapshot';
export declare function on(type: string, fn: EventListenerOrEventListenerObject, target?: Document | Window): listenerHandler;
export declare function createMirror(): Mirror;
export declare let _mirror: Mirror;
export declare function throttle<T>(func: (arg: T) => void, wait: number, options?: throttleOptions): (arg: T) => void;
export declare function hookSetter<T>(target: T, key: string | number | symbol, d: PropertyDescriptor, isRevoked?: boolean, win?: Window & typeof globalThis): hookResetter;
export declare function patch(source: {
[key: string]: any;
}, name: string, replacement: (...args: any[]) => any): () => void;
export declare function getWindowHeight(): number;
export declare function getWindowWidth(): number;
export declare function isBlocked(node: Node | null, blockClass: blockClass): boolean;
export declare function isIgnored(n: Node | INode): boolean;
export declare function isAncestorRemoved(target: INode, mirror: Mirror): boolean;
export declare function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent;
export declare function polyfill(win?: Window & typeof globalThis): void;
export declare function needCastInSyncMode(event: eventWithTime): boolean;
export declare type TreeNode = {
id: number;
mutation: addedNodeMutation;
parent?: TreeNode;
children: Record<number, TreeNode>;
texts: textMutation[];
attributes: attributeMutation[];
};
export declare class TreeIndex {
tree: Record<number, TreeNode>;
private removeNodeMutations;
private textMutations;
private attributeMutations;
private indexes;
private removeIdSet;
private scrollMap;
private inputMap;
constructor();
add(mutation: addedNodeMutation): void;
remove(mutation: removedNodeMutation, mirror: Mirror): void;
text(mutation: textMutation): void;
attribute(mutation: attributeMutation): void;
scroll(d: scrollData): void;
input(d: inputData): void;
flush(): {
mutationData: mutationData;
scrollMap: TreeIndex['scrollMap'];
inputMap: TreeIndex['inputMap'];
};
private reset;
idRemoved(id: number): boolean;
}
declare type ResolveTree = {
value: addedNodeMutation;
children: ResolveTree[];
parent: ResolveTree | null;
};
export declare function queueToResolveTrees(queue: addedNodeMutation[]): ResolveTree[];
export declare function iterateResolveTree(tree: ResolveTree, cb: (mutation: addedNodeMutation) => unknown): void;
declare type HTMLIFrameINode = HTMLIFrameElement & {
__sn: serializedNodeWithId;
};
export declare type AppendedIframe = {
mutationInQueue: addedNodeMutation;
builtNode: HTMLIFrameINode;
};
export declare function isIframeINode(node: INode | ShadowRoot): node is HTMLIFrameINode;
export declare function getBaseDimension(node: Node, rootIframe: Node): DocumentDimension;
export declare function hasShadowRoot<T extends Node>(n: T): n is T & {
shadowRoot: ShadowRoot;
};
export {};