diff --git a/packages/rrweb-snapshot/.release-it.json b/packages/rrweb-snapshot/.release-it.json new file mode 100644 index 00000000..e76cbd8e --- /dev/null +++ b/packages/rrweb-snapshot/.release-it.json @@ -0,0 +1,9 @@ +{ + "non-interactive": true, + "hooks": { + "before:init": ["npm run bundle", "npm run typings"] + }, + "git": { + "requireCleanWorkingDir": false + } +} diff --git a/packages/rrweb-snapshot/LICENSE b/packages/rrweb-snapshot/LICENSE new file mode 100644 index 00000000..0b2f1b2f --- /dev/null +++ b/packages/rrweb-snapshot/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Contributors (https://github.com/rrweb-io/rrweb-snapshot/graphs/contributors) and SmartX Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/rrweb-snapshot/README.md b/packages/rrweb-snapshot/README.md new file mode 100644 index 00000000..46f1702b --- /dev/null +++ b/packages/rrweb-snapshot/README.md @@ -0,0 +1,40 @@ +# rrweb-snapshot + +[![Build Status](https://travis-ci.org/rrweb-io/rrweb-snapshot.svg?branch=master)](https://travis-ci.org/rrweb-io/rrweb-snapshot) [![Join the chat at https://gitter.im/rrweb-io/rrweb-snapshot](https://badges.gitter.im/rrweb-io/rrweb-snapshot.svg)](https://gitter.im/rrweb-io/rrweb-snapshot?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + +Snapshot the DOM into a stateful and serializable data structure. +Also, provide the ability to rebuild the DOM via snapshot. + +## API + +This module export following methods: + +### snapshot + +`snapshot` will traverse the DOM and return a stateful and serializable data structure which can represent the current DOM **view**. + +There are several things will be done during snapshot: + +1. Inline some DOM states into HTML attributes, e.g, HTMLInputElement's value. +2. Turn script tags into `noscript` tags to avoid scripts being executed. +3. Try to inline stylesheets to make sure local stylesheets can be used. +4. Make relative paths in href, src, CSS to be absolute paths. +5. Give an id to each Node, and return the id node map when snapshot finished. + +#### rebuild + +`rebuild` will build the DOM according to the taken snapshot. + +There are several things will be done during rebuild: + +1. Add data-rrid attribute if the Node is an Element. +2. Create some extra DOM node like text node to place inline CSS and some states. +3. Add data-extra-child-index attribute if Node has some extra child DOM. + +#### serializeNodeWithId + +`serializeNodeWithId` can serialize a node into snapshot format with id. + +#### buildNodeWithSN + +`buildNodeWithSN` will build DOM from serialized node and store serialized information in `__sn` property. diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json new file mode 100644 index 00000000..bd312935 --- /dev/null +++ b/packages/rrweb-snapshot/package.json @@ -0,0 +1,57 @@ +{ + "name": "rrweb-snapshot", + "version": "1.1.7", + "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", + "scripts": { + "prepare": "npm run prepack", + "prepack": "npm run bundle && npm run typings", + "test": "cross-env TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register test/**/*.ts", + "bundle": "rollup --config", + "typings": "tsc -d --declarationDir typings" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rrweb-io/rrweb-snapshot.git" + }, + "keywords": [ + "rrweb", + "snapshot", + "DOM" + ], + "main": "lib/rrweb-snapshot.js", + "module": "es/rrweb-snapshot.js", + "unpkg": "dist/rrweb-snapshot.js", + "typings": "typings/index.d.ts", + "files": [ + "dist", + "lib", + "es", + "typings" + ], + "author": "yanzhen@smartx.com", + "license": "MIT", + "bugs": { + "url": "https://github.com/rrweb-io/rrweb-snapshot/issues" + }, + "homepage": "https://github.com/rrweb-io/rrweb-snapshot#readme", + "devDependencies": { + "@types/chai": "^4.1.4", + "@types/jsdom": "^16.2.4", + "@types/mocha": "^5.2.5", + "@types/node": "^10.11.3", + "@types/puppeteer": "^1.12.4", + "chai": "^4.1.2", + "cross-env": "^5.2.0", + "jest-snapshot": "^23.6.0", + "jsdom": "^16.4.0", + "mocha": "^5.2.0", + "puppeteer": "^1.15.0", + "rollup": "^0.66.4", + "rollup-plugin-terser": "^3.0.0", + "rollup-plugin-typescript": "^1.0.0", + "ts-node": "^7.0.1", + "tslib": "^1.9.3", + "tslint": "^4.5.1", + "typescript": "^3.4.1" + } +} diff --git a/packages/rrweb-snapshot/rollup.config.js b/packages/rrweb-snapshot/rollup.config.js new file mode 100644 index 00000000..942cc62f --- /dev/null +++ b/packages/rrweb-snapshot/rollup.config.js @@ -0,0 +1,67 @@ +import typescript from 'rollup-plugin-typescript'; +import { terser } from 'rollup-plugin-terser'; +import pkg from './package.json'; + +function toMinPath(path) { + return path.replace(/\.js$/, '.min.js'); +} + +export default [ + // browser + { + input: './src/index.ts', + plugins: [typescript()], + output: [ + { + name: 'rrwebSnapshot', + format: 'iife', + file: pkg.unpkg, + }, + ], + }, + { + input: './src/index.ts', + plugins: [typescript(), terser()], + output: [ + { + name: 'rrwebSnapshot', + format: 'iife', + file: toMinPath(pkg.unpkg), + sourcemap: true, + }, + ], + }, + // CommonJS + { + input: './src/index.ts', + plugins: [typescript()], + output: [ + { + format: 'cjs', + file: pkg.main, + }, + ], + }, + // ES module + { + input: './src/index.ts', + plugins: [typescript()], + output: [ + { + format: 'esm', + file: pkg.module, + }, + ], + }, + { + input: './src/index.ts', + plugins: [typescript(), terser()], + output: [ + { + format: 'esm', + file: toMinPath(pkg.module), + sourcemap: true, + }, + ], + }, +]; diff --git a/packages/rrweb-snapshot/src/css.ts b/packages/rrweb-snapshot/src/css.ts new file mode 100644 index 00000000..950ec0b4 --- /dev/null +++ b/packages/rrweb-snapshot/src/css.ts @@ -0,0 +1,909 @@ +/** + * This file is a fork of https://github.com/reworkcss/css/blob/master/lib/parse/index.js + * I fork it because: + * 1. The css library was built for node.js which does not have tree-shaking supports. + * 2. Rewrites into typescript give us a better type interface. + */ + +/* tslint:disable no-conditional-assignment interface-name no-shadowed-variable */ + +export interface ParserOptions { + /** Silently fail on parse errors */ + silent?: boolean; + /** + * The path to the file containing css. + * Makes errors and source maps more helpful, by letting them know where code comes from. + */ + source?: string; +} + +/** + * Error thrown during parsing. + */ +export interface ParserError { + /** The full error message with the source position. */ + message?: string; + /** The error message without position. */ + reason?: string; + /** The value of options.source if passed to css.parse. Otherwise undefined. */ + filename?: string; + line?: number; + column?: number; + /** The portion of code that couldn't be parsed. */ + source?: string; +} + +export interface Loc { + line?: number; + column?: number; +} + +/** + * Base AST Tree Node. + */ +export interface Node { + /** The possible values are the ones listed in the Types section on https://github.com/reworkcss/css page. */ + type?: string; + /** A reference to the parent node, or null if the node has no parent. */ + parent?: Node; + /** Information about the position in the source string that corresponds to the node. */ + position?: { + start?: Loc; + end?: Loc; + /** The value of options.source if passed to css.parse. Otherwise undefined. */ + source?: string; + /** The full source string passed to css.parse. */ + content?: string; + }; +} + +export interface Rule extends Node { + /** The list of selectors of the rule, split on commas. Each selector is trimmed from whitespace and comments. */ + selectors?: string[]; + /** Array of nodes with the types declaration and comment. */ + declarations?: Array; +} + +export interface Declaration extends Node { + /** The property name, trimmed from whitespace and comments. May not be empty. */ + property?: string; + /** The value of the property, trimmed from whitespace and comments. Empty values are allowed. */ + value?: string; +} + +/** + * A rule-level or declaration-level comment. Comments inside selectors, properties and values etc. are lost. + */ +export interface Comment extends Node { + comment?: string; +} + +/** + * The @charset at-rule. + */ +export interface Charset extends Node { + /** The part following @charset. */ + charset?: string; +} + +/** + * The @custom-media at-rule + */ +export interface CustomMedia extends Node { + /** The ---prefixed name. */ + name?: string; + /** The part following the name. */ + media?: string; +} + +/** + * The @document at-rule. + */ +export interface Document extends Node { + /** The part following @document. */ + document?: string; + /** The vendor prefix in @document, or undefined if there is none. */ + vendor?: string; + /** Array of nodes with the types rule, comment and any of the at-rule types. */ + rules?: Array; +} + +/** + * The @font-face at-rule. + */ +export interface FontFace extends Node { + /** Array of nodes with the types declaration and comment. */ + declarations?: Array; +} + +/** + * The @host at-rule. + */ +export interface Host extends Node { + /** Array of nodes with the types rule, comment and any of the at-rule types. */ + rules?: Array; +} + +/** + * The @import at-rule. + */ +export interface Import extends Node { + /** The part following @import. */ + import?: string; +} + +/** + * The @keyframes at-rule. + */ +export interface KeyFrames extends Node { + /** The name of the keyframes rule. */ + name?: string; + /** The vendor prefix in @keyframes, or undefined if there is none. */ + vendor?: string; + /** Array of nodes with the types keyframe and comment. */ + keyframes?: Array; +} + +export interface KeyFrame extends Node { + /** The list of "selectors" of the keyframe rule, split on commas. Each “selector” is trimmed from whitespace. */ + values?: string[]; + /** Array of nodes with the types declaration and comment. */ + declarations?: Array; +} + +/** + * The @media at-rule. + */ +export interface Media extends Node { + /** The part following @media. */ + media?: string; + /** Array of nodes with the types rule, comment and any of the at-rule types. */ + rules?: Array; +} + +/** + * The @namespace at-rule. + */ +export interface Namespace extends Node { + /** The part following @namespace. */ + namespace?: string; +} + +/** + * The @page at-rule. + */ +export interface Page extends Node { + /** The list of selectors of the rule, split on commas. Each selector is trimmed from whitespace and comments. */ + selectors?: string[]; + /** Array of nodes with the types declaration and comment. */ + declarations?: Array; +} + +/** + * The @supports at-rule. + */ +export interface Supports extends Node { + /** The part following @supports. */ + supports?: string; + /** Array of nodes with the types rule, comment and any of the at-rule types. */ + rules?: Array; +} + +/** All at-rules. */ +export type AtRule = + | Charset + | CustomMedia + | Document + | FontFace + | Host + | Import + | KeyFrames + | Media + | Namespace + | Page + | Supports; + +/** + * A collection of rules + */ +export interface StyleRules { + source?: string; + /** Array of nodes with the types rule, comment and any of the at-rule types. */ + rules: Array; + /** Array of Errors. Errors collected during parsing when option silent is true. */ + parsingErrors?: ParserError[]; +} + +/** + * The root node returned by css.parse. + */ +export interface Stylesheet extends Node { + stylesheet?: StyleRules; +} + +// http://www.w3.org/TR/CSS21/grammar.html +// https://github.com/visionmedia/css-parse/pull/49#issuecomment-30088027 +const commentre = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g; + +export function parse(css: string, options: ParserOptions = {}) { + /** + * Positional. + */ + + let lineno = 1; + let column = 1; + + /** + * Update lineno and column based on `str`. + */ + + function updatePosition(str: string) { + const lines = str.match(/\n/g); + if (lines) { + lineno += lines.length; + } + let i = str.lastIndexOf('\n'); + column = i === -1 ? column + str.length : str.length - i; + } + + /** + * Mark position and patch `node.position`. + */ + + function position() { + const start = { line: lineno, column }; + return ( + node: Rule | Declaration | Comment | AtRule | Stylesheet | KeyFrame, + ) => { + node.position = new Position(start); + whitespace(); + return node; + }; + } + + /** + * Store position information for a node + */ + + class Position { + public content!: string; + public start!: Loc; + public end!: Loc; + public source?: string; + + constructor(start: Loc) { + this.start = start; + this.end = { line: lineno, column }; + this.source = options.source; + } + } + + /** + * Non-enumerable source string + */ + + Position.prototype.content = css; + + const errorsList: ParserError[] = []; + + function error(msg: string) { + const err = new Error( + options.source + ':' + lineno + ':' + column + ': ' + msg, + ) as ParserError; + err.reason = msg; + err.filename = options.source; + err.line = lineno; + err.column = column; + err.source = css; + + if (options.silent) { + errorsList.push(err); + } else { + throw err; + } + } + + /** + * Parse stylesheet. + */ + + function stylesheet(): Stylesheet { + const rulesList = rules(); + + return { + type: 'stylesheet', + stylesheet: { + source: options.source, + rules: rulesList, + parsingErrors: errorsList, + }, + }; + } + + /** + * Opening brace. + */ + + function open() { + return match(/^{\s*/); + } + + /** + * Closing brace. + */ + + function close() { + return match(/^}/); + } + + /** + * Parse ruleset. + */ + + function rules() { + let node: Rule | void; + const rules: Rule[] = []; + whitespace(); + comments(rules); + while (css.length && css.charAt(0) !== '}' && (node = atrule() || rule())) { + if (node !== false) { + rules.push(node); + comments(rules); + } + } + return rules; + } + + /** + * Match `re` and return captures. + */ + + function match(re: RegExp) { + const m = re.exec(css); + if (!m) { + return; + } + const str = m[0]; + updatePosition(str); + css = css.slice(str.length); + return m; + } + + /** + * Parse whitespace. + */ + + function whitespace() { + match(/^\s*/); + } + + /** + * Parse comments; + */ + + function comments(rules: Rule[] = []) { + let c: Comment | void; + while ((c = comment())) { + if (c !== false) { + rules.push(c); + } + c = comment(); + } + return rules; + } + + /** + * Parse comment. + */ + + function comment() { + const pos = position(); + if ('/' !== css.charAt(0) || '*' !== css.charAt(1)) { + return; + } + + let i = 2; + while ( + '' !== css.charAt(i) && + ('*' !== css.charAt(i) || '/' !== css.charAt(i + 1)) + ) { + ++i; + } + i += 2; + + if ('' === css.charAt(i - 1)) { + return error('End of comment missing'); + } + + const str = css.slice(2, i - 2); + column += 2; + updatePosition(str); + css = css.slice(i); + column += 2; + + return pos({ + type: 'comment', + comment: str, + }); + } + + /** + * Parse selector. + */ + + function selector() { + const m = match(/^([^{]+)/); + if (!m) { + return; + } + /* @fix Remove all comments from selectors + * http://ostermiller.org/findcomment.html */ + return trim(m[0]) + .replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '') + .replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, (m) => { + return m.replace(/,/g, '\u200C'); + }) + .split(/\s*(?![^(]*\)),\s*/) + .map((s) => { + return s.replace(/\u200C/g, ','); + }); + } + + /** + * Parse declaration. + */ + + function declaration(): Declaration | void | never { + const pos = position(); + + // prop + let propMatch = match(/^(\*?[-#\/\*\\\w]+(\[[0-9a-z_-]+\])?)\s*/); + if (!propMatch) { + return; + } + const prop = trim(propMatch[0]); + + // : + if (!match(/^:\s*/)) { + return error(`property missing ':'`); + } + + // val + const val = match(/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^\)]*?\)|[^};])+)/); + + const ret = pos({ + type: 'declaration', + property: prop.replace(commentre, ''), + value: val ? trim(val[0]).replace(commentre, '') : '', + }); + + // ; + match(/^[;\s]*/); + + return ret; + } + + /** + * Parse declarations. + */ + + function declarations() { + const decls: Array = []; + + if (!open()) { + return error(`missing '{'`); + } + comments(decls); + + // declarations + let decl; + while ((decl = declaration())) { + if ((decl as unknown) !== false) { + decls.push(decl); + comments(decls); + } + decl = declaration(); + } + + if (!close()) { + return error(`missing '}'`); + } + return decls; + } + + /** + * Parse keyframe. + */ + + function keyframe() { + let m; + const vals = []; + const pos = position(); + + while ((m = match(/^((\d+\.\d+|\.\d+|\d+)%?|[a-z]+)\s*/))) { + vals.push(m[1]); + match(/^,\s*/); + } + + if (!vals.length) { + return; + } + + return pos({ + type: 'keyframe', + values: vals, + declarations: declarations() as Declaration[], + }); + } + + /** + * Parse keyframes. + */ + + function atkeyframes() { + const pos = position(); + let m = match(/^@([-\w]+)?keyframes\s*/); + + if (!m) { + return; + } + const vendor = m[1]; + + // identifier + m = match(/^([-\w]+)\s*/); + if (!m) { + return error('@keyframes missing name'); + } + const name = m[1]; + + if (!open()) { + return error(`@keyframes missing '{'`); + } + + let frame; + let frames = comments(); + while ((frame = keyframe())) { + frames.push(frame); + frames = frames.concat(comments()); + } + + if (!close()) { + return error(`@keyframes missing '}'`); + } + + return pos({ + type: 'keyframes', + name, + vendor, + keyframes: frames, + }); + } + + /** + * Parse supports. + */ + + function atsupports() { + const pos = position(); + const m = match(/^@supports *([^{]+)/); + + if (!m) { + return; + } + const supports = trim(m[1]); + + if (!open()) { + return error(`@supports missing '{'`); + } + + const style = comments().concat(rules()); + + if (!close()) { + return error(`@supports missing '}'`); + } + + return pos({ + type: 'supports', + supports, + rules: style, + }); + } + + /** + * Parse host. + */ + + function athost() { + const pos = position(); + const m = match(/^@host\s*/); + + if (!m) { + return; + } + + if (!open()) { + return error(`@host missing '{'`); + } + + const style = comments().concat(rules()); + + if (!close()) { + return error(`@host missing '}'`); + } + + return pos({ + type: 'host', + rules: style, + }); + } + + /** + * Parse media. + */ + + function atmedia() { + const pos = position(); + const m = match(/^@media *([^{]+)/); + + if (!m) { + return; + } + const media = trim(m[1]); + + if (!open()) { + return error(`@media missing '{'`); + } + + const style = comments().concat(rules()); + + if (!close()) { + return error(`@media missing '}'`); + } + + return pos({ + type: 'media', + media, + rules: style, + }); + } + + /** + * Parse custom-media. + */ + + function atcustommedia() { + const pos = position(); + const m = match(/^@custom-media\s+(--[^\s]+)\s*([^{;]+);/); + if (!m) { + return; + } + + return pos({ + type: 'custom-media', + name: trim(m[1]), + media: trim(m[2]), + }); + } + + /** + * Parse paged media. + */ + + function atpage() { + const pos = position(); + const m = match(/^@page */); + if (!m) { + return; + } + + const sel = selector() || []; + + if (!open()) { + return error(`@page missing '{'`); + } + let decls = comments(); + + // declarations + let decl; + while ((decl = declaration())) { + decls.push(decl); + decls = decls.concat(comments()); + } + + if (!close()) { + return error(`@page missing '}'`); + } + + return pos({ + type: 'page', + selectors: sel, + declarations: decls, + }); + } + + /** + * Parse document. + */ + + function atdocument() { + const pos = position(); + const m = match(/^@([-\w]+)?document *([^{]+)/); + if (!m) { + return; + } + + const vendor = trim(m[1]); + const doc = trim(m[2]); + + if (!open()) { + return error(`@document missing '{'`); + } + + const style = comments().concat(rules()); + + if (!close()) { + return error(`@document missing '}'`); + } + + return pos({ + type: 'document', + document: doc, + vendor, + rules: style, + }); + } + + /** + * Parse font-face. + */ + + function atfontface() { + const pos = position(); + const m = match(/^@font-face\s*/); + if (!m) { + return; + } + + if (!open()) { + return error(`@font-face missing '{'`); + } + let decls = comments(); + + // declarations + let decl; + while ((decl = declaration())) { + decls.push(decl); + decls = decls.concat(comments()); + } + + if (!close()) { + return error(`@font-face missing '}'`); + } + + return pos({ + type: 'font-face', + declarations: decls, + }); + } + + /** + * Parse import + */ + + const atimport = _compileAtrule('import'); + + /** + * Parse charset + */ + + const atcharset = _compileAtrule('charset'); + + /** + * Parse namespace + */ + + const atnamespace = _compileAtrule('namespace'); + + /** + * Parse non-block at-rules + */ + + function _compileAtrule(name: string) { + const re = new RegExp('^@' + name + '\\s*([^;]+);'); + return () => { + const pos = position(); + const m = match(re); + if (!m) { + return; + } + const ret: Record = { type: name }; + ret[name] = m[1].trim(); + return pos(ret); + }; + } + + /** + * Parse at rule. + */ + + function atrule() { + if (css[0] !== '@') { + return; + } + + return ( + atkeyframes() || + atmedia() || + atcustommedia() || + atsupports() || + atimport() || + atcharset() || + atnamespace() || + atdocument() || + atpage() || + athost() || + atfontface() + ); + } + + /** + * Parse rule. + */ + + function rule() { + const pos = position(); + const sel = selector(); + + if (!sel) { + return error('selector missing'); + } + comments(); + + return pos({ + type: 'rule', + selectors: sel, + declarations: declarations() as Declaration[], + }); + } + + return addParent(stylesheet()); +} + +/** + * Trim `str`. + */ + +function trim(str: string) { + return str ? str.replace(/^\s+|\s+$/g, '') : ''; +} + +/** + * Adds non-enumerable parent node reference to each node. + */ + +function addParent(obj: Stylesheet, parent?: Stylesheet) { + const isNode = obj && typeof obj.type === 'string'; + const childParent = isNode ? obj : parent; + + for (const k of Object.keys(obj)) { + const value = obj[k as keyof Stylesheet]; + if (Array.isArray(value)) { + value.forEach((v) => { + addParent(v, childParent); + }); + } else if (value && typeof value === 'object') { + addParent((value as unknown) as Stylesheet, childParent); + } + } + + if (isNode) { + Object.defineProperty(obj, 'parent', { + configurable: true, + writable: true, + enumerable: false, + value: parent || null, + }); + } + + return obj; +} diff --git a/packages/rrweb-snapshot/src/index.ts b/packages/rrweb-snapshot/src/index.ts new file mode 100644 index 00000000..8335768f --- /dev/null +++ b/packages/rrweb-snapshot/src/index.ts @@ -0,0 +1,24 @@ +import snapshot, { + serializeNodeWithId, + transformAttribute, + visitSnapshot, + cleanupSnapshot, + needMaskingText, + IGNORED_NODE, +} from './snapshot'; +import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild'; +export * from './types'; +export * from './utils'; + +export { + snapshot, + serializeNodeWithId, + rebuild, + buildNodeWithSN, + addHoverClass, + transformAttribute, + visitSnapshot, + cleanupSnapshot, + needMaskingText, + IGNORED_NODE, +}; diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts new file mode 100644 index 00000000..a0778a0c --- /dev/null +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -0,0 +1,392 @@ +import { parse } from './css'; +import { + serializedNodeWithId, + NodeType, + tagMap, + elementNode, + idNodeMap, + INode, +} from './types'; +import { isElement } from './utils'; + +const tagMap: tagMap = { + script: 'noscript', + // camel case svg element tag names + altglyph: 'altGlyph', + altglyphdef: 'altGlyphDef', + altglyphitem: 'altGlyphItem', + animatecolor: 'animateColor', + animatemotion: 'animateMotion', + animatetransform: 'animateTransform', + clippath: 'clipPath', + feblend: 'feBlend', + fecolormatrix: 'feColorMatrix', + fecomponenttransfer: 'feComponentTransfer', + fecomposite: 'feComposite', + feconvolvematrix: 'feConvolveMatrix', + fediffuselighting: 'feDiffuseLighting', + fedisplacementmap: 'feDisplacementMap', + fedistantlight: 'feDistantLight', + fedropshadow: 'feDropShadow', + feflood: 'feFlood', + fefunca: 'feFuncA', + fefuncb: 'feFuncB', + fefuncg: 'feFuncG', + fefuncr: 'feFuncR', + fegaussianblur: 'feGaussianBlur', + feimage: 'feImage', + femerge: 'feMerge', + femergenode: 'feMergeNode', + femorphology: 'feMorphology', + feoffset: 'feOffset', + fepointlight: 'fePointLight', + fespecularlighting: 'feSpecularLighting', + fespotlight: 'feSpotLight', + fetile: 'feTile', + feturbulence: 'feTurbulence', + foreignobject: 'foreignObject', + glyphref: 'glyphRef', + lineargradient: 'linearGradient', + radialgradient: 'radialGradient', +}; +function getTagName(n: elementNode): string { + let tagName = tagMap[n.tagName] ? tagMap[n.tagName] : n.tagName; + if (tagName === 'link' && n.attributes._cssText) { + tagName = 'style'; + } + return tagName; +} + +// based on https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping +function escapeRegExp(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +const HOVER_SELECTOR = /([^\\]):hover/; +const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR, 'g'); +export function addHoverClass(cssText: string): string { + const ast = parse(cssText, { + silent: true, + }); + + if (!ast.stylesheet) { + return cssText; + } + + const selectors: string[] = []; + ast.stylesheet.rules.forEach((rule) => { + if ('selectors' in rule) { + (rule.selectors || []).forEach((selector: string) => { + if (HOVER_SELECTOR.test(selector)) { + selectors.push(selector); + } + }); + } + }); + + if (selectors.length === 0) { + return cssText; + } + + const selectorMatcher = new RegExp( + selectors + .filter((selector, index) => selectors.indexOf(selector) === index) + .sort((a, b) => b.length - a.length) + .map((selector) => { + return escapeRegExp(selector); + }) + .join('|'), + 'g', + ); + + return cssText.replace(selectorMatcher, (selector) => { + const newSelector = selector.replace(HOVER_SELECTOR_GLOBAL, '$1.\\:hover'); + return `${selector}, ${newSelector}`; + }); +} + +function buildNode( + n: serializedNodeWithId, + options: { + doc: Document; + hackCss: boolean; + }, +): Node | null { + const { doc, hackCss } = options; + switch (n.type) { + case NodeType.Document: + return doc.implementation.createDocument(null, '', null); + case NodeType.DocumentType: + return doc.implementation.createDocumentType( + n.name || 'html', + n.publicId, + n.systemId, + ); + case NodeType.Element: + const tagName = getTagName(n); + let node: Element; + if (n.isSVG) { + node = doc.createElementNS('http://www.w3.org/2000/svg', tagName); + } else { + node = doc.createElement(tagName); + } + for (const name in n.attributes) { + if (!n.attributes.hasOwnProperty(name)) { + continue; + } + let value = n.attributes[name]; + value = + typeof value === 'boolean' || typeof value === 'number' ? '' : value; + // attribute names start with rr_ are internal attributes added by rrweb + if (!name.startsWith('rr_')) { + const isTextarea = tagName === 'textarea' && name === 'value'; + const isRemoteOrDynamicCss = + tagName === 'style' && name === '_cssText'; + if (isRemoteOrDynamicCss && hackCss) { + value = addHoverClass(value); + } + if (isTextarea || isRemoteOrDynamicCss) { + const child = doc.createTextNode(value); + // https://github.com/rrweb-io/rrweb/issues/112 + for (const c of Array.from(node.childNodes)) { + if (c.nodeType === node.TEXT_NODE) { + node.removeChild(c); + } + } + node.appendChild(child); + continue; + } + + try { + if (n.isSVG && name === 'xlink:href') { + node.setAttributeNS('http://www.w3.org/1999/xlink', name, value); + } else if ( + name === 'onload' || + name === 'onclick' || + name.substring(0, 7) === 'onmouse' + ) { + // Rename some of the more common atttributes from https://www.w3schools.com/tags/ref_eventattributes.asp + // as setting them triggers a console.error (which shows up despite the try/catch) + // Assumption: these attributes are not used to css + node.setAttribute('_' + name, value); + } else if ( + tagName === 'meta' && + n.attributes['http-equiv'] === 'Content-Security-Policy' && + name === 'content' + ) { + // If CSP contains style-src and inline-style is disabled, there will be an error "Refused to apply inline style because it violates the following Content Security Policy directive: style-src '*'". + // And the function insertStyleRules in rrweb replayer will throw an error "Uncaught TypeError: Cannot read property 'insertRule' of null". + node.setAttribute('csp-content', value); + continue; + } else if ( + tagName === 'link' && + n.attributes.rel === 'preload' && + n.attributes.as === 'script' + ) { + // ignore + } else if ( + tagName === 'link' && + n.attributes.rel === 'prefetch' && + typeof n.attributes.href === 'string' && + n.attributes.href.endsWith('.js') + ) { + // ignore + } else { + node.setAttribute(name, value); + } + } catch (error) { + // skip invalid attribute + } + } else { + // handle internal attributes + if (tagName === 'canvas' && name === 'rr_dataURL') { + const image = document.createElement('img'); + image.src = value; + image.onload = () => { + const ctx = (node as HTMLCanvasElement).getContext('2d'); + if (ctx) { + ctx.drawImage(image, 0, 0, image.width, image.height); + } + }; + } + if (name === 'rr_width') { + (node as HTMLElement).style.width = value; + } + if (name === 'rr_height') { + (node as HTMLElement).style.height = value; + } + if (name === 'rr_mediaCurrentTime') { + (node as HTMLMediaElement).currentTime = n.attributes + .rr_mediaCurrentTime as number; + } + if (name === 'rr_mediaState') { + switch (value) { + case 'played': + (node as HTMLMediaElement) + .play() + .catch((e) => console.warn('media playback error', e)); + break; + case 'paused': + (node as HTMLMediaElement).pause(); + break; + default: + } + } + } + } + if (n.isShadowHost) { + /** + * Since node is newly rebuilt, it should be a normal element + * without shadowRoot. + * But if there are some weird situations that has defined + * custom element in the scope before we rebuild node, it may + * register the shadowRoot earlier. + * The logic in the 'else' block is just a try-my-best solution + * for the corner case, please let we know if it is wrong and + * we can remove it. + */ + if (!node.shadowRoot) { + node.attachShadow({ mode: 'open' }); + } else { + while (node.shadowRoot.firstChild) { + node.shadowRoot.removeChild(node.shadowRoot.firstChild); + } + } + } + return node; + case NodeType.Text: + return doc.createTextNode( + n.isStyle && hackCss ? addHoverClass(n.textContent) : n.textContent, + ); + case NodeType.CDATA: + return doc.createCDATASection(n.textContent); + case NodeType.Comment: + return doc.createComment(n.textContent); + default: + return null; + } +} + +export function buildNodeWithSN( + n: serializedNodeWithId, + options: { + doc: Document; + map: idNodeMap; + skipChild?: boolean; + hackCss: boolean; + afterAppend?: (n: INode) => unknown; + }, +): INode | null { + const { doc, map, skipChild = false, hackCss = true, afterAppend } = options; + let node = buildNode(n, { doc, hackCss }); + if (!node) { + return null; + } + if (n.rootId) { + console.assert( + ((map[n.rootId] as unknown) as Document) === doc, + 'Target document should has the same root id.', + ); + } + // use target document as root document + if (n.type === NodeType.Document) { + // close before open to make sure document was closed + doc.close(); + doc.open(); + node = doc; + } + + (node as INode).__sn = n; + map[n.id] = node as INode; + + if ( + (n.type === NodeType.Document || n.type === NodeType.Element) && + !skipChild + ) { + for (const childN of n.childNodes) { + const childNode = buildNodeWithSN(childN, { + doc, + map, + skipChild: false, + hackCss, + afterAppend, + }); + if (!childNode) { + console.warn('Failed to rebuild', childN); + continue; + } + + if (childN.isShadow && isElement(node) && node.shadowRoot) { + node.shadowRoot.appendChild(childNode); + } else { + node.appendChild(childNode); + } + if (afterAppend) { + afterAppend(childNode); + } + } + } + + return node as INode; +} + +function visit(idNodeMap: idNodeMap, onVisit: (node: INode) => void) { + function walk(node: INode) { + onVisit(node); + } + + for (const key in idNodeMap) { + if (idNodeMap[key]) { + walk(idNodeMap[key]); + } + } +} + +function handleScroll(node: INode) { + const n = node.__sn; + if (n.type !== NodeType.Element) { + return; + } + const el = (node as Node) as HTMLElement; + for (const name in n.attributes) { + if (!(n.attributes.hasOwnProperty(name) && name.startsWith('rr_'))) { + continue; + } + const value = n.attributes[name]; + if (name === 'rr_scrollLeft') { + el.scrollLeft = value as number; + } + if (name === 'rr_scrollTop') { + el.scrollTop = value as number; + } + } +} + +function rebuild( + n: serializedNodeWithId, + options: { + doc: Document; + onVisit?: (node: INode) => unknown; + hackCss?: boolean; + afterAppend?: (n: INode) => unknown; + }, +): [Node | null, idNodeMap] { + const { doc, onVisit, hackCss = true, afterAppend } = options; + const idNodeMap: idNodeMap = {}; + const node = buildNodeWithSN(n, { + doc, + map: idNodeMap, + skipChild: false, + hackCss, + afterAppend, + }); + visit(idNodeMap, (visitedNode) => { + if (onVisit) { + onVisit(visitedNode); + } + handleScroll(visitedNode); + }); + return [node, idNodeMap]; +} + +export default rebuild; diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts new file mode 100644 index 00000000..49c2e13c --- /dev/null +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -0,0 +1,970 @@ +import { + serializedNode, + serializedNodeWithId, + NodeType, + attributes, + INode, + idNodeMap, + MaskInputOptions, + SlimDOMOptions, + MaskTextFn, + MaskInputFn, + KeepIframeSrcFn, +} from './types'; +import { isElement, isShadowRoot, maskInputValue } from './utils'; + +let _id = 1; +const tagNameRegex = RegExp('[^a-z0-9-_:]'); + +export const IGNORED_NODE = -2; + +function genId(): number { + return _id++; +} + +function getValidTagName(element: HTMLElement): string { + if (element instanceof HTMLFormElement) { + return 'form'; + } + + const processedTagName = element.tagName.toLowerCase().trim(); + + if (tagNameRegex.test(processedTagName)) { + // if the tag name is odd and we cannot extract + // anything from the string, then we return a + // generic div + return 'div'; + } + + return processedTagName; +} + +function getCssRulesString(s: CSSStyleSheet): string | null { + try { + const rules = s.rules || s.cssRules; + return rules ? Array.from(rules).map(getCssRuleString).join('') : null; + } catch (error) { + return null; + } +} + +function getCssRuleString(rule: CSSRule): string { + return isCSSImportRule(rule) + ? getCssRulesString(rule.styleSheet) || '' + : rule.cssText; +} + +function isCSSImportRule(rule: CSSRule): rule is CSSImportRule { + return 'styleSheet' in rule; +} + +function extractOrigin(url: string): string { + let origin; + if (url.indexOf('//') > -1) { + origin = url.split('/').slice(0, 3).join('/'); + } else { + origin = url.split('/')[0]; + } + origin = origin.split('?')[0]; + return origin; +} + +const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")(.*?)"|([^)]*))\)/gm; +const RELATIVE_PATH = /^(?!www\.|(?:http|ftp)s?:\/\/|[A-Za-z]:\\|\/\/|#).*/; +const DATA_URI = /^(data:)([^,]*),(.*)/i; +export function absoluteToStylesheet( + cssText: string | null, + href: string, +): string { + return (cssText || '').replace( + URL_IN_CSS_REF, + (origin, quote1, path1, quote2, path2, path3) => { + const filePath = path1 || path2 || path3; + const maybeQuote = quote1 || quote2 || ''; + if (!filePath) { + return origin; + } + if (!RELATIVE_PATH.test(filePath)) { + return `url(${maybeQuote}${filePath}${maybeQuote})`; + } + if (DATA_URI.test(filePath)) { + return `url(${maybeQuote}${filePath}${maybeQuote})`; + } + if (filePath[0] === '/') { + return `url(${maybeQuote}${ + extractOrigin(href) + filePath + }${maybeQuote})`; + } + const stack = href.split('/'); + const parts = filePath.split('/'); + stack.pop(); + for (const part of parts) { + if (part === '.') { + continue; + } else if (part === '..') { + stack.pop(); + } else { + stack.push(part); + } + } + return `url(${maybeQuote}${stack.join('/')}${maybeQuote})`; + }, + ); +} + +const SRCSET_NOT_SPACES = /^[^ \t\n\r\u000c]+/; // Don't use \s, to avoid matching non-breaking space +const SRCSET_COMMAS_OR_SPACES = /^[, \t\n\r\u000c]+/; +function getAbsoluteSrcsetString(doc: Document, attributeValue: string) { + /* + run absoluteToDoc over every url in the srcset + + this is adapted from https://github.com/albell/parse-srcset/ + without the parsing of the descriptors (we return these as-is) + parce-srcset is in turn based on + https://html.spec.whatwg.org/multipage/embedded-content.html#parse-a-srcset-attribute + */ + if (attributeValue.trim() === '') { + return attributeValue; + } + + let pos = 0; + + function collectCharacters(regEx: RegExp) { + var chars, + match = regEx.exec(attributeValue.substring(pos)); + if (match) { + chars = match[0]; + pos += chars.length; + return chars; + } + return ''; + } + + let output = []; + while (true) { + collectCharacters(SRCSET_COMMAS_OR_SPACES); + if (pos >= attributeValue.length) { + break; + } + // don't split on commas within urls + let url = collectCharacters(SRCSET_NOT_SPACES); + if (url.slice(-1) === ',') { + // aside: according to spec more than one comma at the end is a parse error, but we ignore that + url = absoluteToDoc(doc, url.substring(0, url.length - 1)); + // the trailing comma splits the srcset, so the interpretion is that + // another url will follow, and the descriptor is empty + output.push(url); + } else { + let descriptorsStr = ''; + url = absoluteToDoc(doc, url); + let inParens = false; + while (true) { + let c = attributeValue.charAt(pos); + if (c === '') { + output.push((url + descriptorsStr).trim()); + break; + } else if (!inParens) { + if (c === ',') { + pos += 1; + output.push((url + descriptorsStr).trim()); + break; // parse the next url + } else if (c === '(') { + inParens = true; + } + } else { + // in parenthesis; ignore commas + // (parenthesis may be supported by future additions to spec) + if (c === ')') { + inParens = false; + } + } + descriptorsStr += c; + pos += 1; + } + } + } + return output.join(', '); +} + +export function absoluteToDoc(doc: Document, attributeValue: string): string { + if (!attributeValue || attributeValue.trim() === '') { + return attributeValue; + } + const a: HTMLAnchorElement = doc.createElement('a'); + a.href = attributeValue; + return a.href; +} + +function isSVGElement(el: Element): boolean { + return el.tagName === 'svg' || el instanceof SVGElement; +} + +function getHref() { + // return a href without hash + const a = document.createElement('a'); + a.href = ''; + return a.href; +} + +export function transformAttribute( + doc: Document, + tagName: string, + name: string, + value: string, +): string { + // relative path in attribute + if (name === 'src' || ((name === 'href' || name === 'xlink:href') && value)) { + return absoluteToDoc(doc, value); + } else if ( + name === 'background' && + value && + (tagName === 'table' || tagName === 'td' || tagName === 'th') + ) { + return absoluteToDoc(doc, value); + } else if (name === 'srcset' && value) { + return getAbsoluteSrcsetString(doc, value); + } else if (name === 'style' && value) { + return absoluteToStylesheet(value, getHref()); + } else { + return value; + } +} + +export function _isBlockedElement( + element: HTMLElement, + blockClass: string | RegExp, + blockSelector: string | null, +): boolean { + if (typeof blockClass === 'string') { + if (element.classList.contains(blockClass)) { + return true; + } + } else { + // tslint:disable-next-line: prefer-for-of + for (let eIndex = 0; eIndex < element.classList.length; eIndex++) { + const className = element.classList[eIndex]; + if (blockClass.test(className)) { + return true; + } + } + } + if (blockSelector) { + return element.matches(blockSelector); + } + + return false; +} + +export function needMaskingText( + node: Node | null, + maskTextClass: string | RegExp, + maskTextSelector: string | null, +): boolean { + if (!node) { + return false; + } + if (node.nodeType === node.ELEMENT_NODE) { + if (typeof maskTextClass === 'string') { + if ((node as HTMLElement).classList.contains(maskTextClass)) { + return true; + } + } else { + (node as HTMLElement).classList.forEach((className) => { + if (maskTextClass.test(className)) { + return true; + } + }); + } + if (maskTextSelector) { + if ((node as HTMLElement).matches(maskTextSelector)) { + return true; + } + } + return needMaskingText(node.parentNode, maskTextClass, maskTextSelector); + } + if (node.nodeType === node.TEXT_NODE) { + // check parent node since text node do not have class name + return needMaskingText(node.parentNode, maskTextClass, maskTextSelector); + } + return needMaskingText(node.parentNode, maskTextClass, maskTextSelector); +} + +// https://stackoverflow.com/a/36155560 +function onceIframeLoaded( + iframeEl: HTMLIFrameElement, + listener: () => unknown, + iframeLoadTimeout: number, +) { + const win = iframeEl.contentWindow; + if (!win) { + return; + } + // document is loading + let fired = false; + + let readyState: DocumentReadyState; + try { + readyState = win.document.readyState; + } catch (error) { + return; + } + if (readyState !== 'complete') { + const timer = setTimeout(() => { + if (!fired) { + listener(); + fired = true; + } + }, iframeLoadTimeout); + iframeEl.addEventListener('load', () => { + clearTimeout(timer); + fired = true; + listener(); + }); + return; + } + // check blank frame for Chrome + const blankUrl = 'about:blank'; + if ( + win.location.href !== blankUrl || + iframeEl.src === blankUrl || + iframeEl.src === '' + ) { + // iframe was already loaded, make sure we wait to trigger the listener + // till _after_ the mutation that found this iframe has had time to process + setTimeout(listener, 0); + return; + } + // use default listener + iframeEl.addEventListener('load', listener); +} + +function serializeNode( + n: Node, + options: { + doc: Document; + blockClass: string | RegExp; + blockSelector: string | null; + maskTextClass: string | RegExp; + maskTextSelector: string | null; + inlineStylesheet: boolean; + maskInputOptions: MaskInputOptions; + maskTextFn: MaskTextFn | undefined; + maskInputFn: MaskInputFn | undefined; + recordCanvas: boolean; + keepIframeSrcFn: KeepIframeSrcFn; + }, +): serializedNode | false { + const { + doc, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + inlineStylesheet, + maskInputOptions = {}, + maskTextFn, + maskInputFn, + recordCanvas, + keepIframeSrcFn, + } = options; + // Only record root id when document object is not the base document + let rootId: number | undefined; + if (((doc as unknown) as INode).__sn) { + const docId = ((doc as unknown) as INode).__sn.id; + rootId = docId === 1 ? undefined : docId; + } + switch (n.nodeType) { + case n.DOCUMENT_NODE: + return { + type: NodeType.Document, + childNodes: [], + rootId, + }; + case n.DOCUMENT_TYPE_NODE: + return { + type: NodeType.DocumentType, + name: (n as DocumentType).name, + publicId: (n as DocumentType).publicId, + systemId: (n as DocumentType).systemId, + rootId, + }; + case n.ELEMENT_NODE: + const needBlock = _isBlockedElement( + n as HTMLElement, + blockClass, + blockSelector, + ); + const tagName = getValidTagName(n as HTMLElement); + let attributes: attributes = {}; + for (const { name, value } of Array.from((n as HTMLElement).attributes)) { + attributes[name] = transformAttribute(doc, tagName, name, value); + } + // remote css + if (tagName === 'link' && inlineStylesheet) { + const stylesheet = Array.from(doc.styleSheets).find((s) => { + return s.href === (n as HTMLLinkElement).href; + }); + const cssText = getCssRulesString(stylesheet as CSSStyleSheet); + if (cssText) { + delete attributes.rel; + delete attributes.href; + attributes._cssText = absoluteToStylesheet( + cssText, + stylesheet!.href!, + ); + } + } + // dynamic stylesheet + if ( + tagName === 'style' && + (n as HTMLStyleElement).sheet && + // TODO: Currently we only try to get dynamic stylesheet when it is an empty style element + !( + (n as HTMLElement).innerText || + (n as HTMLElement).textContent || + '' + ).trim().length + ) { + const cssText = getCssRulesString( + (n as HTMLStyleElement).sheet as CSSStyleSheet, + ); + if (cssText) { + attributes._cssText = absoluteToStylesheet(cssText, getHref()); + } + } + // form fields + if ( + tagName === 'input' || + tagName === 'textarea' || + tagName === 'select' + ) { + const value = (n as HTMLInputElement | HTMLTextAreaElement).value; + if ( + attributes.type !== 'radio' && + attributes.type !== 'checkbox' && + attributes.type !== 'submit' && + attributes.type !== 'button' && + value + ) { + attributes.value = maskInputValue({ + type: attributes.type, + tagName, + value, + maskInputOptions, + maskInputFn, + }); + } else if ((n as HTMLInputElement).checked) { + attributes.checked = (n as HTMLInputElement).checked; + } + } + if (tagName === 'option') { + const selectValue = (n as HTMLOptionElement).parentElement; + if (attributes.value === (selectValue as HTMLSelectElement).value) { + attributes.selected = (n as HTMLOptionElement).selected; + } + } + // canvas image data + if (tagName === 'canvas' && recordCanvas) { + attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL(); + } + // media elements + if (tagName === 'audio' || tagName === 'video') { + attributes.rr_mediaState = (n as HTMLMediaElement).paused + ? 'paused' + : 'played'; + attributes.rr_mediaCurrentTime = (n as HTMLMediaElement).currentTime; + } + // scroll + if ((n as HTMLElement).scrollLeft) { + attributes.rr_scrollLeft = (n as HTMLElement).scrollLeft; + } + if ((n as HTMLElement).scrollTop) { + attributes.rr_scrollTop = (n as HTMLElement).scrollTop; + } + // block element + if (needBlock) { + const { width, height } = (n as HTMLElement).getBoundingClientRect(); + attributes = { + class: attributes.class, + rr_width: `${width}px`, + rr_height: `${height}px`, + }; + } + // iframe + if (tagName === 'iframe' && !keepIframeSrcFn(attributes.src as string)) { + delete attributes.src; + } + return { + type: NodeType.Element, + tagName, + attributes, + childNodes: [], + isSVG: isSVGElement(n as Element) || undefined, + needBlock, + rootId, + }; + case n.TEXT_NODE: + // The parent node may not be a html element which has a tagName attribute. + // So just let it be undefined which is ok in this use case. + const parentTagName = + n.parentNode && (n.parentNode as HTMLElement).tagName; + let textContent = (n as Text).textContent; + const isStyle = parentTagName === 'STYLE' ? true : undefined; + const isScript = parentTagName === 'SCRIPT' ? true : undefined; + if (isStyle && textContent) { + textContent = absoluteToStylesheet(textContent, getHref()); + } + if (isScript) { + textContent = 'SCRIPT_PLACEHOLDER'; + } + if ( + !isStyle && + !isScript && + needMaskingText(n, maskTextClass, maskTextSelector) && + textContent + ) { + textContent = maskTextFn + ? maskTextFn(textContent) + : textContent.replace(/[\S]/g, '*'); + } + return { + type: NodeType.Text, + textContent: textContent || '', + isStyle, + rootId, + }; + case n.CDATA_SECTION_NODE: + return { + type: NodeType.CDATA, + textContent: '', + rootId, + }; + case n.COMMENT_NODE: + return { + type: NodeType.Comment, + textContent: (n as Comment).textContent || '', + rootId, + }; + default: + return false; + } +} + +function lowerIfExists(maybeAttr: string | number | boolean): string { + if (maybeAttr === undefined) { + return ''; + } else { + return (maybeAttr as string).toLowerCase(); + } +} + +function slimDOMExcluded( + sn: serializedNode, + slimDOMOptions: SlimDOMOptions, +): boolean { + if (slimDOMOptions.comment && sn.type === NodeType.Comment) { + // TODO: convert IE conditional comments to real nodes + return true; + } else if (sn.type === NodeType.Element) { + if ( + slimDOMOptions.script && + // script tag + (sn.tagName === 'script' || + // preload link + (sn.tagName === 'link' && + sn.attributes.rel === 'preload' && + sn.attributes.as === 'script') || + // prefetch link + (sn.tagName === 'link' && + sn.attributes.rel === 'prefetch' && + typeof sn.attributes.href === 'string' && + sn.attributes.href.endsWith('.js'))) + ) { + return true; + } else if ( + slimDOMOptions.headFavicon && + ((sn.tagName === 'link' && sn.attributes.rel === 'shortcut icon') || + (sn.tagName === 'meta' && + (lowerIfExists(sn.attributes.name).match( + /^msapplication-tile(image|color)$/, + ) || + lowerIfExists(sn.attributes.name) === 'application-name' || + lowerIfExists(sn.attributes.rel) === 'icon' || + lowerIfExists(sn.attributes.rel) === 'apple-touch-icon' || + lowerIfExists(sn.attributes.rel) === 'shortcut icon'))) + ) { + return true; + } else if (sn.tagName === 'meta') { + if ( + slimDOMOptions.headMetaDescKeywords && + lowerIfExists(sn.attributes.name).match(/^description|keywords$/) + ) { + return true; + } else if ( + slimDOMOptions.headMetaSocial && + (lowerIfExists(sn.attributes.property).match(/^(og|twitter|fb):/) || // og = opengraph (facebook) + lowerIfExists(sn.attributes.name).match(/^(og|twitter):/) || + lowerIfExists(sn.attributes.name) === 'pinterest') + ) { + return true; + } else if ( + slimDOMOptions.headMetaRobots && + (lowerIfExists(sn.attributes.name) === 'robots' || + lowerIfExists(sn.attributes.name) === 'googlebot' || + lowerIfExists(sn.attributes.name) === 'bingbot') + ) { + return true; + } else if ( + slimDOMOptions.headMetaHttpEquiv && + sn.attributes['http-equiv'] !== undefined + ) { + // e.g. X-UA-Compatible, Content-Type, Content-Language, + // cache-control, X-Translated-By + return true; + } else if ( + slimDOMOptions.headMetaAuthorship && + (lowerIfExists(sn.attributes.name) === 'author' || + lowerIfExists(sn.attributes.name) === 'generator' || + lowerIfExists(sn.attributes.name) === 'framework' || + lowerIfExists(sn.attributes.name) === 'publisher' || + lowerIfExists(sn.attributes.name) === 'progid' || + lowerIfExists(sn.attributes.property).match(/^article:/) || + lowerIfExists(sn.attributes.property).match(/^product:/)) + ) { + return true; + } else if ( + slimDOMOptions.headMetaVerification && + (lowerIfExists(sn.attributes.name) === 'google-site-verification' || + lowerIfExists(sn.attributes.name) === 'yandex-verification' || + lowerIfExists(sn.attributes.name) === 'csrf-token' || + lowerIfExists(sn.attributes.name) === 'p:domain_verify' || + lowerIfExists(sn.attributes.name) === 'verify-v1' || + lowerIfExists(sn.attributes.name) === 'verification' || + lowerIfExists(sn.attributes.name) === 'shopify-checkout-api-token') + ) { + return true; + } + } + } + return false; +} + +export function serializeNodeWithId( + n: Node | INode, + options: { + doc: Document; + map: idNodeMap; + blockClass: string | RegExp; + blockSelector: string | null; + maskTextClass: string | RegExp; + maskTextSelector: string | null; + skipChild: boolean; + inlineStylesheet: boolean; + maskInputOptions?: MaskInputOptions; + maskTextFn: MaskTextFn | undefined; + maskInputFn: MaskInputFn | undefined; + slimDOMOptions: SlimDOMOptions; + keepIframeSrcFn?: KeepIframeSrcFn; + recordCanvas?: boolean; + preserveWhiteSpace?: boolean; + onSerialize?: (n: INode) => unknown; + onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; + iframeLoadTimeout?: number; + }, +): serializedNodeWithId | null { + const { + doc, + map, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + skipChild = false, + inlineStylesheet = true, + maskInputOptions = {}, + maskTextFn, + maskInputFn, + slimDOMOptions, + recordCanvas = false, + onSerialize, + onIframeLoad, + iframeLoadTimeout = 5000, + keepIframeSrcFn = () => false, + } = options; + let { preserveWhiteSpace = true } = options; + const _serializedNode = serializeNode(n, { + doc, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + inlineStylesheet, + maskInputOptions, + maskTextFn, + maskInputFn, + recordCanvas, + keepIframeSrcFn, + }); + if (!_serializedNode) { + // TODO: dev only + console.warn(n, 'not serialized'); + return null; + } + + let id; + // Try to reuse the previous id + if ('__sn' in n) { + id = n.__sn.id; + } else if ( + slimDOMExcluded(_serializedNode, slimDOMOptions) || + (!preserveWhiteSpace && + _serializedNode.type === NodeType.Text && + !_serializedNode.isStyle && + !_serializedNode.textContent.replace(/^\s+|\s+$/gm, '').length) + ) { + id = IGNORED_NODE; + } else { + id = genId(); + } + const serializedNode = Object.assign(_serializedNode, { id }); + (n as INode).__sn = serializedNode; + if (id === IGNORED_NODE) { + return null; // slimDOM + } + map[id] = n as INode; + if (onSerialize) { + onSerialize(n as INode); + } + let recordChild = !skipChild; + if (serializedNode.type === NodeType.Element) { + recordChild = recordChild && !serializedNode.needBlock; + // this property was not needed in replay side + delete serializedNode.needBlock; + } + if ( + (serializedNode.type === NodeType.Document || + serializedNode.type === NodeType.Element) && + recordChild + ) { + if ( + slimDOMOptions.headWhitespace && + _serializedNode.type === NodeType.Element && + _serializedNode.tagName === 'head' + // would impede performance: || getComputedStyle(n)['white-space'] === 'normal' + ) { + preserveWhiteSpace = false; + } + const bypassOptions = { + doc, + map, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + skipChild, + inlineStylesheet, + maskInputOptions, + maskTextFn, + maskInputFn, + slimDOMOptions, + recordCanvas, + preserveWhiteSpace, + onSerialize, + onIframeLoad, + iframeLoadTimeout, + keepIframeSrcFn, + }; + for (const childN of Array.from(n.childNodes)) { + const serializedChildNode = serializeNodeWithId(childN, bypassOptions); + if (serializedChildNode) { + serializedNode.childNodes.push(serializedChildNode); + } + } + + if (isElement(n) && n.shadowRoot) { + serializedNode.isShadowHost = true; + for (const childN of Array.from(n.shadowRoot.childNodes)) { + const serializedChildNode = serializeNodeWithId(childN, bypassOptions); + if (serializedChildNode) { + serializedChildNode.isShadow = true; + serializedNode.childNodes.push(serializedChildNode); + } + } + } + } + + if (n.parentNode && isShadowRoot(n.parentNode)) { + serializedNode.isShadow = true; + } + + if ( + serializedNode.type === NodeType.Element && + serializedNode.tagName === 'iframe' + ) { + onceIframeLoaded( + n as HTMLIFrameElement, + () => { + const iframeDoc = (n as HTMLIFrameElement).contentDocument; + if (iframeDoc && onIframeLoad) { + const serializedIframeNode = serializeNodeWithId(iframeDoc, { + doc: iframeDoc, + map, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + skipChild: false, + inlineStylesheet, + maskInputOptions, + maskTextFn, + maskInputFn, + slimDOMOptions, + recordCanvas, + preserveWhiteSpace, + onSerialize, + onIframeLoad, + iframeLoadTimeout, + keepIframeSrcFn, + }); + + if (serializedIframeNode) { + onIframeLoad(n as INode, serializedIframeNode); + } + } + }, + iframeLoadTimeout, + ); + } + + return serializedNode; +} + +function snapshot( + n: Document, + options?: { + blockClass?: string | RegExp; + blockSelector?: string | null; + maskTextClass?: string | RegExp; + maskTextSelector?: string | null; + inlineStylesheet?: boolean; + maskAllInputs?: boolean | MaskInputOptions; + maskTextFn?: MaskTextFn; + maskInputFn?: MaskTextFn; + slimDOM?: boolean | SlimDOMOptions; + recordCanvas?: boolean; + preserveWhiteSpace?: boolean; + onSerialize?: (n: INode) => unknown; + onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; + iframeLoadTimeout?: number; + keepIframeSrcFn?: KeepIframeSrcFn; + }, +): [serializedNodeWithId | null, idNodeMap] { + const { + blockClass = 'rr-block', + blockSelector = null, + maskTextClass = 'rr-mask', + maskTextSelector = null, + inlineStylesheet = true, + recordCanvas = false, + maskAllInputs = false, + maskTextFn, + maskInputFn, + slimDOM = false, + preserveWhiteSpace, + onSerialize, + onIframeLoad, + iframeLoadTimeout, + keepIframeSrcFn = () => false, + } = options || {}; + const idNodeMap: idNodeMap = {}; + 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, + } + : maskAllInputs === false + ? { + password: true, + } + : maskAllInputs; + const slimDOMOptions: SlimDOMOptions = + slimDOM === true || slimDOM === 'all' + ? // if true: set of sensible options that should not throw away any information + { + script: true, + comment: true, + headFavicon: true, + headWhitespace: true, + headMetaDescKeywords: slimDOM === 'all', // destructive + headMetaSocial: true, + headMetaRobots: true, + headMetaHttpEquiv: true, + headMetaAuthorship: true, + headMetaVerification: true, + } + : slimDOM === false + ? {} + : slimDOM; + return [ + serializeNodeWithId(n, { + doc: n, + map: idNodeMap, + blockClass, + blockSelector, + maskTextClass, + maskTextSelector, + skipChild: false, + inlineStylesheet, + maskInputOptions, + maskTextFn, + maskInputFn, + slimDOMOptions, + recordCanvas, + preserveWhiteSpace, + onSerialize, + onIframeLoad, + iframeLoadTimeout, + keepIframeSrcFn, + }), + idNodeMap, + ]; +} + +export function visitSnapshot( + node: serializedNodeWithId, + onVisit: (node: serializedNodeWithId) => unknown, +) { + function walk(current: serializedNodeWithId) { + onVisit(current); + if ( + current.type === NodeType.Document || + current.type === NodeType.Element + ) { + current.childNodes.forEach(walk); + } + } + + walk(node); +} + +export function cleanupSnapshot() { + // allow a new recording to start numbering nodes from scratch + _id = 1; +} + +export default snapshot; diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts new file mode 100644 index 00000000..ffeef690 --- /dev/null +++ b/packages/rrweb-snapshot/src/types.ts @@ -0,0 +1,113 @@ +export enum NodeType { + Document, + DocumentType, + Element, + Text, + CDATA, + Comment, +} + +export type documentNode = { + type: NodeType.Document; + childNodes: serializedNodeWithId[]; +}; + +export type documentTypeNode = { + type: NodeType.DocumentType; + name: string; + publicId: string; + systemId: string; +}; + +export type attributes = { + [key: string]: string | number | boolean; +}; +export type elementNode = { + type: NodeType.Element; + tagName: string; + attributes: attributes; + childNodes: serializedNodeWithId[]; + isSVG?: true; + needBlock?: boolean; +}; + +export type textNode = { + type: NodeType.Text; + textContent: string; + isStyle?: true; +}; + +export type cdataNode = { + type: NodeType.CDATA; + textContent: ''; +}; + +export type commentNode = { + type: NodeType.Comment; + textContent: string; +}; + +export type serializedNode = ( + | documentNode + | documentTypeNode + | elementNode + | textNode + | cdataNode + | commentNode +) & { + rootId?: number; + isShadowHost?: boolean; + isShadow?: boolean; +}; + +export type serializedNodeWithId = serializedNode & { id: number }; + +export type tagMap = { + [key: string]: string; +}; + +export interface INode extends Node { + __sn: serializedNodeWithId; +} + +export type idNodeMap = { + [key: number]: INode; +}; + +export type MaskInputOptions = Partial<{ + color: boolean; + date: boolean; + 'datetime-local': boolean; + email: boolean; + month: boolean; + number: boolean; + range: boolean; + search: boolean; + tel: boolean; + text: boolean; + time: boolean; + url: boolean; + week: boolean; + // unify textarea and select element with text input + textarea: boolean; + select: boolean; + password: boolean; +}>; + +export type SlimDOMOptions = Partial<{ + script: boolean; + comment: boolean; + headFavicon: boolean; + headWhitespace: boolean; + headMetaDescKeywords: boolean; + headMetaSocial: boolean; + headMetaRobots: boolean; + headMetaHttpEquiv: boolean; + headMetaAuthorship: boolean; + headMetaVerification: boolean; +}>; + +export type MaskTextFn = (text: string) => string; +export type MaskInputFn = (text: string) => string; + +export type KeepIframeSrcFn = (src: string) => boolean; diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts new file mode 100644 index 00000000..ed5c5f75 --- /dev/null +++ b/packages/rrweb-snapshot/src/utils.ts @@ -0,0 +1,37 @@ +import { INode, MaskInputFn, MaskInputOptions } from './types'; + +export function isElement(n: Node | INode): n is Element { + return n.nodeType === n.ELEMENT_NODE; +} + +export function isShadowRoot(n: Node): n is ShadowRoot { + const host: Element | null = (n as ShadowRoot)?.host; + return Boolean(host && host.shadowRoot && host.shadowRoot === n); +} + +export function maskInputValue({ + maskInputOptions, + tagName, + type, + value, + maskInputFn, +}: { + maskInputOptions: MaskInputOptions; + tagName: string; + type: string | number | boolean | null; + value: string | null; + maskInputFn?: MaskInputFn; +}): string { + let text = value || ''; + if ( + maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || + maskInputOptions[type as keyof MaskInputOptions] + ) { + if (maskInputFn) { + text = maskInputFn(text); + } else { + text = '*'.repeat(text.length); + } + } + return text; +} diff --git a/packages/rrweb-snapshot/test.d.ts b/packages/rrweb-snapshot/test.d.ts new file mode 100644 index 00000000..a3b614ee --- /dev/null +++ b/packages/rrweb-snapshot/test.d.ts @@ -0,0 +1,21 @@ +declare module 'rollup-plugin-typescript' { + function typescript(): any; + export = typescript; +} + +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; +} diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.ts.snap new file mode 100644 index 00000000..e087f79b --- /dev/null +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.ts.snap @@ -0,0 +1,838 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`[html file]: about-mozilla.html 1`] = ` +" + The Book of Mozilla, 11:9 + +

+ Mammon slept. And the beast reborn spread over the earth and its numbers + grew legion. And they proclaimed the times and sacrificed crops unto the + fire, with the cunning of foxes. And they built a new world in their own + image as promised by the + sacred words, and spoke + of the beast with their children. Mammon awoke, and lo! it was + naught but a follower. +

+ from The Book of Mozilla, 11:9
(10th Edition) +

" +`; + +exports[`[html file]: basic.html 1`] = ` +" + + + + Document + +

Title

" +`; + +exports[`[html file]: block-element.html 1`] = ` +" + + + + Document + + +
+
record 2
+
+
+ " +`; + +exports[`[html file]: cors-style-sheet.html 1`] = ` +" + + + + with style sheet + + + + " +`; + +exports[`[html file]: dynamic-stylesheet.html 1`] = ` +" + + + + dynamic stylesheet + + + + +

p tag

+ " +`; + +exports[`[html file]: form-fields.html 1`] = ` +" + + + + form fields + +
+ + + + + + +
+ + " +`; + +exports[`[html file]: hover.html 1`] = ` +" + + + + hover selector + + +
hover me
+" +`; + +exports[`[html file]: iframe.html 1`] = ` +" + + + + iframe + + + +" +`; + +exports[`[html file]: iframe-inner.html 1`] = ` +" +" +`; + +exports[`[html file]: invalid-attribute.html 1`] = ` +" +" +`; + +exports[`[html file]: invalid-doctype.html 1`] = ` +" + + + Invalid Doctype + + " +`; + +exports[`[html file]: invalid-tagname.html 1`] = ` +" + + + + Document + + +
Hello
+
Hello
+
+" +`; + +exports[`[html file]: mask-text.html 1`] = ` +" + + + + Document + +

**** *

+
+ **** * +
+
**** *
+ " +`; + +exports[`[html file]: picture.html 1`] = ` +" + + + + + " +`; + +exports[`[html file]: preload.html 1`] = ` +" + + + Document + + + + " +`; + +exports[`[html file]: shadow-dom.html 1`] = ` +" + + + shadow DOM + + + + + + +
content panel 1
+
content panel 2
+
content panel 3
+
+ + " +`; + +exports[`[html file]: video.html 1`] = ` +" + + + + video + + + + " +`; + +exports[`[html file]: with-relative-res.html 1`] = ` +" + + + + Document + + + +
Hello
+ Hello +
Hello
+
+ \\"\\" + \\"\\" + \\"\\" + \\"\\" + \\"\\"" +`; + +exports[`[html file]: with-script.html 1`] = ` +" + + + + with script + + + " +`; + +exports[`[html file]: with-style-sheet.html 1`] = ` +" + + + + with style sheet + + +" +`; + +exports[`[html file]: with-style-sheet-with-import.html 1`] = ` +" + + + + with style sheet with import + + +" +`; + +exports[`iframe integration tests 1`] = ` +"{ + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Main\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 13 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"one\\" + }, + \\"childNodes\\": [], + \\"id\\": 16 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"iframe\\", + \\"attributes\\": { + \\"id\\": \\"two\\" + }, + \\"childNodes\\": [], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n\\\\n\\", + \\"id\\": 19 + } + ], + \\"id\\": 14 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 +}" +`; + +exports[`shadown DOM integration tests 1`] = ` +"{ + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"shadow DOM\\", + \\"id\\": 11 + } + ], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 12 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 13 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"fancy-tabs\\", + \\"attributes\\": { + \\"background\\": \\"\\", + \\"role\\": \\"tablist\\", + \\"selected\\": \\"1\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"button\\", + \\"attributes\\": { + \\"slot\\": \\"title\\", + \\"role\\": \\"tab\\", + \\"tabindex\\": \\"-1\\", + \\"aria-selected\\": \\"false\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Tab 1\\", + \\"id\\": 19 + } + ], + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 20 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"button\\", + \\"attributes\\": { + \\"slot\\": \\"title\\", + \\"selected\\": \\"\\", + \\"role\\": \\"tab\\", + \\"tabindex\\": \\"0\\", + \\"aria-selected\\": \\"true\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Tab 2\\", + \\"id\\": 22 + } + ], + \\"id\\": 21 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 23 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"button\\", + \\"attributes\\": { + \\"slot\\": \\"title\\", + \\"role\\": \\"tab\\", + \\"tabindex\\": \\"-1\\", + \\"aria-selected\\": \\"false\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Tab 3\\", + \\"id\\": 25 + } + ], + \\"id\\": 24 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 26 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"section\\", + \\"attributes\\": { + \\"role\\": \\"tabpanel\\", + \\"tabindex\\": \\"0\\", + \\"aria-hidden\\": \\"true\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"content panel 1\\", + \\"id\\": 28 + } + ], + \\"id\\": 27 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 29 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"section\\", + \\"attributes\\": { + \\"role\\": \\"tabpanel\\", + \\"tabindex\\": \\"0\\", + \\"aria-hidden\\": \\"false\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"content panel 2\\", + \\"id\\": 31 + } + ], + \\"id\\": 30 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 32 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"section\\", + \\"attributes\\": { + \\"role\\": \\"tabpanel\\", + \\"tabindex\\": \\"0\\", + \\"aria-hidden\\": \\"true\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"content panel 3\\", + \\"id\\": 34 + } + ], + \\"id\\": 33 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 35 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 36, + \\"isShadow\\": true + }, + { + \\"type\\": 2, + \\"tagName\\": \\"style\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n :host {\\\\n display: inline-block;\\\\n width: 650px;\\\\n font-family: 'Roboto Slab';\\\\n contain: content;\\\\n }\\\\n :host([background]) {\\\\n background: var(--background-color, #9E9E9E);\\\\n border-radius: 10px;\\\\n padding: 10px;\\\\n }\\\\n #panels {\\\\n box-shadow: 0 2px 2px rgba(0, 0, 0, .3);\\\\n background: white;\\\\n border-radius: 3px;\\\\n padding: 16px;\\\\n height: 250px;\\\\n overflow: auto;\\\\n }\\\\n #tabs {\\\\n display: inline-flex;\\\\n -webkit-user-select: none;\\\\n user-select: none;\\\\n }\\\\n #tabs slot {\\\\n display: inline-flex; /* Safari bug. Treats as a parent */\\\\n }\\\\n /* Safari does not support #id prefixes on ::slotted\\\\n See https://bugs.webkit.org/show_bug.cgi?id=160538 */\\\\n #tabs ::slotted(*) {\\\\n font: 400 16px/22px 'Roboto';\\\\n padding: 16px 8px;\\\\n margin: 0;\\\\n text-align: center;\\\\n width: 100px;\\\\n text-overflow: ellipsis;\\\\n white-space: nowrap;\\\\n overflow: hidden;\\\\n cursor: pointer;\\\\n border-top-left-radius: 3px;\\\\n border-top-right-radius: 3px;\\\\n background: linear-gradient(#fafafa, #eee);\\\\n border: none; /* if the user users a diff --git a/packages/rrweb-snapshot/test/html/iframe.html b/packages/rrweb-snapshot/test/html/iframe.html new file mode 100644 index 00000000..8b45139e --- /dev/null +++ b/packages/rrweb-snapshot/test/html/iframe.html @@ -0,0 +1,12 @@ + + + + + + + iframe + + + + + diff --git a/packages/rrweb-snapshot/test/html/invalid-attribute.html b/packages/rrweb-snapshot/test/html/invalid-attribute.html new file mode 100644 index 00000000..e2428e28 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/invalid-attribute.html @@ -0,0 +1,3 @@ + + + diff --git a/packages/rrweb-snapshot/test/html/invalid-doctype.html b/packages/rrweb-snapshot/test/html/invalid-doctype.html new file mode 100644 index 00000000..395c916d --- /dev/null +++ b/packages/rrweb-snapshot/test/html/invalid-doctype.html @@ -0,0 +1,9 @@ + + + + + + Invalid Doctype + + + diff --git a/packages/rrweb-snapshot/test/html/invalid-tagname.html b/packages/rrweb-snapshot/test/html/invalid-tagname.html new file mode 100644 index 00000000..e28dd710 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/invalid-tagname.html @@ -0,0 +1,14 @@ + + + + + + + Document + + + Hello + Hello + + + diff --git a/packages/rrweb-snapshot/test/html/mask-text.html b/packages/rrweb-snapshot/test/html/mask-text.html new file mode 100644 index 00000000..fe177a61 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/mask-text.html @@ -0,0 +1,17 @@ + + + + + + + Document + + + +

mask 1

+
+ mask 2 +
+
mask 3
+ + diff --git a/packages/rrweb-snapshot/test/html/picture.html b/packages/rrweb-snapshot/test/html/picture.html new file mode 100644 index 00000000..27d83686 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/picture.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/rrweb-snapshot/test/html/preload.html b/packages/rrweb-snapshot/test/html/preload.html new file mode 100644 index 00000000..32e84a26 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/preload.html @@ -0,0 +1,11 @@ + + + + + + Document + + + + + diff --git a/packages/rrweb-snapshot/test/html/shadow-dom.html b/packages/rrweb-snapshot/test/html/shadow-dom.html new file mode 100644 index 00000000..0050bede --- /dev/null +++ b/packages/rrweb-snapshot/test/html/shadow-dom.html @@ -0,0 +1,209 @@ + + + + + + shadow DOM + + + + + + +
content panel 1
+
content panel 2
+
content panel 3
+
+ + + diff --git a/packages/rrweb-snapshot/test/html/video.html b/packages/rrweb-snapshot/test/html/video.html new file mode 100644 index 00000000..653f7172 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/video.html @@ -0,0 +1,19 @@ + + + + + + + video + + + + + diff --git a/packages/rrweb-snapshot/test/html/with-relative-res.html b/packages/rrweb-snapshot/test/html/with-relative-res.html new file mode 100644 index 00000000..c390dc53 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/with-relative-res.html @@ -0,0 +1,21 @@ + + + + + + + Document + + + + Hello + Hello + Hello + + + + + + + + \ No newline at end of file diff --git a/packages/rrweb-snapshot/test/html/with-script.html b/packages/rrweb-snapshot/test/html/with-script.html new file mode 100644 index 00000000..b4812e96 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/with-script.html @@ -0,0 +1,18 @@ + + + + + + + + with script + + + + + + + + diff --git a/packages/rrweb-snapshot/test/html/with-style-sheet-with-import.html b/packages/rrweb-snapshot/test/html/with-style-sheet-with-import.html new file mode 100644 index 00000000..6b45f65b --- /dev/null +++ b/packages/rrweb-snapshot/test/html/with-style-sheet-with-import.html @@ -0,0 +1,16 @@ + + + + + + + + with style sheet with import + + + + + + + + diff --git a/packages/rrweb-snapshot/test/html/with-style-sheet.html b/packages/rrweb-snapshot/test/html/with-style-sheet.html new file mode 100644 index 00000000..2083dae9 --- /dev/null +++ b/packages/rrweb-snapshot/test/html/with-style-sheet.html @@ -0,0 +1,16 @@ + + + + + + + + with style sheet + + + + + + + + diff --git a/packages/rrweb-snapshot/test/iframe-html/frame1.html b/packages/rrweb-snapshot/test/iframe-html/frame1.html new file mode 100644 index 00000000..8810af46 --- /dev/null +++ b/packages/rrweb-snapshot/test/iframe-html/frame1.html @@ -0,0 +1,13 @@ + + + + + + Frame 1 + + + frame 1 + + + + diff --git a/packages/rrweb-snapshot/test/iframe-html/frame2.html b/packages/rrweb-snapshot/test/iframe-html/frame2.html new file mode 100644 index 00000000..34324085 --- /dev/null +++ b/packages/rrweb-snapshot/test/iframe-html/frame2.html @@ -0,0 +1,11 @@ + + + + + + Frame 2 + + + frame 2 + + diff --git a/packages/rrweb-snapshot/test/iframe-html/main.html b/packages/rrweb-snapshot/test/iframe-html/main.html new file mode 100644 index 00000000..d8e712bc --- /dev/null +++ b/packages/rrweb-snapshot/test/iframe-html/main.html @@ -0,0 +1,12 @@ + + + + + + Main + + + + + + diff --git a/packages/rrweb-snapshot/test/integration.ts b/packages/rrweb-snapshot/test/integration.ts new file mode 100644 index 00000000..751fbeed --- /dev/null +++ b/packages/rrweb-snapshot/test/integration.ts @@ -0,0 +1,215 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as http from 'http'; +import * as url from 'url'; +import 'mocha'; +import * as puppeteer from 'puppeteer'; +import * as rollup from 'rollup'; +import typescript = require('rollup-plugin-typescript'); +import { assert } from 'chai'; +import { SnapshotState, toMatchSnapshot } from 'jest-snapshot'; +import { Suite } from 'mocha'; + +const htmlFolder = path.join(__dirname, 'html'); +const htmls = fs.readdirSync(htmlFolder).map((filePath) => { + const raw = fs.readFileSync(path.resolve(htmlFolder, filePath), 'utf-8'); + return { + filePath, + src: raw, + }; +}); + +interface IMimeType { + [key: string]: string; +} + +const server = () => + new Promise((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'); + res.end(data); + } catch (error) { + res.end(); + } + }); + s.listen(3030).on('listening', () => { + resolve(s); + }); + }); + +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; +} + +interface ISuite extends Suite { + server: http.Server; + browser: puppeteer.Browser; + code: string; +} + +describe('integration tests', function (this: ISuite) { + before(async () => { + this.server = await server(); + this.browser = await puppeteer.launch({ + // headless: false, + }); + + const bundle = await rollup.rollup({ + input: path.resolve(__dirname, '../src/index.ts'), + plugins: [typescript()], + }); + const { code } = await bundle.generate({ + name: 'rrweb', + format: 'iife', + }); + this.code = code; + }); + + after(async () => { + await this.browser.close(); + await this.server.close(); + }); + + for (const html of htmls) { + const title = '[html file]: ' + html.filePath; + it(title, async () => { + const page: puppeteer.Page = await this.browser.newPage(); + // console for debug + // tslint:disable-next-line: no-console + page.on('console', (msg) => console.log(msg.text())); + await page.goto(`http://localhost:3030/html`); + await page.setContent(html.src, { + waitUntil: 'load', + }); + const rebuildHtml = ( + await page.evaluate(`${this.code} + const x = new XMLSerializer(); + const [snap] = rrweb.snapshot(document); + x.serializeToString(rrweb.rebuild(snap, { doc: document })[0]); + `) + ).replace(/\n\n/g, ''); + const result = matchSnapshot(rebuildHtml, __filename, title); + assert(result.pass, result.pass ? '' : result.report()); + }).timeout(5000); + } +}); + +describe('iframe integration tests', function (this: ISuite) { + const iframeHtml = path.join(__dirname, 'iframe-html/main.html'); + const raw = fs.readFileSync(iframeHtml, 'utf-8'); + + before(async () => { + this.server = await server(); + this.browser = await puppeteer.launch({ + // headless: false, + }); + + const bundle = await rollup.rollup({ + input: path.resolve(__dirname, '../src/index.ts'), + plugins: [typescript()], + }); + const { code } = await bundle.generate({ + name: 'rrweb', + format: 'iife', + }); + this.code = code; + }); + + after(async () => { + await this.browser.close(); + await this.server.close(); + }); + + it('snapshot async iframes', async () => { + const page: puppeteer.Page = await this.browser.newPage(); + // console for debug + // tslint:disable-next-line: no-console + page.on('console', (msg) => console.log(msg.text())); + await page.goto(`http://localhost:3030/html`); + await page.setContent(raw, { + waitUntil: 'load', + }); + const snapshotResult = JSON.stringify( + await page.evaluate(`${this.code}; + rrweb.snapshot(document)[0]; + `), + null, + 2, + ); + const result = matchSnapshot(snapshotResult, __filename, this.title); + assert(result.pass, result.pass ? '' : result.report()); + }).timeout(5000); +}); + +describe('shadown DOM integration tests', function (this: ISuite) { + const shadowDomHtml = path.join(__dirname, 'html/shadow-dom.html'); + const raw = fs.readFileSync(shadowDomHtml, 'utf-8'); + + before(async () => { + this.server = await server(); + this.browser = await puppeteer.launch({ + // headless: false, + }); + + const bundle = await rollup.rollup({ + input: path.resolve(__dirname, '../src/index.ts'), + plugins: [typescript()], + }); + const { code } = await bundle.generate({ + name: 'rrweb', + format: 'iife', + }); + this.code = code; + }); + + after(async () => { + await this.browser.close(); + await this.server.close(); + }); + + it('snapshot shadow DOM', async () => { + const page: puppeteer.Page = await this.browser.newPage(); + // console for debug + // tslint:disable-next-line: no-console + page.on('console', (msg) => console.log(msg.text())); + await page.goto(`http://localhost:3030/html`); + await page.setContent(raw, { + waitUntil: 'load', + }); + const snapshotResult = JSON.stringify( + await page.evaluate(`${this.code}; + rrweb.snapshot(document)[0]; + `), + null, + 2, + ); + const result = matchSnapshot(snapshotResult, __filename, this.title); + assert(result.pass, result.pass ? '' : result.report()); + }).timeout(5000); +}); diff --git a/packages/rrweb-snapshot/test/js/a.js b/packages/rrweb-snapshot/test/js/a.js new file mode 100644 index 00000000..7a776f91 --- /dev/null +++ b/packages/rrweb-snapshot/test/js/a.js @@ -0,0 +1 @@ +var a = 1 + 1; diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts new file mode 100644 index 00000000..a343e780 --- /dev/null +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -0,0 +1,64 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import 'mocha'; +import { expect } from 'chai'; +import { addHoverClass } from '../src/rebuild'; + +describe('add hover class to hover selector related rules', () => { + it('will do nothing to css text without :hover', () => { + const cssText = 'body { color: white }'; + expect(addHoverClass(cssText)).to.equal(cssText); + }); + + it('can add hover class to css text', () => { + const cssText = '.a:hover { color: white }'; + expect(addHoverClass(cssText)).to.equal( + '.a:hover, .a.\\:hover { color: white }', + ); + }); + + it('can add hover class when there is multi selector', () => { + const cssText = '.a, .b:hover, .c { color: white }'; + expect(addHoverClass(cssText)).to.equal( + '.a, .b:hover, .b.\\:hover, .c { color: white }', + ); + }); + + it('can add hover class when there is a multi selector with the same prefix', () => { + const cssText = '.a:hover, .a:hover::after { color: white }'; + expect(addHoverClass(cssText)).to.equal( + '.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }', + ); + }); + + it('can add hover class when :hover is not the end of selector', () => { + const cssText = 'div:hover::after { color: white }'; + expect(addHoverClass(cssText)).to.equal( + 'div:hover::after, div.\\:hover::after { color: white }', + ); + }); + + it('can add hover class when the selector has multi :hover', () => { + const cssText = 'a:hover b:hover { color: white }'; + expect(addHoverClass(cssText)).to.equal( + 'a:hover b:hover, a.\\:hover b.\\:hover { color: white }', + ); + }); + + it('will ignore :hover in css value', () => { + const cssText = '.a::after { content: ":hover" }'; + expect(addHoverClass(cssText)).to.equal(cssText); + }); + + it('benchmark', () => { + const cssText = fs.readFileSync( + path.resolve(__dirname, './css/benchmark.css'), + 'utf8', + ); + const start = process.hrtime(); + addHoverClass(cssText); + const end = process.hrtime(start); + const duration = end[0] * 1_000 + end[1] / 1_000_000; + expect(duration).to.below(100); + }); +}); diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts new file mode 100644 index 00000000..4d96a02b --- /dev/null +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -0,0 +1,130 @@ +import 'mocha'; +import { JSDOM } from 'jsdom'; +import { expect } from 'chai'; +import { absoluteToStylesheet, _isBlockedElement } from '../src/snapshot'; + +describe('absolute url to stylesheet', () => { + const href = 'http://localhost/css/style.css'; + + it('can handle relative path', () => { + expect(absoluteToStylesheet('url(a.jpg)', href)).to.equal( + `url(http://localhost/css/a.jpg)`, + ); + }); + + it('can handle same level path', () => { + expect(absoluteToStylesheet('url("./a.jpg")', href)).to.equal( + `url("http://localhost/css/a.jpg")`, + ); + }); + + it('can handle parent level path', () => { + expect(absoluteToStylesheet('url("../a.jpg")', href)).to.equal( + `url("http://localhost/a.jpg")`, + ); + }); + + it('can handle absolute path', () => { + expect(absoluteToStylesheet('url("/a.jpg")', href)).to.equal( + `url("http://localhost/a.jpg")`, + ); + }); + + it('can handle external path', () => { + expect( + absoluteToStylesheet('url("http://localhost/a.jpg")', href), + ).to.equal(`url("http://localhost/a.jpg")`); + }); + + it('can handle single quote path', () => { + expect(absoluteToStylesheet(`url('./a.jpg')`, href)).to.equal( + `url('http://localhost/css/a.jpg')`, + ); + }); + + it('can handle no quote path', () => { + expect(absoluteToStylesheet('url(./a.jpg)', href)).to.equal( + `url(http://localhost/css/a.jpg)`, + ); + }); + + it('can handle multiple no quote paths', () => { + expect( + absoluteToStylesheet( + 'background-image: url(images/b.jpg);background: #aabbcc url(images/a.jpg) 50% 50% repeat;', + href, + ), + ).to.equal( + `background-image: url(http://localhost/css/images/b.jpg);` + + `background: #aabbcc url(http://localhost/css/images/a.jpg) 50% 50% repeat;`, + ); + }); + + it('can handle data url image', () => { + expect( + absoluteToStylesheet('url(data:image/gif;base64,ABC)', href), + ).to.equal('url(data:image/gif;base64,ABC)'); + expect( + absoluteToStylesheet( + 'url(data:application/font-woff;base64,d09GMgABAAAAAAm)', + href, + ), + ).to.equal('url(data:application/font-woff;base64,d09GMgABAAAAAAm)'); + }); + + it('preserves quotes around inline svgs with spaces', () => { + expect( + absoluteToStylesheet( + "url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%2328a745' d='M3'/%3E%3C/svg%3E\")", + href, + ), + ).to.equal( + "url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%2328a745' d='M3'/%3E%3C/svg%3E\")", + ); + expect( + absoluteToStylesheet( + 'url(\'data:image/svg+xml;utf8,\')', + href, + ), + ).to.equal( + 'url(\'data:image/svg+xml;utf8,\')', + ); + expect( + absoluteToStylesheet( + 'url("data:image/svg+xml;utf8,")', + href, + ), + ).to.equal( + 'url("data:image/svg+xml;utf8,")', + ); + }); + it('can handle empty path', () => { + expect(absoluteToStylesheet(`url('')`, href)).to.equal(`url('')`); + }); +}); + +describe('isBlockedElement()', () => { + const subject = (html: string, opt: any = {}) => + _isBlockedElement(render(html), 'rr-block', opt.blockSelector); + + const render = (html: string): HTMLElement => + JSDOM.fragment(html).querySelector('div')!; + + it('can handle empty elements', () => { + expect(subject('
')).to.equal(false); + }); + + it('blocks prohibited className', () => { + expect(subject('
')).to.equal(true); + }); + + it('does not block random data selector', () => { + expect(subject('
')).to.equal(false); + }); + + it('blocks blocked selector', () => { + expect( + subject('
', { blockSelector: '[data-rr-block]' }), + ).to.equal(true); + }); +}); diff --git a/packages/rrweb-snapshot/tsconfig.json b/packages/rrweb-snapshot/tsconfig.json new file mode 100644 index 00000000..d58ee546 --- /dev/null +++ b/packages/rrweb-snapshot/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitAny": true, + "strictNullChecks": true, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": true, + "rootDir": "src", + "outDir": "build", + "lib": ["es6", "dom"] + }, + "compileOnSave": true, + "exclude": ["test"], + "include": ["src", "test.d.ts"] +} diff --git a/packages/rrweb-snapshot/tslint.json b/packages/rrweb-snapshot/tslint.json new file mode 100644 index 00000000..a153081c --- /dev/null +++ b/packages/rrweb-snapshot/tslint.json @@ -0,0 +1,21 @@ +{ + "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 + }, + "rulesDirectory": [] +} diff --git a/packages/rrweb-snapshot/typings/css.d.ts b/packages/rrweb-snapshot/typings/css.d.ts new file mode 100644 index 00000000..6207385a --- /dev/null +++ b/packages/rrweb-snapshot/typings/css.d.ts @@ -0,0 +1,92 @@ +export interface ParserOptions { + silent?: boolean; + source?: string; +} +export interface ParserError { + message?: string; + reason?: string; + filename?: string; + line?: number; + column?: number; + source?: string; +} +export interface Loc { + line?: number; + column?: number; +} +export interface Node { + type?: string; + parent?: Node; + position?: { + start?: Loc; + end?: Loc; + source?: string; + content?: string; + }; +} +export interface Rule extends Node { + selectors?: string[]; + declarations?: Array; +} +export interface Declaration extends Node { + property?: string; + value?: string; +} +export interface Comment extends Node { + comment?: string; +} +export interface Charset extends Node { + charset?: string; +} +export interface CustomMedia extends Node { + name?: string; + media?: string; +} +export interface Document extends Node { + document?: string; + vendor?: string; + rules?: Array; +} +export interface FontFace extends Node { + declarations?: Array; +} +export interface Host extends Node { + rules?: Array; +} +export interface Import extends Node { + import?: string; +} +export interface KeyFrames extends Node { + name?: string; + vendor?: string; + keyframes?: Array; +} +export interface KeyFrame extends Node { + values?: string[]; + declarations?: Array; +} +export interface Media extends Node { + media?: string; + rules?: Array; +} +export interface Namespace extends Node { + namespace?: string; +} +export interface Page extends Node { + selectors?: string[]; + declarations?: Array; +} +export interface Supports extends Node { + supports?: string; + rules?: Array; +} +export declare type AtRule = Charset | CustomMedia | Document | FontFace | Host | Import | KeyFrames | Media | Namespace | Page | Supports; +export interface StyleRules { + source?: string; + rules: Array; + parsingErrors?: ParserError[]; +} +export interface Stylesheet extends Node { + stylesheet?: StyleRules; +} +export declare function parse(css: string, options?: ParserOptions): Stylesheet; diff --git a/packages/rrweb-snapshot/typings/index.d.ts b/packages/rrweb-snapshot/typings/index.d.ts new file mode 100644 index 00000000..000750d0 --- /dev/null +++ b/packages/rrweb-snapshot/typings/index.d.ts @@ -0,0 +1,5 @@ +import snapshot, { serializeNodeWithId, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE } from './snapshot'; +import rebuild, { buildNodeWithSN, addHoverClass } from './rebuild'; +export * from './types'; +export * from './utils'; +export { snapshot, serializeNodeWithId, rebuild, buildNodeWithSN, addHoverClass, transformAttribute, visitSnapshot, cleanupSnapshot, needMaskingText, IGNORED_NODE, }; diff --git a/packages/rrweb-snapshot/typings/rebuild.d.ts b/packages/rrweb-snapshot/typings/rebuild.d.ts new file mode 100644 index 00000000..6ab9f04b --- /dev/null +++ b/packages/rrweb-snapshot/typings/rebuild.d.ts @@ -0,0 +1,16 @@ +import { serializedNodeWithId, idNodeMap, INode } from './types'; +export declare function addHoverClass(cssText: string): string; +export declare function buildNodeWithSN(n: serializedNodeWithId, options: { + doc: Document; + map: idNodeMap; + skipChild?: boolean; + hackCss: boolean; + afterAppend?: (n: INode) => unknown; +}): INode | null; +declare function rebuild(n: serializedNodeWithId, options: { + doc: Document; + onVisit?: (node: INode) => unknown; + hackCss?: boolean; + afterAppend?: (n: INode) => unknown; +}): [Node | null, idNodeMap]; +export default rebuild; diff --git a/packages/rrweb-snapshot/typings/snapshot.d.ts b/packages/rrweb-snapshot/typings/snapshot.d.ts new file mode 100644 index 00000000..9ccb147a --- /dev/null +++ b/packages/rrweb-snapshot/typings/snapshot.d.ts @@ -0,0 +1,47 @@ +import { serializedNodeWithId, INode, idNodeMap, MaskInputOptions, SlimDOMOptions, MaskTextFn, MaskInputFn, KeepIframeSrcFn } from './types'; +export declare const IGNORED_NODE = -2; +export declare function absoluteToStylesheet(cssText: string | null, href: string): string; +export declare function absoluteToDoc(doc: Document, attributeValue: string): string; +export declare function transformAttribute(doc: Document, tagName: string, name: string, value: string): string; +export declare function _isBlockedElement(element: HTMLElement, blockClass: string | RegExp, blockSelector: string | null): boolean; +export declare function needMaskingText(node: Node | null, maskTextClass: string | RegExp, maskTextSelector: string | null): boolean; +export declare function serializeNodeWithId(n: Node | INode, options: { + doc: Document; + map: idNodeMap; + blockClass: string | RegExp; + blockSelector: string | null; + maskTextClass: string | RegExp; + maskTextSelector: string | null; + skipChild: boolean; + inlineStylesheet: boolean; + maskInputOptions?: MaskInputOptions; + maskTextFn: MaskTextFn | undefined; + maskInputFn: MaskInputFn | undefined; + slimDOMOptions: SlimDOMOptions; + keepIframeSrcFn?: KeepIframeSrcFn; + recordCanvas?: boolean; + preserveWhiteSpace?: boolean; + onSerialize?: (n: INode) => unknown; + onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; + iframeLoadTimeout?: number; +}): serializedNodeWithId | null; +declare function snapshot(n: Document, options?: { + blockClass?: string | RegExp; + blockSelector?: string | null; + maskTextClass?: string | RegExp; + maskTextSelector?: string | null; + inlineStylesheet?: boolean; + maskAllInputs?: boolean | MaskInputOptions; + maskTextFn?: MaskTextFn; + maskInputFn?: MaskTextFn; + slimDOM?: boolean | SlimDOMOptions; + recordCanvas?: boolean; + preserveWhiteSpace?: boolean; + onSerialize?: (n: INode) => unknown; + onIframeLoad?: (iframeINode: INode, node: serializedNodeWithId) => unknown; + iframeLoadTimeout?: number; + keepIframeSrcFn?: KeepIframeSrcFn; +}): [serializedNodeWithId | null, idNodeMap]; +export declare function visitSnapshot(node: serializedNodeWithId, onVisit: (node: serializedNodeWithId) => unknown): void; +export declare function cleanupSnapshot(): void; +export default snapshot; diff --git a/packages/rrweb-snapshot/typings/types.d.ts b/packages/rrweb-snapshot/typings/types.d.ts new file mode 100644 index 00000000..8524801c --- /dev/null +++ b/packages/rrweb-snapshot/typings/types.d.ts @@ -0,0 +1,92 @@ +export declare enum NodeType { + Document = 0, + DocumentType = 1, + Element = 2, + Text = 3, + CDATA = 4, + Comment = 5 +} +export declare type documentNode = { + type: NodeType.Document; + childNodes: serializedNodeWithId[]; +}; +export declare type documentTypeNode = { + type: NodeType.DocumentType; + name: string; + publicId: string; + systemId: string; +}; +export declare type attributes = { + [key: string]: string | number | boolean; +}; +export declare type elementNode = { + type: NodeType.Element; + tagName: string; + attributes: attributes; + childNodes: serializedNodeWithId[]; + isSVG?: true; + needBlock?: boolean; +}; +export declare type textNode = { + type: NodeType.Text; + textContent: string; + isStyle?: true; +}; +export declare type cdataNode = { + type: NodeType.CDATA; + textContent: ''; +}; +export declare type commentNode = { + type: NodeType.Comment; + textContent: string; +}; +export declare type serializedNode = (documentNode | documentTypeNode | elementNode | textNode | cdataNode | commentNode) & { + rootId?: number; + isShadowHost?: boolean; + isShadow?: boolean; +}; +export declare type serializedNodeWithId = serializedNode & { + id: number; +}; +export declare type tagMap = { + [key: string]: string; +}; +export interface INode extends Node { + __sn: serializedNodeWithId; +} +export declare type idNodeMap = { + [key: number]: INode; +}; +export declare type MaskInputOptions = Partial<{ + color: boolean; + date: boolean; + 'datetime-local': boolean; + email: boolean; + month: boolean; + number: boolean; + range: boolean; + search: boolean; + tel: boolean; + text: boolean; + time: boolean; + url: boolean; + week: boolean; + textarea: boolean; + select: boolean; + password: boolean; +}>; +export declare type SlimDOMOptions = Partial<{ + script: boolean; + comment: boolean; + headFavicon: boolean; + headWhitespace: boolean; + headMetaDescKeywords: boolean; + headMetaSocial: boolean; + headMetaRobots: boolean; + headMetaHttpEquiv: boolean; + headMetaAuthorship: boolean; + headMetaVerification: boolean; +}>; +export declare type MaskTextFn = (text: string) => string; +export declare type MaskInputFn = (text: string) => string; +export declare type KeepIframeSrcFn = (src: string) => boolean; diff --git a/packages/rrweb-snapshot/typings/utils.d.ts b/packages/rrweb-snapshot/typings/utils.d.ts new file mode 100644 index 00000000..dfb1b70a --- /dev/null +++ b/packages/rrweb-snapshot/typings/utils.d.ts @@ -0,0 +1,10 @@ +import { INode, MaskInputFn, MaskInputOptions } from './types'; +export declare function isElement(n: Node | INode): n is Element; +export declare function isShadowRoot(n: Node): n is ShadowRoot; +export declare function maskInputValue({ maskInputOptions, tagName, type, value, maskInputFn, }: { + maskInputOptions: MaskInputOptions; + tagName: string; + type: string | number | boolean | null; + value: string | null; + maskInputFn?: MaskInputFn; +}): string;