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