UNPKG

devalue

Version:

Gets the job done when JSON.stringify can't

351 lines (301 loc) 9.17 kB
import { DevalueError, enumerable_symbols, get_type, is_plain_object, is_primitive, stringify_key, stringify_string, valid_array_indices } from './utils.js'; import { HOLE, NAN, NEGATIVE_INFINITY, NEGATIVE_ZERO, POSITIVE_INFINITY, SPARSE, UNDEFINED } from './constants.js'; import { encode64 } from './base64.js'; /** * Turn a value into a JSON string that can be parsed with `devalue.parse` * @param {any} value * @param {Record<string, (value: any) => any>} [reducers] */ export function stringify(value, reducers) { /** @type {any[]} */ const stringified = []; /** @type {Map<any, number>} */ const indexes = new Map(); /** @type {Array<{ key: string, fn: (value: any) => any }>} */ const custom = []; if (reducers) { for (const key of Object.getOwnPropertyNames(reducers)) { custom.push({ key, fn: reducers[key] }); } } /** @type {string[]} */ const keys = []; let p = 0; /** @param {any} thing */ function flatten(thing) { if (thing === undefined) return UNDEFINED; if (Number.isNaN(thing)) return NAN; if (thing === Infinity) return POSITIVE_INFINITY; if (thing === -Infinity) return NEGATIVE_INFINITY; if (thing === 0 && 1 / thing < 0) return NEGATIVE_ZERO; if (indexes.has(thing)) return indexes.get(thing); const index = p++; indexes.set(thing, index); for (const { key, fn } of custom) { const value = fn(thing); if (value) { stringified[index] = `["${key}",${flatten(value)}]`; return index; } } if (typeof thing === 'function') { throw new DevalueError(`Cannot stringify a function`, keys, thing, value); } let str = ''; if (is_primitive(thing)) { str = stringify_primitive(thing); } else { const type = get_type(thing); switch (type) { case 'Number': case 'String': case 'Boolean': str = `["Object",${stringify_primitive(thing)}]`; break; case 'BigInt': str = `["BigInt",${thing}]`; break; case 'Date': const valid = !isNaN(thing.getDate()); str = `["Date","${valid ? thing.toISOString() : ''}"]`; break; case 'URL': str = `["URL",${stringify_string(thing.toString())}]`; break; case 'URLSearchParams': str = `["URLSearchParams",${stringify_string(thing.toString())}]`; break; case 'RegExp': const { source, flags } = thing; str = flags ? `["RegExp",${stringify_string(source)},"${flags}"]` : `["RegExp",${stringify_string(source)}]`; break; case 'Array': { // For dense arrays (no holes), we iterate normally. // When we encounter the first hole, we call Object.keys // to determine the sparseness, then decide between: // - HOLE encoding: [-2, val, -2, ...] (default) // - Sparse encoding: [-7, length, idx, val, ...] (for very sparse arrays) // Only the sparse path avoids iterating every slot, which // is what protects against the DoS of e.g. `arr[1000000] = 1`. let mostly_dense = false; str = '['; for (let i = 0; i < thing.length; i += 1) { if (i > 0) str += ','; if (Object.hasOwn(thing, i)) { keys.push(`[${i}]`); str += flatten(thing[i]); keys.pop(); } else if (mostly_dense) { // Use dense encoding. The heuristic guarantees the // array is only mildly sparse, so iterating over every // slot is fine. str += HOLE; } else { // Decide between HOLE encoding and sparse encoding. // // HOLE encoding: each hole is serialized as the HOLE // sentinel (-2). For example, [, "a", ,] becomes // [-2, 0, -2]. Each hole costs 3 chars ("-2" + comma). // // Sparse encoding: lists only populated indices. // For example, [, "a", ,] becomes [-7, 3, 1, 0] — the // -7 sentinel, the array length (3), then index-value // pairs. This avoids paying per-hole, but each element // costs extra chars to write its index. // // The values are the same size either way, so the // choice comes down to structural overhead: // // HOLE overhead: // 3 chars per hole ("-2" + comma) // = (L - P) * 3 // // Sparse overhead: // "-7," — 3 chars (sparse sentinel + comma) // + length + "," — (d + 1) chars (array length + comma) // + per element: index + "," — (d + 1) chars // = (4 + d) + P * (d + 1) // // where L is the array length, P is the number of // populated elements, and d is the number of digits // in L (an upper bound on the digits in any index). // // Sparse encoding is cheaper when: // (4 + d) + P * (d + 1) < (L - P) * 3 const populated_keys = valid_array_indices(/** @type {any[]} */ (thing)); const population = populated_keys.length; const d = String(thing.length).length; const hole_cost = (thing.length - population) * 3; const sparse_cost = 4 + d + population * (d + 1); if (hole_cost > sparse_cost) { str = '[' + SPARSE + ',' + thing.length; for (let j = 0; j < populated_keys.length; j++) { const key = populated_keys[j]; keys.push(`[${key}]`); str += ',' + key + ',' + flatten(thing[key]); keys.pop(); } break; } else { mostly_dense = true; str += HOLE; } } } str += ']'; break; } case 'Set': str = '["Set"'; for (const value of thing) { str += `,${flatten(value)}`; } str += ']'; break; case 'Map': str = '["Map"'; for (const [key, value] of thing) { keys.push( `.get(${is_primitive(key) ? stringify_primitive(key) : '...'})` ); str += `,${flatten(key)},${flatten(value)}`; keys.pop(); } str += ']'; break; case 'Int8Array': case 'Uint8Array': case 'Uint8ClampedArray': case 'Int16Array': case 'Uint16Array': case 'Int32Array': case 'Uint32Array': case 'Float32Array': case 'Float64Array': case 'BigInt64Array': case 'BigUint64Array': { /** @type {import("./types.js").TypedArray} */ const typedArray = thing; str = '["' + type + '",' + flatten(typedArray.buffer); const a = thing.byteOffset; const b = a + thing.byteLength; // handle subarrays if (a > 0 || b !== typedArray.buffer.byteLength) { const m = +/(\d+)/.exec(type)[1] / 8; str += `,${a / m},${b / m}`; } str += ']'; break; } case 'ArrayBuffer': { /** @type {ArrayBuffer} */ const arraybuffer = thing; const base64 = encode64(arraybuffer); str = `["ArrayBuffer","${base64}"]`; break; } case 'Temporal.Duration': case 'Temporal.Instant': case 'Temporal.PlainDate': case 'Temporal.PlainTime': case 'Temporal.PlainDateTime': case 'Temporal.PlainMonthDay': case 'Temporal.PlainYearMonth': case 'Temporal.ZonedDateTime': str = `["${type}",${stringify_string(thing.toString())}]`; break; default: if (!is_plain_object(thing)) { throw new DevalueError( `Cannot stringify arbitrary non-POJOs`, keys, thing, value ); } if (enumerable_symbols(thing).length > 0) { throw new DevalueError( `Cannot stringify POJOs with symbolic keys`, keys, thing, value ); } if (Object.getPrototypeOf(thing) === null) { str = '["null"'; for (const key of Object.keys(thing)) { if (key === '__proto__') { throw new DevalueError( `Cannot stringify objects with __proto__ keys`, keys, thing, value ); } keys.push(stringify_key(key)); str += `,${stringify_string(key)},${flatten(thing[key])}`; keys.pop(); } str += ']'; } else { str = '{'; let started = false; for (const key of Object.keys(thing)) { if (key === '__proto__') { throw new DevalueError( `Cannot stringify objects with __proto__ keys`, keys, thing, value ); } if (started) str += ','; started = true; keys.push(stringify_key(key)); str += `${stringify_string(key)}:${flatten(thing[key])}`; keys.pop(); } str += '}'; } } } stringified[index] = str; return index; } const index = flatten(value); // special case — value is represented as a negative index if (index < 0) return `${index}`; return `[${stringified.join(',')}]`; } /** * @param {any} thing * @returns {string} */ function stringify_primitive(thing) { const type = typeof thing; if (type === 'string') return stringify_string(thing); if (thing instanceof String) return stringify_string(thing.toString()); if (thing === void 0) return UNDEFINED.toString(); if (thing === 0 && 1 / thing < 0) return NEGATIVE_ZERO.toString(); if (type === 'bigint') return `["BigInt","${thing}"]`; return String(thing); }