devalue
Version:
Gets the job done when JSON.stringify can't
275 lines (226 loc) • 7.74 kB
JavaScript
import { decode64 } from './base64.js';
import {
HOLE,
NAN,
NEGATIVE_INFINITY,
NEGATIVE_ZERO,
POSITIVE_INFINITY,
SPARSE,
UNDEFINED
} from './constants.js';
import { default_parse_operations, merge_operations } from './operations.js';
import { is_valid_array_index, is_valid_array_len } from './utils.js';
/**
* Revive a value serialized with `devalue.stringify`
* @param {string} serialized
* @param {Record<string, (value: any) => any>} [revivers]
* @param {import('./types.js').ParseOptions} [options]
*/
export function parse(serialized, revivers, options) {
return unflatten(JSON.parse(serialized), revivers, options);
}
/**
* Revive a value flattened with `devalue.stringify`
* @param {number | any[]} parsed
* @param {Record<string, (value: any) => any>} [revivers]
* @param {import('./types.js').ParseOptions} [options]
*/
export function unflatten(parsed, revivers, options) {
/** @type {import('./types.js').ParseOperations} */
const ops = merge_operations(default_parse_operations, options?.operations);
if (typeof parsed === 'number') return hydrate(parsed, true);
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error('Invalid input');
}
const values = /** @type {any[]} */ (parsed);
const hydrated = Array(values.length);
/**
* A set of values currently being hydrated with custom revivers,
* used to detect invalid cyclical dependencies
* @type {Set<number> | null}
*/
let hydrating = null;
/**
* @param {number} index
* @returns {any}
*/
function hydrate(index, standalone = false) {
if (index === UNDEFINED) return ops.fromPrimitive(undefined);
if (index === NAN) return ops.fromPrimitive(NaN);
if (index === POSITIVE_INFINITY) return ops.fromPrimitive(Infinity);
if (index === NEGATIVE_INFINITY) return ops.fromPrimitive(-Infinity);
if (index === NEGATIVE_ZERO) return ops.fromPrimitive(-0);
if (standalone || typeof index !== 'number') {
throw new Error(`Invalid input`);
}
if (index in hydrated) return hydrated[index];
if (index >= values.length) {
throw new Error(`Invalid input`);
}
const value = values[index];
if (!value || typeof value !== 'object') {
hydrated[index] = ops.fromPrimitive(value);
} else if (Array.isArray(value)) {
if (typeof value[0] === 'string') {
const type = value[0];
const reviver = revivers && Object.hasOwn(revivers, type) ? revivers[type] : undefined;
if (reviver) {
let i = value[1];
if (typeof i !== 'number') {
// if it's not a number, it was serialized by a builtin reviver
// so we need to munge it into the format expected by a custom reviver
i = values.push(value[1]) - 1;
}
// If the payload is already hydrated, its recursion has already
// terminated (e.g. a self-referential object cached itself before
// following its own back-reference), so revive it directly. Falling
// through to the `hydrating` guard here would wrongly reject a valid
// cycle. An actually infinite payload (e.g. `[["Custom", 0]]`) is never
// cached, so it still hits the guard below.
if (Object.hasOwn(hydrated, i)) {
return (hydrated[index] = reviver(hydrated[i]));
}
hydrating ??= new Set();
if (hydrating.has(i)) {
throw new Error('Invalid circular reference');
}
hydrating.add(i);
hydrated[index] = reviver(hydrate(i));
hydrating.delete(i);
return hydrated[index];
}
switch (type) {
case 'Date':
hydrated[index] = ops.fromISOString(value[1]);
break;
case 'Set':
const set = ops.createSet();
hydrated[index] = set;
for (let i = 1; i < value.length; i += 1) {
ops.addValue(set, hydrate(value[i]));
}
break;
case 'Map':
const map = ops.createMap();
hydrated[index] = map;
for (let i = 1; i < value.length; i += 2) {
ops.addEntry(map, hydrate(value[i]), hydrate(value[i + 1]));
}
break;
case 'RegExp':
hydrated[index] = ops.fromRegExpInfo(value[1], value[2]);
break;
case 'Object': {
const wrapped_index = value[1];
if (
typeof values[wrapped_index] === 'object' &&
values[wrapped_index][0] !== 'BigInt'
) {
// avoid infinite recusion in case of malformed input
throw new Error('Invalid input');
}
hydrated[index] = ops.box(hydrate(wrapped_index));
break;
}
case 'BigInt':
hydrated[index] = ops.fromPrimitive(BigInt(value[1]));
break;
case 'null':
const obj = ops.createNullPrototypeObject();
hydrated[index] = obj;
for (let i = 1; i < value.length; i += 2) {
if (value[i] === '__proto__') {
throw new Error('Cannot parse an object with a `__proto__` property');
}
ops.set(obj, value[i], hydrate(value[i + 1]));
}
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': {
if (values[value[1]][0] !== 'ArrayBuffer') {
// without this, if we receive malformed input we could
// end up trying to hydrate in a circle or allocate
// huge amounts of memory when we call `new TypedArrayConstructor(buffer)`
throw new Error('Invalid data');
}
const buffer = hydrate(value[1]);
hydrated[index] = ops.fromViewInfo(type, buffer, value[2], value[3]);
break;
}
case 'ArrayBuffer': {
const base64 = value[1];
if (typeof base64 !== 'string') {
throw new Error('Invalid ArrayBuffer encoding');
}
hydrated[index] = ops.fromArrayBuffer(decode64(base64));
break;
}
case 'URL':
case 'URLSearchParams':
case 'Temporal.Duration':
case 'Temporal.Instant':
case 'Temporal.PlainDate':
case 'Temporal.PlainTime':
case 'Temporal.PlainDateTime':
case 'Temporal.PlainMonthDay':
case 'Temporal.PlainYearMonth':
case 'Temporal.ZonedDateTime': {
// the same tags `toStringValue` serializes on the stringify side
hydrated[index] = ops.fromStringValue(type, value[1]);
break;
}
default:
throw new Error(`Unknown type ${type}`);
}
} else if (value[0] === SPARSE) {
// Sparse array encoding: [SPARSE, length, idx, val, idx, val, ...]
const len = value[1];
if (!is_valid_array_len(len)) {
throw new Error('Invalid input');
}
// `len` comes from the input rather than being bounded by it, so
// `createSparseArray` is responsible for not allocating storage
// proportional to it.
const array = ops.createSparseArray(len);
hydrated[index] = array;
for (let i = 2; i < value.length; i += 2) {
const idx = value[i];
if (!is_valid_array_index(idx) || idx >= len) {
throw new Error('Invalid input');
}
ops.set(array, idx, hydrate(value[i + 1]));
}
} else {
const array = ops.createArray(value.length);
hydrated[index] = array;
for (let i = 0; i < value.length; i += 1) {
const n = value[i];
if (n === HOLE) continue;
ops.set(array, i, hydrate(n));
}
}
} else {
const object = ops.createObject();
hydrated[index] = object;
for (const key of Object.keys(value)) {
if (key === '__proto__') {
throw new Error('Cannot parse an object with a `__proto__` property');
}
ops.set(object, key, hydrate(value[key]));
}
}
return hydrated[index];
}
return hydrate(0);
}