import { snapshot, MaskInputOptions, SlimDOMOptions } from 'rrweb-snapshot'; import { initObservers, mutationBuffer } from './observer'; import { mirror, on, getWindowWidth, getWindowHeight, polyfill, } from '../utils'; import { EventType, event, eventWithTime, recordOptions, IncrementalSource, listenerHandler, } from '../types'; function wrapEvent(e: event): eventWithTime { return { ...e, timestamp: Date.now(), }; } let wrappedEmit!: (e: eventWithTime, isCheckout?: boolean) => void; function record( options: recordOptions = {}, ): listenerHandler | undefined { const { emit, checkoutEveryNms, checkoutEveryNth, blockClass = 'rr-block', blockSelector = null, ignoreClass = 'rr-ignore', inlineStylesheet = true, maskAllInputs, maskInputOptions: _maskInputOptions, slimDOMOptions: _slimDOMOptions, maskInputFn, hooks, packFn, sampling = {}, mousemoveWait, recordCanvas = false, collectFonts = false, } = options; // runtime checks for user options if (!emit) { throw new Error('emit function is required'); } // move departed options to new options if (mousemoveWait !== undefined && sampling.mousemove === undefined) { sampling.mousemove = mousemoveWait; } const maskInputOptions: MaskInputOptions = maskAllInputs === true ? { color: true, date: true, 'datetime-local': true, email: true, month: true, number: true, range: true, search: true, tel: true, text: true, time: true, url: true, week: true, textarea: true, select: true, } : _maskInputOptions !== undefined ? _maskInputOptions : {}; const slimDOMOptions: SlimDOMOptions = _slimDOMOptions === true || _slimDOMOptions === 'all' ? { script: true, comment: true, headFavicon: true, headWhitespace: true, headMetaSocial: true, headMetaRobots: true, headMetaHttpEquiv: true, headMetaVerification: true, // the following are off for slimDOMOptions === true, // as they destroy some (hidden) info: headMetaAuthorship: _slimDOMOptions === 'all', headMetaDescKeywords: _slimDOMOptions === 'all', } : _slimDOMOptions === false ? {} : _slimDOMOptions; polyfill(); let lastFullSnapshotEvent: eventWithTime; let incrementalSnapshotCount = 0; wrappedEmit = (e: eventWithTime, isCheckout?: boolean) => { if ( mutationBuffer.isFrozen() && e.type !== EventType.FullSnapshot && !( e.type === EventType.IncrementalSnapshot && e.data.source === IncrementalSource.Mutation ) ) { // we've got a user initiated event so first we need to apply // all DOM changes that have been buffering during paused state mutationBuffer.emit(); mutationBuffer.unfreeze(); } emit(((packFn ? packFn(e) : e) as unknown) as T, isCheckout); if (e.type === EventType.FullSnapshot) { lastFullSnapshotEvent = e; incrementalSnapshotCount = 0; } else if (e.type === EventType.IncrementalSnapshot) { incrementalSnapshotCount++; const exceedCount = checkoutEveryNth && incrementalSnapshotCount >= checkoutEveryNth; const exceedTime = checkoutEveryNms && e.timestamp - lastFullSnapshotEvent.timestamp > checkoutEveryNms; if (exceedCount || exceedTime) { takeFullSnapshot(true); } } }; function takeFullSnapshot(isCheckout = false) { wrappedEmit( wrapEvent({ type: EventType.Meta, data: { href: window.location.href, width: getWindowWidth(), height: getWindowHeight(), }, }), isCheckout, ); let wasFrozen = mutationBuffer.isFrozen(); mutationBuffer.freeze(); // don't allow any mirror modifications during snapshotting const [node, idNodeMap] = snapshot(document, { blockClass, blockSelector, inlineStylesheet, maskAllInputs: maskInputOptions, slimDOM: slimDOMOptions, recordCanvas, }); if (!node) { return console.warn('Failed to snapshot the document'); } mirror.map = idNodeMap; wrappedEmit( wrapEvent({ type: EventType.FullSnapshot, data: { node, initialOffset: { left: window.pageXOffset !== undefined ? window.pageXOffset : document?.documentElement.scrollLeft || document?.body?.parentElement?.scrollLeft || document?.body.scrollLeft || 0, top: window.pageYOffset !== undefined ? window.pageYOffset : document?.documentElement.scrollTop || document?.body?.parentElement?.scrollTop || document?.body.scrollTop || 0, }, }, }), ); if (!wasFrozen) { mutationBuffer.emit(); // emit anything queued up now mutationBuffer.unfreeze(); } } try { const handlers: listenerHandler[] = []; handlers.push( on('DOMContentLoaded', () => { wrappedEmit( wrapEvent({ type: EventType.DomContentLoaded, data: {}, }), ); }), ); const init = () => { takeFullSnapshot(); handlers.push( initObservers( { mutationCb: (m) => wrappedEmit( wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.Mutation, ...m, }, }), ), mousemoveCb: (positions, source) => wrappedEmit( wrapEvent({ type: EventType.IncrementalSnapshot, data: { source, positions, }, }), ), mouseInteractionCb: (d) => wrappedEmit( wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.MouseInteraction, ...d, }, }), ), scrollCb: (p) => wrappedEmit( wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.Scroll, ...p, }, }), ), viewportResizeCb: (d) => wrappedEmit( wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.ViewportResize, ...d, }, }), ), inputCb: (v) => wrappedEmit( wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.Input, ...v, }, }), ), mediaInteractionCb: (p) => wrappedEmit( wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.MediaInteraction, ...p, }, }), ), styleSheetRuleCb: (r) => wrappedEmit( wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.StyleSheetRule, ...r, }, }), ), canvasMutationCb: (p) => wrappedEmit( wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.CanvasMutation, ...p, }, }), ), fontCb: (p) => wrappedEmit( wrapEvent({ type: EventType.IncrementalSnapshot, data: { source: IncrementalSource.Font, ...p, }, }), ), blockClass, blockSelector, ignoreClass, maskInputOptions, maskInputFn, inlineStylesheet, sampling, recordCanvas, collectFonts, slimDOMOptions, }, hooks, ), ); }; if ( document.readyState === 'interactive' || document.readyState === 'complete' ) { init(); } else { handlers.push( on( 'load', () => { wrappedEmit( wrapEvent({ type: EventType.Load, data: {}, }), ); init(); }, window, ), ); } return () => { handlers.forEach((h) => h()); }; } catch (error) { // TODO: handle internal error console.warn(error); } } record.addCustomEvent = (tag: string, payload: T) => { if (!wrappedEmit) { throw new Error('please add custom event after start recording'); } wrappedEmit( wrapEvent({ type: EventType.Custom, data: { tag, payload, }, }), ); }; record.freezePage = () => { mutationBuffer.freeze(); }; export default record;