/** * this file is used to serialize log message to string * */ import type { StringifyOptions } from './index'; /** * transfer the node path in Event to string * @param node - the first node in a node path array */ function pathToSelector(node: HTMLElement): string | '' { if (!node || !node.outerHTML) { return ''; } let path = ''; while (node.parentElement) { let name = node.localName; if (!name) { break; } name = name.toLowerCase(); const parent = node.parentElement; const domSiblings = []; if (parent.children && parent.children.length > 0) { for (let i = 0; i < parent.children.length; i++) { const sibling = parent.children[i]; if (sibling.localName && sibling.localName.toLowerCase) { if (sibling.localName.toLowerCase() === name) { domSiblings.push(sibling); } } } } if (domSiblings.length > 1) { name += `:eq(${domSiblings.indexOf(node)})`; } path = name + (path ? '>' + path : ''); node = parent; } return path; } /** * judge is object */ function isObject(obj: unknown): boolean { return Object.prototype.toString.call(obj) === '[object Object]'; } /** * judge the object's depth */ function isObjTooDeep(obj: Record, limit: number): boolean { if (limit === 0) { return true; } const keys = Object.keys(obj); for (const key of keys) { if ( isObject(obj[key]) && isObjTooDeep(obj[key] as Record, limit - 1) ) { return true; } } return false; } /** * stringify any js object * @param obj - the object to stringify */ export function stringify( obj: unknown, stringifyOptions?: StringifyOptions, ): string { const options: StringifyOptions = { numOfKeysLimit: 50, depthOfLimit: 4, }; Object.assign(options, stringifyOptions); const stack: unknown[] = []; const keys: unknown[] = []; return JSON.stringify( obj, function (key, value: string | bigint | object | null | undefined) { /** * forked from https://github.com/moll/json-stringify-safe/blob/master/stringify.js * to deCycle the object */ if (stack.length > 0) { const thisPos = stack.indexOf(this); ~thisPos ? stack.splice(thisPos + 1) : stack.push(this); ~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key); if (~stack.indexOf(value)) { if (stack[0] === value) { value = '[Circular ~]'; } else { value = '[Circular ~.' + keys.slice(0, stack.indexOf(value)).join('.') + ']'; } } } else { stack.push(value); } /* END of the FORK */ if (value === null) return value; if (value === undefined) return 'undefined'; if (shouldIgnore(value as object)) { return toString(value as object); } if (typeof value === 'bigint') { return value.toString() + 'n'; } if (value instanceof Event) { const eventResult: Record = {}; for (const eventKey in value) { const eventValue = (value as unknown as Record)[ eventKey ]; if (Array.isArray(eventValue)) { eventResult[eventKey] = pathToSelector( (eventValue.length ? eventValue[0] : null) as HTMLElement, ); } else { eventResult[eventKey] = eventValue; } } return eventResult; } else if (value instanceof Node) { if (value instanceof HTMLElement) { return value ? value.outerHTML : ''; } return value.nodeName; } else if (value instanceof Error) { return value.stack ? value.stack + '\nEnd of stack for Error object' : value.name + ': ' + value.message; } return value; }, ); /** * whether we should ignore obj's info and call toString() function instead */ function shouldIgnore(_obj: object): boolean { // outof keys limit if (isObject(_obj) && Object.keys(_obj).length > options.numOfKeysLimit) { return true; } // is function or bigint if (typeof _obj === 'function') { return true; } /** * judge object's depth to avoid browser's OOM * * issues: https://github.com/rrweb-io/rrweb/issues/653 */ if ( isObject(_obj) && isObjTooDeep(_obj as Record, options.depthOfLimit) ) { return true; } return false; } /** * limit the toString() result according to option */ function toString(_obj: object): string { let str = _obj.toString(); if (options.stringLengthLimit && str.length > options.stringLengthLimit) { str = `${str.slice(0, options.stringLengthLimit)}...`; } return str; } }