devalue
Version:
Gets the job done when JSON.stringify can't
623 lines (530 loc) • 17.4 kB
JavaScript
/**
* @import { UnevalReplacer } from './types.js';
*/
import { js, JavaScriptSource } from './javascript-source.js';
import {
DevalueError,
MAP_KEY,
enumerable_symbols,
escaped,
format_path,
get_name,
get_type,
is_buffer,
is_plain_object,
is_primitive,
stringify_sparse_array,
stringify_string,
valid_array_indices
} from './utils.js';
const unsafe_chars = /[<\b\f\n\r\t\0\u2028\u2029]/g;
// Short strings have bounded escaping/output costs per reference.
const MIN_STRING_LENGTH = 128;
/**
* Turn a value into the JavaScript that creates an equivalent value
* @param {any} value
* @param {UnevalReplacer} [replacer]
*/
export function uneval(value, replacer) {
/** @type {Map<any, string>} */
const names = new Map();
const reserved = new Set();
const templates = new Set();
const seen = new Set();
// the path to the value being walked, recorded as-is and only formatted
// by `error` — see `format_path`
/** @type {any[]} */
const keys = [];
/** @type {Map<any, JavaScriptSource>} */
const custom = new Map();
/**
* @param {string} message
* @param {any} thing
* @returns {never}
*/
function error(message, thing) {
const path = format_path(keys, (key) => (is_primitive(key) ? stringify_primitive(key) : '...'));
throw new DevalueError(message, path, thing, value);
}
/** @type {Map<string | bigint, number> | undefined} */
let primitive_counts;
/** @type {Map<string | bigint, string> | undefined} */
let primitives;
/** @param {any} thing */
function stringify_cached_primitive(thing) {
if (
(typeof thing === 'string' && thing.length >= MIN_STRING_LENGTH) ||
typeof thing === 'bigint'
) {
primitives ??= new Map();
let literal = primitives.get(thing);
if (literal === undefined) {
literal = stringify_primitive(thing);
primitives.set(thing, literal);
}
return literal;
}
return stringify_primitive(thing);
}
/** @param {any} thing */
function walk(thing) {
if (!is_primitive(thing)) {
if (seen.has(thing)) {
if (!names.has(thing)) names.set(thing, '');
return;
}
seen.add(thing);
if (replacer) {
const fragment = replacer(thing, js);
if (fragment) {
const source = JavaScriptSource.from(fragment);
custom.set(thing, source);
source.visit(walk, reserved, templates);
return;
}
if (fragment !== undefined && fragment !== null && fragment !== false) {
throw new TypeError('Invalid uneval replacer result');
}
}
if (typeof thing === 'function') {
error(`Cannot stringify a function`, thing);
}
const type = get_type(thing);
switch (type) {
case 'BigInt':
case 'String':
walk(thing.valueOf());
return;
case 'Number':
case 'Boolean':
case 'Date':
case 'RegExp':
case 'URL':
case 'URLSearchParams':
return;
case 'Array':
// Never scan the logical length of a dictionary-backed sparse array.
for (const i of valid_array_indices(thing)) {
keys.push(+i);
walk(thing[i]);
keys.pop();
}
break;
case 'Set':
for (const value of thing) walk(value);
break;
case 'Map':
for (const [key, value] of thing) {
keys.push(MAP_KEY, key);
walk(key);
walk(value);
keys.pop();
keys.pop();
}
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':
// Buffer pools can contain unrelated, sensitive bytes.
if (!is_buffer(thing)) walk(thing.buffer);
return;
case 'ArrayBuffer':
return;
case 'Temporal.Duration':
case 'Temporal.Instant':
case 'Temporal.PlainDate':
case 'Temporal.PlainTime':
case 'Temporal.PlainDateTime':
case 'Temporal.PlainMonthDay':
case 'Temporal.PlainYearMonth':
case 'Temporal.ZonedDateTime':
return;
default:
if (!is_plain_object(thing)) {
error(`Cannot stringify arbitrary non-POJOs`, thing);
}
if (enumerable_symbols(thing).length > 0) {
error(`Cannot stringify POJOs with symbolic keys`, thing);
}
for (const key of Object.keys(thing)) {
if (key === '__proto__') {
error(`Cannot stringify objects with __proto__ keys`, thing);
}
keys.push(key);
walk(thing[key]);
keys.pop();
}
}
} else if (typeof thing === 'symbol') {
error(`Cannot stringify a Symbol primitive`, thing);
} else if (
(typeof thing === 'string' && thing.length >= MIN_STRING_LENGTH) ||
typeof thing === 'bigint'
) {
primitive_counts ??= new Map();
primitive_counts.set(thing, (primitive_counts.get(thing) || 0) + 1);
}
}
walk(value);
let name_index = 0;
function next_name() {
let name;
do {
name = get_name(name_index++);
} while (reserved.has(name));
return name;
}
// Wait until every custom source has been visited before assigning names.
for (const thing of names.keys()) names.set(thing, next_name());
if (primitive_counts) {
for (const [thing, count] of primitive_counts) {
if (count < 2) continue;
const name = next_name();
const length = stringify_cached_primitive(thing).length;
// Include the declaration and IIFE overhead even if one already exists.
if (length * count > length + (count + 1) * name.length + 40) names.set(thing, name);
}
}
// Reuse the traversal set to track declarations during serialization.
seen.clear();
const inlining = custom.size > 0 ? new Set() : null;
/** @type {Map<any, () => void>} */
const initializers = new Map();
/** @type {string[]} */
const statements = [];
/**
* @param {any} thing
* @returns {string}
*/
function stringify(thing) {
const name = names.get(thing);
if (name) {
if (!seen.has(thing)) {
if (is_primitive(thing)) {
seen.add(thing);
statements.push(`let ${name}=${stringify_cached_primitive(thing)}`);
return name;
}
const type = custom.has(thing) ? null : get_type(thing);
switch (type) {
case 'Object':
seen.add(thing);
statements.push(
`let ${name}=${Object.getPrototypeOf(thing) === null ? 'Object.create(null)' : '{}'}`
);
Object.keys(thing).forEach((key) => {
statements.push(`${name}${safe_prop(key)}=${stringify(thing[key])}`);
});
break;
case 'Array': {
seen.add(thing);
const indices = valid_array_indices(thing);
const array =
thing.length > 32 + 2 * indices.length
? stringify_sparse_array(thing.length)
: `Array(${thing.length})`;
statements.push(`let ${name}=${array}`);
for (const i of indices) {
statements.push(`${name}[${i}]=${stringify(thing[i])}`);
}
break;
}
case 'Set':
case 'Map': {
seen.add(thing);
/** @type {string[]} */
const entries = [];
let initialized = false;
let statement_index = -1;
// Collect a ready prefix until a reference needs the collection.
// Emit subsequent entries eagerly, preserving insertion order.
const initialize = () => {
if (initialized) return;
initialized = true;
initializers.delete(thing);
statements.push(
`let ${name}=new ${type}${entries.length ? `([${entries.join(',')}])` : ''}`
);
};
initializers.set(thing, initialize);
for (const entry of thing) {
const args =
type === 'Map' ? `${stringify(entry[0])},${stringify(entry[1])}` : stringify(entry);
if (!initialized) {
entries.push(type === 'Map' ? `[${args}]` : args);
} else {
const call = `.${type === 'Map' ? 'set' : 'add'}(${args})`;
if (statement_index === statements.length - 1) {
statements[statement_index] += call;
} else {
statement_index = statements.push(name + call) - 1;
}
}
}
initialize();
break;
}
default:
const str = actually_stringify(thing);
if (!seen.has(thing)) statements.push(`let ${name}=${str}`);
seen.add(thing);
}
} else {
initializers.get(thing)?.();
}
return name;
}
if (is_primitive(thing)) {
return stringify_cached_primitive(thing);
}
if (inlining === null) return actually_stringify(thing);
// A singly referenced value can still be part of a custom constructor's
// cycle. If inlining it re-enters itself, hoist it to break the cycle.
if (inlining.has(thing)) {
names.set(thing, next_name());
return stringify(thing);
}
inlining.add(thing);
const str = actually_stringify(thing);
inlining.delete(thing);
return names.get(thing) || str;
}
/**
* @param {any} thing
* @returns {string}
*/
function actually_stringify(thing) {
const source = custom.get(thing);
if (source) {
return source.render(stringify);
}
const type = get_type(thing);
switch (type) {
case 'Number':
case 'String':
case 'Boolean':
case 'BigInt':
return `Object(${stringify(thing.valueOf())})`;
case 'RegExp':
const { source, flags } = thing;
return flags
? `new RegExp(${stringify_string(source)},"${flags}")`
: `new RegExp(${stringify_string(source)})`;
case 'Date':
return `new Date(${thing.getTime()})`;
case 'URL':
return `new URL(${stringify_string(thing.toString())})`;
case 'URLSearchParams':
return `new URLSearchParams(${stringify_string(thing.toString())})`;
case 'Array': {
// For dense arrays (no holes), we iterate normally.
// When we encounter the first hole, we collect own indices
// to determine the sparseness, then decide between:
// - Array literal with holes: [,"a",,] (default)
// - Object.assign with a sparse-safe allocator (for very sparse arrays)
// Only the Object.assign path avoids iterating every slot, which
// is what protects against the DoS of e.g. `arr[1000000] = 1`.
let has_holes = false;
let result = '[';
for (let i = 0; i < thing.length; i += 1) {
if (i > 0) result += ',';
if (Object.hasOwn(thing, i)) {
result += stringify(thing[i]);
} else if (!has_holes) {
// Decide between array literal and Object.assign.
//
// Array literal: holes are consecutive commas.
// For example, [, "a", ,] is written as [,"a",,].
// Each hole costs 1 char (a comma).
//
// Object.assign: populated indices are listed explicitly.
// For example, [, "a", ,] would be written as
// Object.assign(sparse(3),{1:"a"}), where sparse(n) stands
// for the expression emitted by stringify_sparse_array(n).
// This avoids paying per-hole, but has fixed overhead for
// the allocator and wrapper, plus each index and colon.
//
// The serialized values are the same size either way, so
// the choice comes down to the structural overhead:
//
// Array literal overhead:
// 1 char per element or hole (comma separators)
// + 2 chars for "[" and "]"
// = L + 2
//
// Object.assign overhead:
// "Object.assign(" — 14 chars
// + allocator expression — A chars
// + ",{" — 2 chars
// + for each populated element:
// index + ":" + "," — (d + 2) chars
// + "})" — 2 chars
// = (18 + A) + P * (d + 2)
//
// where L is the array length, P is the number of
// populated elements, A is the allocator length, and d
// is the number of digits in L (an upper bound per index).
//
// Object.assign is cheaper when:
// (18 + A) + P * (d + 2) < L + 2
const populated_keys = valid_array_indices(/** @type {any[]} */ (thing));
const population = populated_keys.length;
const d = String(thing.length).length;
const array = stringify_sparse_array(thing.length);
const hole_cost = thing.length + 2;
const sparse_cost = array.length + 18 + population * (d + 2);
if (hole_cost > sparse_cost) {
const entries = populated_keys.map((k) => `${k}:${stringify(thing[k])}`).join(',');
return `Object.assign(${array},{${entries}})`;
}
has_holes = true;
}
// else: already decided on array literal, hole is just an empty slot
// (the comma separator is all we need — no content for this position)
}
const tail = thing.length === 0 || Object.hasOwn(thing, thing.length - 1) ? '' : ',';
return result + tail + ']';
}
case 'Set':
return `new Set([${Array.from(thing, stringify).join(',')}])`;
case 'Map': {
const entries = Array.from(thing, ([k, v]) => `[${stringify(k)},${stringify(v)}]`);
return `new Map([${entries.join(',')}])`;
}
case 'Int8Array':
case 'Uint8Array':
case 'Uint8ClampedArray':
case 'Int16Array':
case 'Uint16Array':
case 'Float16Array':
case 'Int32Array':
case 'Uint32Array':
case 'Float32Array':
case 'Float64Array':
case 'BigInt64Array':
case 'BigUint64Array': {
if (is_buffer(thing)) thing = new Uint8Array(thing);
if (thing.buffer.byteLength % thing.BYTES_PER_ELEMENT !== 0) {
return `new ${type}(${stringify(thing.buffer)},${thing.byteOffset},${thing.length})`;
}
let str = `new ${type}`;
if (!names.has(thing.buffer)) {
str += `([${stringify_typed_array_elements(type, thing.buffer)}])`;
} else {
str += `(${stringify(thing.buffer)})`;
}
// handle subarrays
if (thing.byteLength !== thing.buffer.byteLength) {
const start = thing.byteOffset / thing.BYTES_PER_ELEMENT;
const end = start + thing.length;
str += `.subarray(${start},${end})`;
}
return str;
}
case 'DataView': {
let str = `new DataView`;
if (!names.has(thing.buffer)) {
str += `(new Uint8Array([${new Uint8Array(thing.buffer)}]).buffer`;
} else {
str += `(${stringify(thing.buffer)}`;
}
// handle subviews
if (thing.byteLength !== thing.buffer.byteLength) {
str += `,${thing.byteOffset},${thing.byteLength}`;
}
return str + ')';
}
case 'ArrayBuffer': {
const ui8 = new Uint8Array(thing);
return `new Uint8Array([${ui8.toString()}]).buffer`;
}
case 'Temporal.Duration':
case 'Temporal.Instant':
case 'Temporal.PlainDate':
case 'Temporal.PlainTime':
case 'Temporal.PlainDateTime':
case 'Temporal.PlainMonthDay':
case 'Temporal.PlainYearMonth':
case 'Temporal.ZonedDateTime':
return `${type}.from(${stringify_string(thing.toString())})`;
default:
const keys = Object.keys(thing);
const obj = keys.map((key) => `${safe_key(key)}:${stringify(thing[key])}`).join(',');
const proto = Object.getPrototypeOf(thing);
if (proto === null) {
return keys.length > 0 ? `{${obj},__proto__:null}` : `{__proto__:null}`;
}
return `{${obj}}`;
}
}
const str = stringify(value);
if (statements.length === 0) {
return str;
}
return `(function(){${statements.join(';')};return ${str}}())`;
}
/**
* Serialize the elements of `buffer`, read as `type`, as a comma-separated list.
* The view is created from `type` rather than from the serialized value's own
* constructor, which may be a subclass like Node's `Buffer` whose `toString`
* decodes the bytes instead of listing them.
* `BigInt64Array`/`BigUint64Array` elements are bigints and must be written
* with an `n` suffix, otherwise the emitted `new BigInt64Array([...])` throws.
* @param {string} type
* @param {ArrayBufferLike} buffer
*/
function stringify_typed_array_elements(type, buffer) {
const array = new /** @type {any} */ (globalThis)[type](buffer);
if (type === 'BigInt64Array' || type === 'BigUint64Array') {
return Array.from(array, (element) => `${element}n`).join(',');
}
// Float arrays can hold `-0`, which `toString()` collapses to `"0"`, silently
// losing the sign on round-trip. Emit `-0` explicitly for those elements.
if (
array instanceof Float32Array ||
array instanceof Float64Array ||
(typeof Float16Array !== 'undefined' && array instanceof Float16Array)
) {
return Array.from(array, (element) => (Object.is(element, -0) ? '-0' : `${element}`)).join(',');
}
return array.toString();
}
/** @param {string} c */
function escape_unsafe_char(c) {
return escaped[c] || c;
}
/** @param {string} str */
function escape_unsafe_chars(str) {
return str.replace(unsafe_chars, escape_unsafe_char);
}
/** @param {string} key */
function safe_key(key) {
return /^[_$a-zA-Z][_$a-zA-Z0-9]*$/.test(key) ? key : escape_unsafe_chars(JSON.stringify(key));
}
/** @param {string} key */
function safe_prop(key) {
return /^[_$a-zA-Z][_$a-zA-Z0-9]*$/.test(key)
? `.${key}`
: `[${escape_unsafe_chars(JSON.stringify(key))}]`;
}
/** @param {any} thing */
function stringify_primitive(thing) {
const type = typeof thing;
if (type === 'string') return stringify_string(thing);
if (thing === void 0) return 'void 0';
if (thing === 0 && 1 / thing < 0) return '-0';
const str = String(thing);
if (type === 'number') return str.replace(/^(-)?0\./, '$1.');
if (type === 'bigint') return thing + 'n';
return str;
}