402 lines
10 KiB
JavaScript
402 lines
10 KiB
JavaScript
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) {
|
|
const stringified = run(false, value, reducers);
|
|
return typeof stringified === 'string' ? stringified : `[${stringified.join(',')}]`;
|
|
}
|
|
|
|
/**
|
|
* 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 async function stringifyAsync(value, reducers) {
|
|
const stringified = run(true, value, reducers);
|
|
|
|
if (typeof stringified === 'string') {
|
|
return stringified;
|
|
}
|
|
|
|
let out = '[';
|
|
|
|
for (let i = 0; i < stringified.length; i += 1) {
|
|
let value = stringified[i];
|
|
|
|
if (typeof value !== 'string') {
|
|
await value;
|
|
value = stringified[i];
|
|
|
|
if (i === 0 && value < 0) {
|
|
return `${value}`;
|
|
}
|
|
}
|
|
|
|
out += value;
|
|
|
|
if (i < stringified.length - 1) {
|
|
out += ',';
|
|
}
|
|
}
|
|
|
|
out += ']';
|
|
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* @param {boolean} async
|
|
* @param {any} value
|
|
* @param {Record<string, (value: any) => any>} [reducers]
|
|
*/
|
|
function run(async, 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
|
|
* @param {number} [index]
|
|
*/
|
|
function flatten(thing, index) {
|
|
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 /** @type {number} */ (indexes.get(thing));
|
|
|
|
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);
|
|
} else if (typeof thing === 'symbol') {
|
|
throw new DevalueError(`Cannot stringify a Symbol primitive`, keys, thing, value);
|
|
}
|
|
|
|
/** @type {string | Promise<any>} */
|
|
let str = '';
|
|
|
|
if (is_primitive(thing)) {
|
|
str = stringify_primitive(thing);
|
|
} else if (typeof thing.then === 'function') {
|
|
if (!async) {
|
|
throw new DevalueError(
|
|
`Cannot stringify a Promise or thenable — use stringifyAsync instead`,
|
|
keys,
|
|
thing,
|
|
value
|
|
);
|
|
}
|
|
|
|
str = Promise.resolve(thing).then((value) => {
|
|
const i = flatten(value, index);
|
|
if (i < 0) stringified[index] = i;
|
|
});
|
|
} else {
|
|
const type = get_type(thing);
|
|
|
|
switch (type) {
|
|
case 'Number':
|
|
case 'String':
|
|
case 'Boolean':
|
|
case 'BigInt':
|
|
str = `["Object",${flatten(thing.valueOf())}]`;
|
|
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 'Float16Array':
|
|
case 'Int32Array':
|
|
case 'Uint32Array':
|
|
case 'Float32Array':
|
|
case 'Float64Array':
|
|
case 'BigInt64Array':
|
|
case 'BigUint64Array':
|
|
case 'DataView': {
|
|
/** @type {import("./types.js").TypedArray} */
|
|
const typedArray = thing;
|
|
str = '["' + type + '",' + flatten(typedArray.buffer);
|
|
|
|
// handle subarrays
|
|
if (typedArray.byteLength !== typedArray.buffer.byteLength) {
|
|
// to be used with `new TypedArray(buffer, byteOffset, length)`
|
|
str += `,${typedArray.byteOffset},${typedArray.length}`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @param {any} thing
|
|
* @returns {string}
|
|
*/
|
|
function stringify_primitive(thing) {
|
|
const type = typeof thing;
|
|
if (type === 'string') return stringify_string(thing);
|
|
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);
|
|
}
|