|
| 1 | +import { isPrimitive, isSyntheticEvent } from './is'; |
| 2 | +import { memoBuilder, MemoFunc } from './memo'; |
| 3 | +import { getWalkSource } from './object'; |
| 4 | +import { getFunctionName } from './stacktrace'; |
| 5 | + |
| 6 | +type UnknownMaybeWithToJson = unknown & { toJSON?: () => string }; |
| 7 | + |
| 8 | +/** |
| 9 | + * Recursively normalizes the given object. |
| 10 | + * |
| 11 | + * - Creates a copy to prevent original input mutation |
| 12 | + * - Skips non-enumerable properties |
| 13 | + * - When stringifying, calls `toJSON` if implemented |
| 14 | + * - Removes circular references |
| 15 | + * - Translates non-serializable values (`undefined`/`NaN`/functions) to serializable format |
| 16 | + * - Translates known global objects/classes to a string representations |
| 17 | + * - Takes care of `Error` object serialization |
| 18 | + * - Optionally limits depth of final output |
| 19 | + * - Optionally limits number of properties/elements included in any single object/array |
| 20 | + * |
| 21 | + * @param input The object to be normalized. |
| 22 | + * @param depth The max depth to which to normalize the object. (Anything deeper stringified whole.) |
| 23 | + * @param maxProperties The max number of elements or properties to be included in any single array or |
| 24 | + * object in the normallized output.. |
| 25 | + * @returns A normalized version of the object, or `"**non-serializable**"` if any errors are thrown during normalization. |
| 26 | + */ |
| 27 | +export function normalize(input: unknown, depth: number = +Infinity, maxProperties: number = +Infinity): any { |
| 28 | + try { |
| 29 | + // since we're at the outermost level, there is no key |
| 30 | + return walk('', input as UnknownMaybeWithToJson, depth, maxProperties); |
| 31 | + } catch (_oO) { |
| 32 | + return '**non-serializable**'; |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +/** JSDoc */ |
| 37 | +export function normalizeToSize<T>( |
| 38 | + object: { [key: string]: any }, |
| 39 | + // Default Node.js REPL depth |
| 40 | + depth: number = 3, |
| 41 | + // 100kB, as 200kB is max payload size, so half sounds reasonable |
| 42 | + maxSize: number = 100 * 1024, |
| 43 | +): T { |
| 44 | + const serialized = normalize(object, depth); |
| 45 | + |
| 46 | + if (jsonSize(serialized) > maxSize) { |
| 47 | + return normalizeToSize(object, depth - 1, maxSize); |
| 48 | + } |
| 49 | + |
| 50 | + return serialized as T; |
| 51 | +} |
| 52 | + |
| 53 | +/** |
| 54 | + * Walks an object to perform a normalization on it |
| 55 | + * |
| 56 | + * @param key of object that's walked in current iteration |
| 57 | + * @param value object to be walked |
| 58 | + * @param depth Optional number indicating how deep should walking be performed |
| 59 | + * @param maxProperties Optional maximum number of properties/elements included in any single object/array |
| 60 | + * @param memo Optional Memo class handling decycling |
| 61 | + */ |
| 62 | +export function walk( |
| 63 | + key: string, |
| 64 | + value: UnknownMaybeWithToJson, |
| 65 | + depth: number = +Infinity, |
| 66 | + maxProperties: number = +Infinity, |
| 67 | + memo: MemoFunc = memoBuilder(), |
| 68 | +): unknown { |
| 69 | + const [memoize, unmemoize] = memo; |
| 70 | + |
| 71 | + // If we reach the maximum depth, serialize whatever is left |
| 72 | + if (depth === 0) { |
| 73 | + return serializeValue(value); |
| 74 | + } |
| 75 | + |
| 76 | + // If value implements `toJSON` method, call it and return early |
| 77 | + if (value !== null && value !== undefined && typeof value.toJSON === 'function') { |
| 78 | + return value.toJSON(); |
| 79 | + } |
| 80 | + |
| 81 | + // `makeSerializable` provides a string representation of certain non-serializable values. For all others, it's a |
| 82 | + // pass-through. If what comes back is a primitive (either because it's been stringified or because it was primitive |
| 83 | + // all along), we're done. |
| 84 | + const serializable = makeSerializable(value, key); |
| 85 | + if (isPrimitive(serializable)) { |
| 86 | + return serializable; |
| 87 | + } |
| 88 | + |
| 89 | + // Create source that we will use for the next iteration. It will either be an objectified error object (`Error` type |
| 90 | + // with extracted key:value pairs) or the input itself. |
| 91 | + const source = getWalkSource(value); |
| 92 | + |
| 93 | + // Create an accumulator that will act as a parent for all future itterations of that branch |
| 94 | + const acc: { [key: string]: any } = Array.isArray(value) ? [] : {}; |
| 95 | + |
| 96 | + // If we already walked that branch, bail out, as it's circular reference |
| 97 | + if (memoize(value)) { |
| 98 | + return '[Circular ~]'; |
| 99 | + } |
| 100 | + |
| 101 | + let propertyCount = 0; |
| 102 | + // Walk all keys of the source |
| 103 | + for (const innerKey in source) { |
| 104 | + // Avoid iterating over fields in the prototype if they've somehow been exposed to enumeration. |
| 105 | + if (!Object.prototype.hasOwnProperty.call(source, innerKey)) { |
| 106 | + continue; |
| 107 | + } |
| 108 | + |
| 109 | + if (propertyCount >= maxProperties) { |
| 110 | + acc[innerKey] = '[MaxProperties ~]'; |
| 111 | + break; |
| 112 | + } |
| 113 | + |
| 114 | + propertyCount += 1; |
| 115 | + |
| 116 | + // Recursively walk through all the child nodes |
| 117 | + const innerValue: UnknownMaybeWithToJson = source[innerKey]; |
| 118 | + acc[innerKey] = walk(innerKey, innerValue, depth - 1, maxProperties, memo); |
| 119 | + } |
| 120 | + |
| 121 | + // Once walked through all the branches, remove the parent from memo storage |
| 122 | + unmemoize(value); |
| 123 | + |
| 124 | + // Return accumulated values |
| 125 | + return acc; |
| 126 | +} |
| 127 | + |
| 128 | +/** |
| 129 | + * Transform any non-primitive, BigInt, or Symbol-type value into a string. Acts as a no-op on strings, numbers, |
| 130 | + * booleans, null, and undefined. |
| 131 | + * |
| 132 | + * @param value The value to stringify |
| 133 | + * @returns For non-primitive, BigInt, and Symbol-type values, a string denoting the value's type, type and value, or |
| 134 | + * type and `description` property, respectively. For non-BigInt, non-Symbol primitives, returns the original value, |
| 135 | + * unchanged. |
| 136 | + */ |
| 137 | +function serializeValue(value: any): any { |
| 138 | + // Node.js REPL notation |
| 139 | + if (typeof value === 'string') { |
| 140 | + return value; |
| 141 | + } |
| 142 | + |
| 143 | + const type = Object.prototype.toString.call(value); |
| 144 | + if (type === '[object Object]') { |
| 145 | + return '[Object]'; |
| 146 | + } |
| 147 | + if (type === '[object Array]') { |
| 148 | + return '[Array]'; |
| 149 | + } |
| 150 | + |
| 151 | + // `makeSerializable` provides a string representation of certain non-serializable values. For all others, it's a |
| 152 | + // pass-through. |
| 153 | + const serializable = makeSerializable(value); |
| 154 | + return isPrimitive(serializable) ? serializable : type; |
| 155 | +} |
| 156 | + |
| 157 | +/** |
| 158 | + * makeSerializable() |
| 159 | + * |
| 160 | + * Takes unserializable input and make it serializer-friendly. |
| 161 | + * |
| 162 | + * Handles globals, functions, `undefined`, `NaN`, and other non-serializable values. |
| 163 | + */ |
| 164 | +function makeSerializable<T>(value: T, key?: any): T | string { |
| 165 | + if (key === 'domain' && value && typeof value === 'object' && (value as unknown as { _events: any })._events) { |
| 166 | + return '[Domain]'; |
| 167 | + } |
| 168 | + |
| 169 | + if (key === 'domainEmitter') { |
| 170 | + return '[DomainEmitter]'; |
| 171 | + } |
| 172 | + |
| 173 | + if (typeof (global as any) !== 'undefined' && (value as unknown) === global) { |
| 174 | + return '[Global]'; |
| 175 | + } |
| 176 | + |
| 177 | + // It's safe to use `window` and `document` here in this manner, as we are asserting using `typeof` first |
| 178 | + // which won't throw if they are not present. |
| 179 | + |
| 180 | + // eslint-disable-next-line no-restricted-globals |
| 181 | + if (typeof (window as any) !== 'undefined' && (value as unknown) === window) { |
| 182 | + return '[Window]'; |
| 183 | + } |
| 184 | + |
| 185 | + // eslint-disable-next-line no-restricted-globals |
| 186 | + if (typeof (document as any) !== 'undefined' && (value as unknown) === document) { |
| 187 | + return '[Document]'; |
| 188 | + } |
| 189 | + |
| 190 | + // React's SyntheticEvent thingy |
| 191 | + if (isSyntheticEvent(value)) { |
| 192 | + return '[SyntheticEvent]'; |
| 193 | + } |
| 194 | + |
| 195 | + if (typeof value === 'number' && value !== value) { |
| 196 | + return '[NaN]'; |
| 197 | + } |
| 198 | + |
| 199 | + if (value === void 0) { |
| 200 | + return '[undefined]'; |
| 201 | + } |
| 202 | + |
| 203 | + if (typeof value === 'function') { |
| 204 | + return `[Function: ${getFunctionName(value)}]`; |
| 205 | + } |
| 206 | + |
| 207 | + // symbols and bigints are considered primitives by TS, but aren't natively JSON-serilaizable |
| 208 | + |
| 209 | + if (typeof value === 'symbol') { |
| 210 | + return `[${String(value)}]`; |
| 211 | + } |
| 212 | + |
| 213 | + if (typeof value === 'bigint') { |
| 214 | + return `[BigInt: ${String(value)}]`; |
| 215 | + } |
| 216 | + |
| 217 | + return value; |
| 218 | +} |
| 219 | + |
| 220 | +/** Calculates bytes size of input string */ |
| 221 | +function utf8Length(value: string): number { |
| 222 | + // eslint-disable-next-line no-bitwise |
| 223 | + return ~-encodeURI(value).split(/%..|./).length; |
| 224 | +} |
| 225 | + |
| 226 | +/** Calculates bytes size of input object */ |
| 227 | +function jsonSize(value: any): number { |
| 228 | + return utf8Length(JSON.stringify(value)); |
| 229 | +} |
0 commit comments