devalue
Version:
Gets the job done when JSON.stringify can't
194 lines (147 loc) • 5.76 kB
JavaScript
import { MAX_ARRAY_INDEX } from './constants.js';
import {
enumerable_symbols,
get_type,
is_plain_object,
valid_array_indices
} from './utils.js';
/**
* Merges caller-provided operation overrides over the defaults. Iterating the
* default keys (rather than the override's own keys) means nullish members
* fall back to the default, and inherited members — e.g. from a class
* instance — are picked up.
*
* @template {Record<string, any>} T
* @param {T} defaults
* @param {Partial<T> | undefined} overrides
* @returns {T}
*/
export function merge_operations(defaults, overrides) {
if (!overrides) return defaults;
const merged = /** @type {T} */ ({});
for (const key of /** @type {(keyof T)[]} */ (Object.keys(defaults))) {
merged[key] = overrides[key] ?? defaults[key];
}
return merged;
}
/** @type {{ kind: 'not-plain' }} */
const NOT_PLAIN = Object.freeze({ kind: 'not-plain' });
/** @type {{ kind: 'symbol-keys' }} */
const SYMBOL_KEYS = Object.freeze({ kind: 'symbol-keys' });
/**
* The default implementations of every introspection/extraction operation
* `stringify` performs on the value being serialized. Each one uses native
* JavaScript semantics (property access, iteration, prototype methods, etc).
*
* Pass overrides via the `operations` option of `stringify`/`stringifyAsync`
* to customize how values are inspected — e.g. to serialize values without
* triggering getters, proxy traps, or patched prototype methods, or to
* serialize values that live in a different JavaScript runtime (a `node:vm`
* context, a WASM-hosted engine, a remote process) through handle objects.
*
* The object is frozen — it is shared by every `stringify` call that does
* not override a given operation.
*
*/
/** @type {import('./types.js').DefaultStringifyOperations} */
const stringify_operations = {
identify: (value) => value,
typeOf: (value) => (value === null ? 'null' : typeof value),
toPrimitive: (value) => value,
tagOf: (value) => get_type(value),
isThenable: (value) => typeof value.then === 'function',
toPromise: (thenable) => Promise.resolve(thenable),
unbox: (boxed) => boxed.valueOf(),
toISOString: (date) => (isNaN(date.getDate()) ? '' : date.toISOString()),
toStringValue: (value) => value.toString(),
regExpInfo: (regexp) => ({ source: regexp.source, flags: regexp.flags }),
valuesOf: (set) => set,
entriesOf: (map) => map,
viewInfo: (view) => ({
buffer: view.buffer,
byteOffset: view.byteOffset,
byteLength: view.byteLength,
length: view.length,
bufferByteLength: view.buffer.byteLength
}),
toArrayBuffer: (buffer) => buffer,
lengthOf: (array) => array.length,
hasOwn: (value, key) => Object.hasOwn(value, key),
indicesOf: (array) => valid_array_indices(array),
shapeOf: (value) => {
if (!is_plain_object(value)) return NOT_PLAIN;
if (enumerable_symbols(value).length > 0) return SYMBOL_KEYS;
return {
kind: Object.getPrototypeOf(value) === null ? 'null-proto' : 'plain',
keys: Object.keys(value)
};
},
get: (value, key) => value[key]
};
export const default_stringify_operations = Object.freeze(stringify_operations);
/**
* The default implementations of every construction operation `parse` and
* `unflatten` perform while reviving a value. Each one uses native
* JavaScript semantics (built-in constructors, property assignment, etc).
*
* Pass overrides via the `operations` option of `parse`/`unflatten` to
* customize how values are built — e.g. to construct them from the
* intrinsics of a different realm (a `node:vm` context), or to build up
* values inside another JavaScript runtime (a WASM-hosted engine, a remote
* process) through handle objects.
*
* The object is frozen — it is shared by every `parse` call that does not
* override a given operation.
*
*/
/** @type {import('./types.js').DefaultParseOperations} */
const parse_operations = {
fromPrimitive: (primitive) => primitive,
fromISOString: (iso) => new Date(iso),
fromStringValue: (tag, text) => {
if (tag === 'URL') return new URL(text);
if (tag === 'URLSearchParams') return new URLSearchParams(text);
// 'Temporal.Instant', 'Temporal.PlainDate', ...
// @ts-expect-error TS doesn't know about Temporal yet
return Temporal[tag.slice(9)].from(text);
},
fromArrayBuffer: (buffer) => buffer,
fromRegExpInfo: (source, flags) => new RegExp(source, flags),
fromViewInfo: (tag, buffer, byteOffset, length) => {
const Constructor = /** @type {any} */ (globalThis)[tag];
return byteOffset !== undefined
? new Constructor(buffer, byteOffset, length)
: new Constructor(buffer);
},
box: (value) => Object(value),
createArray: (length) => new Array(length),
createSparseArray: (length) => {
/** @type {any[]} */
const array = [];
// Setting `array.length = length` (or equivalently calling
// `new Array(length)`) on an untrusted length is a DoS vector: V8
// eagerly allocates a contiguous backing store for array lengths below
// ~10^8, so a small payload with a huge declared length can force
// arbitrary memory allocation. Touching the largest-possible index
// first forces V8 into dictionary-elements mode, where `length` is
// just a number and no contiguous allocation occurs.
array[MAX_ARRAY_INDEX] = undefined;
delete array[MAX_ARRAY_INDEX];
array.length = length;
return array;
},
createObject: () => ({}),
createNullPrototypeObject: () => Object.create(null),
createSet: () => new Set(),
createMap: () => new Map(),
set: (target, key, value) => {
target[key] = value;
},
addValue: (set, value) => {
set.add(value);
},
addEntry: (map, key, value) => {
map.set(key, value);
}
};
export const default_parse_operations = Object.freeze(parse_operations);