Skip to content

Commit 57a82f3

Browse files
authored
ref(utils): Split normalization code into separate module (#4760)
This splits the normalization functions and tests into their own files, as `object.ts` and its tests had become quite unwieldy.
1 parent 1852e6b commit 57a82f3

File tree

5 files changed

+722
-720
lines changed

5 files changed

+722
-720
lines changed

packages/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from './logger';
1010
export * from './memo';
1111
export * from './misc';
1212
export * from './node';
13+
export * from './normalize';
1314
export * from './object';
1415
export * from './path';
1516
export * from './promisebuffer';

packages/utils/src/normalize.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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

Comments
 (0)