From a225d8e1412a69a761c22eb45565fff0b0ce5c11 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 22 Mar 2023 03:24:36 +0100 Subject: [PATCH] feat: Allow to pass `errorHandler` as record option (#1107) * feat: Allow to pass `errorHandler` as record option * add docs * Apply formatting changes --- .changeset/pretty-plums-rescue.md | 5 + guide.md | 1 + packages/rrweb/src/record/error-handler.ts | 36 + packages/rrweb/src/record/index.ts | 11 +- packages/rrweb/src/record/observer.ts | 712 ++++++++++-------- packages/rrweb/src/types.ts | 3 + .../rrweb/test/record/error-handler.test.ts | 468 ++++++++++++ 7 files changed, 922 insertions(+), 314 deletions(-) create mode 100644 .changeset/pretty-plums-rescue.md create mode 100644 packages/rrweb/src/record/error-handler.ts create mode 100644 packages/rrweb/test/record/error-handler.test.ts diff --git a/.changeset/pretty-plums-rescue.md b/.changeset/pretty-plums-rescue.md new file mode 100644 index 0000000000..a360088cfa --- /dev/null +++ b/.changeset/pretty-plums-rescue.md @@ -0,0 +1,5 @@ +--- +'rrweb': minor +--- + +feat: Allow to pass `errorHandler` as record option diff --git a/guide.md b/guide.md index 383a473275..e2dbf0d23f 100644 --- a/guide.md +++ b/guide.md @@ -163,6 +163,7 @@ The parameter of `rrweb.record` accepts the following options. | collectFonts | false | whether to collect fonts in the website | | userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) | | plugins | [] | load plugins to provide extended record functions. [What is plugins?](./docs/recipes/plugin.md) | +| errorHandler | - | A callback that is called if something inside of rrweb throws an error. The callback receives the error as argument. | #### Privacy diff --git a/packages/rrweb/src/record/error-handler.ts b/packages/rrweb/src/record/error-handler.ts new file mode 100644 index 0000000000..99e392dae7 --- /dev/null +++ b/packages/rrweb/src/record/error-handler.ts @@ -0,0 +1,36 @@ +import type { ErrorHandler } from '../types'; + +type Callback = (...args: unknown[]) => unknown; + +let errorHandler: ErrorHandler | undefined; + +export function registerErrorHandler(handler: ErrorHandler | undefined) { + errorHandler = handler; +} + +export function unregisterErrorHandler() { + errorHandler = undefined; +} + +/** + * Wrap callbacks in a wrapper that allows to pass errors to a configured `errorHandler` method. + */ +export const callbackWrapper = (cb: T): T => { + if (!errorHandler) { + return cb; + } + + const rrwebWrapped = ((...rest: unknown[]) => { + try { + return cb(...rest); + } catch (error) { + if (errorHandler && errorHandler(error) === true) { + return; + } + + throw error; + } + }) as unknown as T; + + return rrwebWrapped; +}; diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index c69ee4b80d..54d8eafa2a 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -36,6 +36,11 @@ import { IframeManager } from './iframe-manager'; import { ShadowDomManager } from './shadow-dom-manager'; import { CanvasManager } from './observers/canvas/canvas-manager'; import { StylesheetManager } from './stylesheet-manager'; +import { + callbackWrapper, + registerErrorHandler, + unregisterErrorHandler, +} from './error-handler'; function wrapEvent(e: event): eventWithTime { return { @@ -85,8 +90,11 @@ function record( plugins, keepIframeSrcFn = () => false, ignoreCSSAttributes = new Set([]), + errorHandler, } = options; + registerErrorHandler(errorHandler); + const inEmittingFrame = recordCrossOriginIframes ? window.parent === window : true; @@ -416,7 +424,7 @@ function record( const handlers: listenerHandler[] = []; const observe = (doc: Document) => { - return initObservers( + return callbackWrapper(initObservers)( { mutationCb: wrappedMutationEmit, mousemoveCb: (positions, source) => @@ -609,6 +617,7 @@ function record( return () => { handlers.forEach((h) => h()); recording = false; + unregisterErrorHandler(); }; } catch (error) { // TODO: handle internal error diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 5e7043bd63..d02c963983 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -42,6 +42,7 @@ import { } from '@rrweb/types'; import MutationBuffer from './mutation'; import ProcessedNodeManager from './processed-node-manager'; +import { callbackWrapper } from './error-handler'; type WindowWithStoredMutationObserver = IWindow & { __rrMutationObserver?: MutationObserver; @@ -110,7 +111,9 @@ export function initMutationObserver( } const observer = new (mutationObserverCtor as new ( callback: MutationCallback, - ) => MutationObserver)(mutationBuffer.processMutations.bind(mutationBuffer)); + ) => MutationObserver)( + callbackWrapper(mutationBuffer.processMutations.bind(mutationBuffer)), + ); observer.observe(rootEl, { attributes: true, attributeOldValue: true, @@ -144,63 +147,67 @@ function initMoveObserver({ let positions: mousePosition[] = []; let timeBaseline: number | null; const wrappedCb = throttle( - ( - source: - | IncrementalSource.MouseMove - | IncrementalSource.TouchMove - | IncrementalSource.Drag, - ) => { - const totalOffset = Date.now() - timeBaseline!; - mousemoveCb( - positions.map((p) => { - p.timeOffset -= totalOffset; - return p; - }), - source, - ); - positions = []; - timeBaseline = null; - }, + callbackWrapper( + ( + source: + | IncrementalSource.MouseMove + | IncrementalSource.TouchMove + | IncrementalSource.Drag, + ) => { + const totalOffset = Date.now() - timeBaseline!; + mousemoveCb( + positions.map((p) => { + p.timeOffset -= totalOffset; + return p; + }), + source, + ); + positions = []; + timeBaseline = null; + }, + ), callbackThreshold, ); - const updatePosition = throttle( - (evt) => { - const target = getEventTarget(evt); - const { clientX, clientY } = isTouchEvent(evt) - ? evt.changedTouches[0] - : evt; - if (!timeBaseline) { - timeBaseline = Date.now(); - } - positions.push({ - x: clientX, - y: clientY, - id: mirror.getId(target as Node), - timeOffset: Date.now() - timeBaseline, - }); - // it is possible DragEvent is undefined even on devices - // that support event 'drag' - wrappedCb( - typeof DragEvent !== 'undefined' && evt instanceof DragEvent - ? IncrementalSource.Drag - : evt instanceof MouseEvent - ? IncrementalSource.MouseMove - : IncrementalSource.TouchMove, - ); - }, - threshold, - { - trailing: false, - }, + const updatePosition = callbackWrapper( + throttle( + callbackWrapper((evt) => { + const target = getEventTarget(evt); + const { clientX, clientY } = isTouchEvent(evt) + ? evt.changedTouches[0] + : evt; + if (!timeBaseline) { + timeBaseline = Date.now(); + } + positions.push({ + x: clientX, + y: clientY, + id: mirror.getId(target as Node), + timeOffset: Date.now() - timeBaseline, + }); + // it is possible DragEvent is undefined even on devices + // that support event 'drag' + wrappedCb( + typeof DragEvent !== 'undefined' && evt instanceof DragEvent + ? IncrementalSource.Drag + : evt instanceof MouseEvent + ? IncrementalSource.MouseMove + : IncrementalSource.TouchMove, + ); + }), + threshold, + { + trailing: false, + }, + ), ); const handlers = [ on('mousemove', updatePosition, doc), on('touchmove', updatePosition, doc), on('drag', updatePosition, doc), ]; - return () => { + return callbackWrapper(() => { handlers.forEach((h) => h()); - }; + }); } function initMouseInteractionObserver({ @@ -235,7 +242,7 @@ function initMouseInteractionObserver({ } const id = mirror.getId(target); const { clientX, clientY } = e; - mouseInteractionCb({ + callbackWrapper(mouseInteractionCb)({ type: MouseInteractions[eventKey], id, x: clientX, @@ -255,9 +262,9 @@ function initMouseInteractionObserver({ const handler = getHandler(eventKey); handlers.push(on(eventName, handler, doc)); }); - return () => { + return callbackWrapper(() => { handlers.forEach((h) => h()); - }; + }); } export function initScrollObserver({ @@ -271,27 +278,35 @@ export function initScrollObserver({ observerParam, 'scrollCb' | 'doc' | 'mirror' | 'blockClass' | 'blockSelector' | 'sampling' >): listenerHandler { - const updatePosition = throttle((evt) => { - const target = getEventTarget(evt); - if (!target || isBlocked(target as Node, blockClass, blockSelector, true)) { - return; - } - const id = mirror.getId(target as Node); - if (target === doc && doc.defaultView) { - const scrollLeftTop = getWindowScroll(doc.defaultView); - scrollCb({ - id, - x: scrollLeftTop.left, - y: scrollLeftTop.top, - }); - } else { - scrollCb({ - id, - x: (target as HTMLElement).scrollLeft, - y: (target as HTMLElement).scrollTop, - }); - } - }, sampling.scroll || 100); + const updatePosition = callbackWrapper( + throttle( + callbackWrapper((evt) => { + const target = getEventTarget(evt); + if ( + !target || + isBlocked(target as Node, blockClass, blockSelector, true) + ) { + return; + } + const id = mirror.getId(target as Node); + if (target === doc && doc.defaultView) { + const scrollLeftTop = getWindowScroll(doc.defaultView); + scrollCb({ + id, + x: scrollLeftTop.left, + y: scrollLeftTop.top, + }); + } else { + scrollCb({ + id, + x: (target as HTMLElement).scrollLeft, + y: (target as HTMLElement).scrollTop, + }); + } + }), + sampling.scroll || 100, + ), + ); return on('scroll', updatePosition, doc); } @@ -300,18 +315,23 @@ function initViewportResizeObserver({ }: observerParam): listenerHandler { let lastH = -1; let lastW = -1; - const updateDimension = throttle(() => { - const height = getWindowHeight(); - const width = getWindowWidth(); - if (lastH !== height || lastW !== width) { - viewportResizeCb({ - width: Number(width), - height: Number(height), - }); - lastH = height; - lastW = width; - } - }, 200); + const updateDimension = callbackWrapper( + throttle( + callbackWrapper(() => { + const height = getWindowHeight(); + const width = getWindowWidth(); + if (lastH !== height || lastW !== width) { + viewportResizeCb({ + width: Number(width), + height: Number(height), + }); + lastH = height; + lastW = width; + } + }), + 200, + ), + ); return on('resize', updateDimension, window); } @@ -382,7 +402,7 @@ function initInputObserver({ } cbWithDedup( target, - wrapEventWithUserTriggeredFlag( + callbackWrapper(wrapEventWithUserTriggeredFlag)( { text, isChecked, userTriggered }, userTriggeredOnInput, ), @@ -397,7 +417,7 @@ function initInputObserver({ if (el !== target) { cbWithDedup( el, - wrapEventWithUserTriggeredFlag( + callbackWrapper(wrapEventWithUserTriggeredFlag)( { text: (el as HTMLInputElement).value, isChecked: !isChecked, @@ -419,7 +439,7 @@ function initInputObserver({ ) { lastInputValueMap.set(target, v); const id = mirror.getId(target as Node); - inputCb({ + callbackWrapper(inputCb)({ ...v, id, }); @@ -427,7 +447,7 @@ function initInputObserver({ } const events = sampling.input === 'last' ? ['change'] : ['input', 'change']; const handlers: Array = events.map( - (eventName) => on(eventName, eventHandler, doc), + (eventName) => on(eventName, callbackWrapper(eventHandler), doc), ); const currentWindow = doc.defaultView; if (!currentWindow) { @@ -457,7 +477,7 @@ function initInputObserver({ { set() { // mock to a normal event - eventHandler({ + callbackWrapper(eventHandler)({ target: this as EventTarget, isTrusted: false, // userTriggered to false as this could well be programmatic } as Event); @@ -469,9 +489,9 @@ function initInputObserver({ ), ); } - return () => { + return callbackWrapper(() => { handlers.forEach((h) => h()); - }; + }); } type GroupingCSSRule = @@ -548,97 +568,125 @@ function initStyleSheetObserver( // eslint-disable-next-line @typescript-eslint/unbound-method const insertRule = win.CSSStyleSheet.prototype.insertRule; - win.CSSStyleSheet.prototype.insertRule = function ( - this: CSSStyleSheet, - rule: string, - index?: number, - ) { - const { id, styleId } = getIdAndStyleId( - this, - mirror, - stylesheetManager.styleMirror, - ); - - if ((id && id !== -1) || (styleId && styleId !== -1)) { - styleSheetRuleCb({ - id, - styleId, - adds: [{ rule, index }], - }); - } - return insertRule.apply(this, [rule, index]); - }; + win.CSSStyleSheet.prototype.insertRule = new Proxy(insertRule, { + apply: callbackWrapper( + ( + target: typeof insertRule, + thisArg: CSSStyleSheet, + argumentsList: [string, number | undefined], + ) => { + const [rule, index] = argumentsList; + + const { id, styleId } = getIdAndStyleId( + thisArg, + mirror, + stylesheetManager.styleMirror, + ); + + if ((id && id !== -1) || (styleId && styleId !== -1)) { + styleSheetRuleCb({ + id, + styleId, + adds: [{ rule, index }], + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }); // eslint-disable-next-line @typescript-eslint/unbound-method const deleteRule = win.CSSStyleSheet.prototype.deleteRule; - win.CSSStyleSheet.prototype.deleteRule = function ( - this: CSSStyleSheet, - index: number, - ) { - const { id, styleId } = getIdAndStyleId( - this, - mirror, - stylesheetManager.styleMirror, - ); - - if ((id && id !== -1) || (styleId && styleId !== -1)) { - styleSheetRuleCb({ - id, - styleId, - removes: [{ index }], - }); - } - return deleteRule.apply(this, [index]); - }; + win.CSSStyleSheet.prototype.deleteRule = new Proxy(deleteRule, { + apply: callbackWrapper( + ( + target: typeof deleteRule, + thisArg: CSSStyleSheet, + argumentsList: [number], + ) => { + const [index] = argumentsList; + + const { id, styleId } = getIdAndStyleId( + thisArg, + mirror, + stylesheetManager.styleMirror, + ); + + if ((id && id !== -1) || (styleId && styleId !== -1)) { + styleSheetRuleCb({ + id, + styleId, + removes: [{ index }], + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }); let replace: (text: string) => Promise; + if (win.CSSStyleSheet.prototype.replace) { // eslint-disable-next-line @typescript-eslint/unbound-method replace = win.CSSStyleSheet.prototype.replace; - win.CSSStyleSheet.prototype.replace = function ( - this: CSSStyleSheet, - text: string, - ) { - const { id, styleId } = getIdAndStyleId( - this, - mirror, - stylesheetManager.styleMirror, - ); - - if ((id && id !== -1) || (styleId && styleId !== -1)) { - styleSheetRuleCb({ - id, - styleId, - replace: text, - }); - } - return replace.apply(this, [text]); - }; + win.CSSStyleSheet.prototype.replace = new Proxy(replace, { + apply: callbackWrapper( + ( + target: typeof replace, + thisArg: CSSStyleSheet, + argumentsList: [string], + ) => { + const [text] = argumentsList; + + const { id, styleId } = getIdAndStyleId( + thisArg, + mirror, + stylesheetManager.styleMirror, + ); + + if ((id && id !== -1) || (styleId && styleId !== -1)) { + styleSheetRuleCb({ + id, + styleId, + replace: text, + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }); } let replaceSync: (text: string) => void; if (win.CSSStyleSheet.prototype.replaceSync) { // eslint-disable-next-line @typescript-eslint/unbound-method replaceSync = win.CSSStyleSheet.prototype.replaceSync; - win.CSSStyleSheet.prototype.replaceSync = function ( - this: CSSStyleSheet, - text: string, - ) { - const { id, styleId } = getIdAndStyleId( - this, - mirror, - stylesheetManager.styleMirror, - ); - - if ((id && id !== -1) || (styleId && styleId !== -1)) { - styleSheetRuleCb({ - id, - styleId, - replaceSync: text, - }); - } - return replaceSync.apply(this, [text]); - }; + win.CSSStyleSheet.prototype.replaceSync = new Proxy(replaceSync, { + apply: callbackWrapper( + ( + target: typeof replaceSync, + thisArg: CSSStyleSheet, + argumentsList: [string], + ) => { + const [text] = argumentsList; + + const { id, styleId } = getIdAndStyleId( + thisArg, + mirror, + stylesheetManager.styleMirror, + ); + + if ((id && id !== -1) || (styleId && styleId !== -1)) { + styleSheetRuleCb({ + id, + styleId, + replaceSync: text, + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }); } const supportedNestedCSSRuleTypes: { @@ -677,59 +725,78 @@ function initStyleSheetObserver( deleteRule: type.prototype.deleteRule, }; - type.prototype.insertRule = function ( - this: CSSGroupingRule, - rule: string, - index?: number, - ) { - const { id, styleId } = getIdAndStyleId( - this.parentStyleSheet, - mirror, - stylesheetManager.styleMirror, - ); + type.prototype.insertRule = new Proxy( + unmodifiedFunctions[typeKey].insertRule, + { + apply: callbackWrapper( + ( + target: typeof insertRule, + thisArg: CSSRule, + argumentsList: [string, number | undefined], + ) => { + const [rule, index] = argumentsList; + + const { id, styleId } = getIdAndStyleId( + thisArg.parentStyleSheet, + mirror, + stylesheetManager.styleMirror, + ); - if ((id && id !== -1) || (styleId && styleId !== -1)) { - styleSheetRuleCb({ - id, - styleId, - adds: [ - { - rule, - index: [ - ...getNestedCSSRulePositions(this as CSSRule), - index || 0, // defaults to 0 - ], - }, - ], - }); - } - return unmodifiedFunctions[typeKey].insertRule.apply(this, [rule, index]); - }; + if ((id && id !== -1) || (styleId && styleId !== -1)) { + styleSheetRuleCb({ + id, + styleId, + adds: [ + { + rule, + index: [ + ...getNestedCSSRulePositions(thisArg), + index || 0, // defaults to 0 + ], + }, + ], + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }, + ); - type.prototype.deleteRule = function ( - this: CSSGroupingRule, - index: number, - ) { - const { id, styleId } = getIdAndStyleId( - this.parentStyleSheet, - mirror, - stylesheetManager.styleMirror, - ); + type.prototype.deleteRule = new Proxy( + unmodifiedFunctions[typeKey].deleteRule, + { + apply: callbackWrapper( + ( + target: typeof deleteRule, + thisArg: CSSRule, + argumentsList: [number], + ) => { + const [index] = argumentsList; + + const { id, styleId } = getIdAndStyleId( + thisArg.parentStyleSheet, + mirror, + stylesheetManager.styleMirror, + ); - if ((id && id !== -1) || (styleId && styleId !== -1)) { - styleSheetRuleCb({ - id, - styleId, - removes: [ - { index: [...getNestedCSSRulePositions(this as CSSRule), index] }, - ], - }); - } - return unmodifiedFunctions[typeKey].deleteRule.apply(this, [index]); - }; + if ((id && id !== -1) || (styleId && styleId !== -1)) { + styleSheetRuleCb({ + id, + styleId, + removes: [ + { index: [...getNestedCSSRulePositions(thisArg), index] }, + ], + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }, + ); }); - return () => { + return callbackWrapper(() => { win.CSSStyleSheet.prototype.insertRule = insertRule; win.CSSStyleSheet.prototype.deleteRule = deleteRule; replace && (win.CSSStyleSheet.prototype.replace = replace); @@ -738,7 +805,7 @@ function initStyleSheetObserver( type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule; type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule; }); - }; + }); } export function initAdoptedStyleSheetObserver( @@ -792,7 +859,7 @@ export function initAdoptedStyleSheetObserver( }, }); - return () => { + return callbackWrapper(() => { Object.defineProperty(host, 'adoptedStyleSheets', { configurable: originalPropertyDescriptor.configurable, enumerable: originalPropertyDescriptor.enumerable, @@ -801,7 +868,7 @@ export function initAdoptedStyleSheetObserver( // eslint-disable-next-line @typescript-eslint/unbound-method set: originalPropertyDescriptor.set, }); - }; + }); } function initStyleDeclarationObserver( @@ -815,70 +882,82 @@ function initStyleDeclarationObserver( ): listenerHandler { // eslint-disable-next-line @typescript-eslint/unbound-method const setProperty = win.CSSStyleDeclaration.prototype.setProperty; - win.CSSStyleDeclaration.prototype.setProperty = function ( - this: CSSStyleDeclaration, - property: string, - value: string, - priority: string, - ) { - // ignore this mutation if we do not care about this css attribute - if (ignoreCSSAttributes.has(property)) { - return setProperty.apply(this, [property, value, priority]); - } - const { id, styleId } = getIdAndStyleId( - this.parentRule?.parentStyleSheet, - mirror, - stylesheetManager.styleMirror, - ); - if ((id && id !== -1) || (styleId && styleId !== -1)) { - styleDeclarationCb({ - id, - styleId, - set: { - property, - value, - priority, - }, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - index: getNestedCSSRulePositions(this.parentRule!), - }); - } - return setProperty.apply(this, [property, value, priority]); - }; + win.CSSStyleDeclaration.prototype.setProperty = new Proxy(setProperty, { + apply: callbackWrapper( + ( + target: typeof setProperty, + thisArg: CSSStyleDeclaration, + argumentsList: [string, string, string], + ) => { + const [property, value, priority] = argumentsList; + + // ignore this mutation if we do not care about this css attribute + if (ignoreCSSAttributes.has(property)) { + return setProperty.apply(thisArg, [property, value, priority]); + } + const { id, styleId } = getIdAndStyleId( + thisArg.parentRule?.parentStyleSheet, + mirror, + stylesheetManager.styleMirror, + ); + if ((id && id !== -1) || (styleId && styleId !== -1)) { + styleDeclarationCb({ + id, + styleId, + set: { + property, + value, + priority, + }, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + index: getNestedCSSRulePositions(thisArg.parentRule!), + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }); // eslint-disable-next-line @typescript-eslint/unbound-method const removeProperty = win.CSSStyleDeclaration.prototype.removeProperty; - win.CSSStyleDeclaration.prototype.removeProperty = function ( - this: CSSStyleDeclaration, - property: string, - ) { - // ignore this mutation if we do not care about this css attribute - if (ignoreCSSAttributes.has(property)) { - return removeProperty.apply(this, [property]); - } - const { id, styleId } = getIdAndStyleId( - this.parentRule?.parentStyleSheet, - mirror, - stylesheetManager.styleMirror, - ); - if ((id && id !== -1) || (styleId && styleId !== -1)) { - styleDeclarationCb({ - id, - styleId, - remove: { - property, - }, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - index: getNestedCSSRulePositions(this.parentRule!), - }); - } - return removeProperty.apply(this, [property]); - }; + win.CSSStyleDeclaration.prototype.removeProperty = new Proxy(removeProperty, { + apply: callbackWrapper( + ( + target: typeof removeProperty, + thisArg: CSSStyleDeclaration, + argumentsList: [string], + ) => { + const [property] = argumentsList; + + // ignore this mutation if we do not care about this css attribute + if (ignoreCSSAttributes.has(property)) { + return removeProperty.apply(thisArg, [property]); + } + const { id, styleId } = getIdAndStyleId( + thisArg.parentRule?.parentStyleSheet, + mirror, + stylesheetManager.styleMirror, + ); + if ((id && id !== -1) || (styleId && styleId !== -1)) { + styleDeclarationCb({ + id, + styleId, + remove: { + property, + }, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + index: getNestedCSSRulePositions(thisArg.parentRule!), + }); + } + return target.apply(thisArg, argumentsList); + }, + ), + }); - return () => { + return callbackWrapper(() => { win.CSSStyleDeclaration.prototype.setProperty = setProperty; win.CSSStyleDeclaration.prototype.removeProperty = removeProperty; - }; + }); } function initMediaInteractionObserver({ @@ -888,26 +967,30 @@ function initMediaInteractionObserver({ mirror, sampling, }: observerParam): listenerHandler { - const handler = (type: MediaInteractions) => - throttle((event: Event) => { - const target = getEventTarget(event); - if ( - !target || - isBlocked(target as Node, blockClass, blockSelector, true) - ) { - return; - } - const { currentTime, volume, muted, playbackRate } = - target as HTMLMediaElement; - mediaInteractionCb({ - type, - id: mirror.getId(target as Node), - currentTime, - volume, - muted, - playbackRate, - }); - }, sampling.media || 500); + const handler = callbackWrapper((type: MediaInteractions) => + throttle( + callbackWrapper((event: Event) => { + const target = getEventTarget(event); + if ( + !target || + isBlocked(target as Node, blockClass, blockSelector, true) + ) { + return; + } + const { currentTime, volume, muted, playbackRate } = + target as HTMLMediaElement; + mediaInteractionCb({ + type, + id: mirror.getId(target as Node), + currentTime, + volume, + muted, + playbackRate, + }); + }), + sampling.media || 500, + ), + ); const handlers = [ on('play', handler(MediaInteractions.Play)), on('pause', handler(MediaInteractions.Pause)), @@ -915,9 +998,9 @@ function initMediaInteractionObserver({ on('volumechange', handler(MediaInteractions.VolumeChange)), on('ratechange', handler(MediaInteractions.RateChange)), ]; - return () => { + return callbackWrapper(() => { handlers.forEach((h) => h()); - }; + }); } function initFontObserver({ fontCb, doc }: observerParam): listenerHandler { @@ -956,13 +1039,16 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler { 'add', function (original: (font: FontFace) => void) { return function (this: FontFaceSet, fontFace: FontFace) { - setTimeout(() => { - const p = fontMap.get(fontFace); - if (p) { - fontCb(p); - fontMap.delete(fontFace); - } - }, 0); + setTimeout( + callbackWrapper(() => { + const p = fontMap.get(fontFace); + if (p) { + fontCb(p); + fontMap.delete(fontFace); + } + }), + 0, + ); return original.apply(this, [fontFace]); }; }, @@ -973,16 +1059,16 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler { }); handlers.push(restoreHandler); - return () => { + return callbackWrapper(() => { handlers.forEach((h) => h()); - }; + }); } function initSelectionObserver(param: observerParam): listenerHandler { const { doc, mirror, blockClass, blockSelector, selectionCb } = param; let collapsed = true; - const updateSelection = () => { + const updateSelection = callbackWrapper(() => { const selection = doc.getSelection(); if (!selection || (collapsed && selection?.isCollapsed)) return; @@ -1012,7 +1098,7 @@ function initSelectionObserver(param: observerParam): listenerHandler { } selectionCb({ ranges }); - }; + }); updateSelection(); @@ -1148,7 +1234,7 @@ export function initObservers( ); } - return () => { + return callbackWrapper(() => { mutationBuffers.forEach((b) => b.reset()); mutationObserver.disconnect(); mousemoveHandler(); @@ -1163,7 +1249,7 @@ export function initObservers( fontObserver(); selectionObserver(); pluginHandlers.forEach((h) => h()); - }; + }); } type CSSGroupingProp = diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index ad35af0039..dd9a516709 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -69,6 +69,7 @@ export type recordOptions = { // departed, please use sampling options mousemoveWait?: number; keepIframeSrcFn?: KeepIframeSrcFn; + errorHandler?: ErrorHandler; }; export type observerParam = { @@ -211,3 +212,5 @@ export type CrossOriginIframeMessageEventContent = { }; export type CrossOriginIframeMessageEvent = MessageEvent; + +export type ErrorHandler = (error: unknown) => void | boolean; diff --git a/packages/rrweb/test/record/error-handler.test.ts b/packages/rrweb/test/record/error-handler.test.ts new file mode 100644 index 0000000000..25b94e9f4c --- /dev/null +++ b/packages/rrweb/test/record/error-handler.test.ts @@ -0,0 +1,468 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type * as puppeteer from 'puppeteer'; +import type { recordOptions } from '../../src/types'; +import { listenerHandler, eventWithTime, EventType } from '@rrweb/types'; +import { launchPuppeteer } from '../utils'; +import { + callbackWrapper, + registerErrorHandler, + unregisterErrorHandler, +} from '../../src/record/error-handler'; + +interface ISuite { + code: string; + browser: puppeteer.Browser; + page: puppeteer.Page; + events: eventWithTime[]; +} + +interface IWindow extends Window { + rrweb: { + record: ( + options: recordOptions, + ) => listenerHandler | undefined; + addCustomEvent(tag: string, payload: T): void; + }; + emit: (e: eventWithTime) => undefined; +} + +const setup = function ( + this: ISuite, + content: string, + canvasSample: 'all' | number = 'all', +): ISuite { + const ctx = {} as ISuite; + + beforeAll(async () => { + ctx.browser = await launchPuppeteer(); + + const bundlePath = path.resolve(__dirname, '../../dist/rrweb.js'); + ctx.code = fs.readFileSync(bundlePath, 'utf8'); + }); + + beforeEach(async () => { + ctx.page = await ctx.browser.newPage(); + await ctx.page.goto('about:blank'); + await ctx.page.setContent(content); + await ctx.page.evaluate(ctx.code); + ctx.events = []; + await ctx.page.exposeFunction('emit', (e: eventWithTime) => { + if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) { + return; + } + ctx.events.push(e); + }); + + ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text())); + }); + + afterEach(async () => { + await ctx.page.close(); + }); + + afterAll(async () => { + await ctx.browser.close(); + }); + + return ctx; +}; + +describe('error-handler', function (this: ISuite) { + jest.setTimeout(100_000); + + const ctx: ISuite = setup.call( + this, + ` + + + + + + +
+
+ + + `, + ); + + describe('CSSStyleSheet.prototype', () => { + it('triggers for errors from insertRule', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSStyleSheet.prototype.insertRule = function () { + // @ts-ignore + window.doSomethingWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + errorHandler: (error) => { + document.getElementById('out')!.innerText = `${error}`; + }, + emit: (window as unknown as IWindow).emit, + }); + + // Trigger buggy style sheet insert + setTimeout(() => { + // @ts-ignore + document.styleSheets[0].insertRule('body { background: blue; }'); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual( + 'TypeError: window.doSomethingWrong is not a function', + ); + }); + + it('triggers for errors from deleteRule', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSStyleSheet.prototype.deleteRule = function () { + // @ts-ignore + window.doSomethingWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + errorHandler: (error) => { + document.getElementById('out')!.innerText = `${error}`; + }, + emit: (window as unknown as IWindow).emit, + }); + + // Trigger buggy style sheet delete + setTimeout(() => { + document.styleSheets[0].deleteRule(0); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual( + 'TypeError: window.doSomethingWrong is not a function', + ); + }); + + it('triggers for errors from replace', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSStyleSheet.prototype.replace = function () { + // @ts-ignore + window.doSomethingWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + errorHandler: (error) => { + document.getElementById('out')!.innerText = `${error}`; + }, + emit: (window as unknown as IWindow).emit, + }); + + // Trigger buggy style sheet insert + setTimeout(() => { + // @ts-ignore + document.styleSheets[0].replace('body { background: blue; }'); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual( + 'TypeError: window.doSomethingWrong is not a function', + ); + }); + + it('triggers for errors from replaceSync', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSStyleSheet.prototype.replaceSync = function () { + // @ts-ignore + window.doSomethingWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + errorHandler: (error) => { + document.getElementById('out')!.innerText = `${error}`; + }, + emit: (window as unknown as IWindow).emit, + }); + + // Trigger buggy style sheet insert + setTimeout(() => { + // @ts-ignore + document.styleSheets[0].replaceSync('body { background: blue; }'); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual( + 'TypeError: window.doSomethingWrong is not a function', + ); + }); + + it('triggers for errors from CSSGroupingRule.insertRule', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSGroupingRule.prototype.insertRule = function () { + // @ts-ignore + window.doSomethingWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + errorHandler: (error) => { + document.getElementById('out')!.innerText = `${error}`; + }, + emit: (window as unknown as IWindow).emit, + }); + + // Trigger buggy style sheet insert + setTimeout(() => { + document.styleSheets[0].insertRule('@media {}'); + const atMediaRule = document.styleSheets[0] + .cssRules[0] as CSSMediaRule; + + const ruleIdx0 = atMediaRule.insertRule( + 'body { background: #000; }', + 0, + ); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual( + 'TypeError: window.doSomethingWrong is not a function', + ); + }); + + it('triggers for errors from CSSGroupingRule.deleteRule', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSGroupingRule.prototype.deleteRule = function () { + // @ts-ignore + window.doSomethingWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + errorHandler: (error) => { + document.getElementById('out')!.innerText = `${error}`; + }, + emit: (window as unknown as IWindow).emit, + }); + + // Trigger buggy style sheet delete + setTimeout(() => { + document.styleSheets[0].insertRule('@media {}'); + const atMediaRule = document.styleSheets[0] + .cssRules[0] as CSSMediaRule; + + const ruleIdx0 = atMediaRule.deleteRule(0); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual( + 'TypeError: window.doSomethingWrong is not a function', + ); + }); + + it('triggers for errors from CSSStyleDeclaration.setProperty', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSStyleDeclaration.prototype.setProperty = function () { + // @ts-ignore + window.doSomethingWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + errorHandler: (error) => { + document.getElementById('out')!.innerText = `${error}`; + }, + emit: (window as unknown as IWindow).emit, + }); + + // Trigger buggy style sheet insert + setTimeout(() => { + ( + document.styleSheets[0].cssRules[0] as unknown as { + style: CSSStyleDeclaration; + } + ).style.setProperty('background', 'blue'); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual( + 'TypeError: window.doSomethingWrong is not a function', + ); + }); + + it('triggers for errors from CSSStyleDeclaration.removeProperty', async () => { + await ctx.page.evaluate(() => { + // @ts-ignore rewrite this to something buggy + window.CSSStyleDeclaration.prototype.removeProperty = function () { + // @ts-ignore + window.doSomethingWrong(); + }; + }); + + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + errorHandler: (error) => { + document.getElementById('out')!.innerText = `${error}`; + }, + emit: (window as unknown as IWindow).emit, + }); + + // Trigger buggy style sheet insert + setTimeout(() => { + ( + document.styleSheets[0].cssRules[0] as unknown as { + style: CSSStyleDeclaration; + } + ).style.removeProperty('background'); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual( + 'TypeError: window.doSomethingWrong is not a function', + ); + }); + }); + + it('triggers for errors from mutation observer', async () => { + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + errorHandler: (error) => { + document.getElementById('out')!.innerText = `${error}`; + }, + emit: (window as unknown as IWindow).emit, + }); + + // Trigger buggy mutation observer + setTimeout(() => { + const el = document.getElementById('in')!; + + // @ts-ignore we want to trigger an error in the mutation observer, which uses this + el.getAttribute = undefined; + + el.setAttribute('data-attr', 'new'); + }, 50); + }); + + await ctx.page.waitForTimeout(100); + + const element = await ctx.page.$('#out'); + const text = await element!.evaluate((el) => el.textContent); + + expect(text).toEqual('TypeError: m.target.getAttribute is not a function'); + }); +}); + +describe('errorHandler unit', function () { + afterEach(function () { + unregisterErrorHandler(); + }); + + it('does not swallow if no errorHandler set', () => { + unregisterErrorHandler(); + + const wrapped = callbackWrapper(() => { + throw new Error('test'); + }); + + expect(() => wrapped()).toThrowError('test'); + }); + + it('does not swallow if errorHandler returns void', () => { + registerErrorHandler(() => { + // do nothing + }); + + const wrapped = callbackWrapper(() => { + throw new Error('test'); + }); + + expect(() => wrapped()).toThrowError('test'); + }); + + it('does not swallow if errorHandler returns false', () => { + registerErrorHandler(() => { + return false; + }); + + const wrapped = callbackWrapper(() => { + throw new Error('test'); + }); + + expect(() => wrapped()).toThrowError('test'); + }); + + it('swallows if errorHandler returns true', () => { + registerErrorHandler(() => { + return true; + }); + + const wrapped = callbackWrapper(() => { + throw new Error('test'); + }); + + expect(() => wrapped()).not.toThrowError('test'); + }); +});