first commit

This commit is contained in:
2026-10-07 11:08:05 +09:00
commit 6c7d3cc893
1811 changed files with 572221 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
/* Baseline 2025 runtimes */
/** @type {(array_buffer: ArrayBuffer) => string} */
export function encode_native(array_buffer) {
return new Uint8Array(array_buffer).toBase64();
}
/** @type {(base64: string) => ArrayBuffer} */
export function decode_native(base64) {
return Uint8Array.fromBase64(base64).buffer;
}
/* Node-compatible runtimes */
/** @type {(array_buffer: ArrayBuffer) => string} */
export function encode_buffer(array_buffer) {
return Buffer.from(array_buffer).toString('base64');
}
/** @type {(base64: string) => ArrayBuffer} */
export function decode_buffer(base64) {
return Uint8Array.from(Buffer.from(base64, 'base64')).buffer;
}
/* Legacy runtimes */
/** @type {(array_buffer: ArrayBuffer) => string} */
export function encode_legacy(array_buffer) {
const array = new Uint8Array(array_buffer);
let binary = '';
// the maximum number of arguments to String.fromCharCode.apply
// should be around 0xFFFF in modern engines
const chunk_size = 0x8000;
for (let i = 0; i < array.length; i += chunk_size) {
const chunk = array.subarray(i, i + chunk_size);
// `apply` accepts any array-like at runtime; lib types only admit `number[]`.
binary += String.fromCharCode.apply(
null,
/** @type {number[]} */ (/** @type {unknown} */ (chunk))
);
}
return btoa(binary);
}
/** @type {(base64: string) => ArrayBuffer} */
export function decode_legacy(base64) {
const binary_string = atob(base64);
const len = binary_string.length;
const array = new Uint8Array(len);
for (let i = 0; i < len; i++) {
array[i] = binary_string.charCodeAt(i);
}
return array.buffer;
}
const native = typeof Uint8Array.fromBase64 === 'function';
const buffer = typeof process === 'object' && process.versions?.node !== undefined;
export const encode64 = native ? encode_native : buffer ? encode_buffer : encode_legacy;
export const decode64 = native ? decode_native : buffer ? decode_buffer : decode_legacy;
+12
View File
@@ -0,0 +1,12 @@
export const UNDEFINED = -1;
export const HOLE = -2;
export const NAN = -3;
export const POSITIVE_INFINITY = -4;
export const NEGATIVE_INFINITY = -5;
export const NEGATIVE_ZERO = -6;
export const SPARSE = -7;
// The largest valid value for a JavaScript array's `length` property,
// and the largest valid array index (one less than the max length).
export const MAX_ARRAY_LEN = 2 ** 32 - 1;
export const MAX_ARRAY_INDEX = MAX_ARRAY_LEN - 1;
+8
View File
@@ -0,0 +1,8 @@
export { uneval } from './uneval.js';
export { parse, unflatten } from './parse.js';
export { stringify, stringifyAsync } from './stringify.js';
export {
default_stringify_operations as defaultStringifyOperations,
default_parse_operations as defaultParseOperations
} from './operations.js';
export { DevalueError, filter_array_indices as filterArrayIndices } from './utils.js';
+98
View File
@@ -0,0 +1,98 @@
const SOURCE = Symbol('JavaScriptSource');
export class JavaScriptSource {
/**
* @param {TemplateStringsArray} strings
* @param {unknown[]} values
*/
constructor(strings, values) {
this.strings = strings;
this.values = values;
}
/**
* @param {(value: unknown) => string} fn
*/
render(fn) {
const { strings, values } = this;
let result = strings[0];
for (let i = 0; i < values.length; i++) {
const value = values[i];
result += JavaScriptSource.is_fragment(value)
? JavaScriptSource.from(value).render(fn)
: fn(value);
result += strings[i + 1];
}
return result;
}
/**
* @param {(value: unknown) => void} fn
* @param {Set<string>} reserved
* @param {Set<readonly string[]>} templates
*/
visit(fn, reserved, templates) {
const { strings, values } = this;
// Each template site reuses its string array, but its interpolations can change.
if (!templates.has(strings)) {
templates.add(strings);
for (const string of strings) {
// Generated names are ASCII, but literal identifiers can use Unicode escapes.
const decoded = string.replace(
/\\u(?:([\da-f]{4})|\{([\da-f]+)\})/gi,
(escape, hex, code_point) => {
const code = parseInt(hex || code_point, 16);
return code < 128 ? String.fromCharCode(code) : escape;
}
);
// Conservatively reserve words even in property names, strings and comments.
for (const name of decoded.match(/[a-zA-Z_$][\w$]*/g) || []) reserved.add(name);
}
}
for (const value of values) {
if (JavaScriptSource.is_fragment(value)) {
JavaScriptSource.from(value).visit(fn, reserved, templates);
} else {
fn(value);
}
}
}
/**
* @param {unknown} value
* @returns {value is JavaScriptFragment}
*/
static is_fragment(value) {
return typeof value === 'object' && value !== null && SOURCE in value;
}
/**
* @param {JavaScriptFragment} fragment
* @returns {JavaScriptSource}
*/
static from(fragment) {
if (!JavaScriptSource.is_fragment(fragment)) {
throw new TypeError('Invalid JavaScript fragment');
}
return fragment[SOURCE];
}
}
/**
* @param {TemplateStringsArray} strings
* @param {...unknown} values
* @returns {JavaScriptFragment}
*/
export function js(strings, ...values) {
if (!Array.isArray(strings) || !Array.isArray(strings.raw)) {
throw new TypeError(
'`js` must be used as a tagged template, but was called as a regular function'
);
}
return { [SOURCE]: new JavaScriptSource(strings, values) };
}
/**
* The opaque result of the `js` tag passed to an `uneval` replacer.
* @typedef {{ readonly [SOURCE]: JavaScriptSource }} JavaScriptFragment
*/
+220
View File
@@ -0,0 +1,220 @@
import { MAX_ARRAY_INDEX } from './constants.js';
import {
enumerable_symbols,
get_type,
is_buffer,
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 = /* @__PURE__ */ Object.freeze({ kind: 'not-plain' });
/** @type {{ kind: 'symbol-keys' }} */
const SYMBOL_KEYS = /* @__PURE__ */ 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) => {
// Node Buffers may share a pool containing unrelated, sensitive data.
// Copy only the visible bytes, without using Buffer's pooling or slice.
if (is_buffer(view)) view = new Uint8Array(view);
return {
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 = /* @__PURE__ */ Object.freeze(stringify_operations);
const array_buffer_byte_length = /** @type {() => number} */ (
Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, 'byteLength')?.get
);
const shared_array_buffer_byte_length =
typeof SharedArrayBuffer === 'undefined'
? undefined
: Object.getOwnPropertyDescriptor(SharedArrayBuffer.prototype, 'byteLength')?.get;
/**
* 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) => {
// A reviver can replace an ArrayBuffer with a length or array-like value,
// which a typed array constructor would use to allocate a new buffer.
// The native getters check internal slots, so they work across realms
// and cannot be fooled by a forged prototype or Symbol.toStringTag.
try {
array_buffer_byte_length.call(buffer);
} catch (error) {
if (!shared_array_buffer_byte_length) throw error;
shared_array_buffer_byte_length.call(buffer);
}
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 = /* @__PURE__ */ Object.freeze(parse_operations);
+312
View File
@@ -0,0 +1,312 @@
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';
function invalid() {
throw new Error('Invalid input');
}
/**
* 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) {
invalid();
}
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 get_raw(index) {
if (!is_valid_array_index(index) || index >= values.length) {
invalid();
}
return values[index];
}
/**
* @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') {
invalid();
}
if (index in hydrated) return hydrated[index];
if (index >= values.length) {
invalid();
}
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];
// Only a primitive can be boxed. The negative sentinels below are the
// ones `stringify` emits for boxed numbers; every other sentinel
// (`UNDEFINED`, `HOLE`, `SPARSE`) would box `undefined` into a plain
// `{}`, which is not a value `stringify` can produce.
const is_boxable_sentinel =
wrapped_index === NAN ||
wrapped_index === POSITIVE_INFINITY ||
wrapped_index === NEGATIVE_INFINITY ||
wrapped_index === NEGATIVE_ZERO;
if (!is_boxable_sentinel) {
const wrapped = get_raw(wrapped_index);
const is_bigint = Array.isArray(wrapped) && wrapped[0] === 'BigInt';
if ((wrapped === undefined || typeof wrapped === 'object') && !is_bigint) {
// avoid infinite recusion in case of malformed input
invalid();
}
}
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) {
const key = value[i];
if (typeof key !== 'string') {
throw new Error('Cannot parse an object with a non-string key');
}
if (key === '__proto__') {
throw new Error('Cannot parse an object with a `__proto__` property');
}
ops.set(obj, key, 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': {
const buffer_index = value[1];
const raw = get_raw(buffer_index);
if (!Array.isArray(raw) || raw[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)`
invalid();
}
const buffer = hydrate(buffer_index);
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)) {
invalid();
}
// `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) {
invalid();
}
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);
}
+438
View File
@@ -0,0 +1,438 @@
import { DevalueError, MAP_KEY, format_path, quote_key, stringify_string } from './utils.js';
import {
HOLE,
NAN,
NEGATIVE_INFINITY,
NEGATIVE_ZERO,
POSITIVE_INFINITY,
SPARSE,
UNDEFINED
} from './constants.js';
import { encode64 } from './base64.js';
import { default_stringify_operations, merge_operations } from './operations.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]
* @param {import('./types.js').StringifyOptions} [options]
*/
export function stringify(value, reducers, options) {
const stringified = run(false, value, reducers, options);
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]
* @param {import('./types.js').StringifyOptions} [options]
*/
export async function stringifyAsync(value, reducers, options) {
const stringified = run(true, value, reducers, options);
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]
* @param {import('./types.js').StringifyOptions} [options]
*/
function run(async, value, reducers, options) {
const ops = merge_operations(default_stringify_operations, options?.operations);
/** @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] });
}
}
// the path to the value being serialized, recorded as-is and only
// formatted by `error` — see `format_path`
/** @type {any[]} */
let keys = [];
let p = 0;
/**
* @param {string} message
* @param {any} thing
* @returns {never}
*/
function error(message, thing) {
const path = format_path(keys, (key) => {
const key_type = ops.typeOf(key);
const key_is_primitive =
key_type !== 'object' && key_type !== 'function' && key_type !== 'symbol';
return key_is_primitive ? stringify_primitive(ops.toPrimitive(key)) : '...';
});
throw new DevalueError(message, path, thing, value);
}
/**
* @param {any} thing
* @param {number} [index]
*/
function flatten(thing, index) {
const type = ops.typeOf(thing);
if (type === 'undefined') return UNDEFINED;
/** @type {number | undefined} */
let number;
// `ops.toPrimitive` is the boundary between the value being serialized and
// plain host JavaScript: everything below operates on the extracted host
// primitive, so native comparisons and arithmetic are correct there.
if (type === 'number') {
number = /** @type {number} */ (ops.toPrimitive(thing));
if (Number.isNaN(number)) return NAN;
if (number === Infinity) return POSITIVE_INFINITY;
if (number === -Infinity) return NEGATIVE_INFINITY;
if (number === 0 && 1 / number < 0) return NEGATIVE_ZERO;
}
const id = ops.identify(thing);
if (indexes.has(id)) return /** @type {number} */ (indexes.get(id));
index ??= p++;
indexes.set(id, index);
for (const { key, fn } of custom) {
const value = fn(thing);
if (value) {
stringified[index] = `["${key}",${flatten(value)}]`;
return index;
}
}
if (type === 'function') {
error(`Cannot stringify a function`, thing);
} else if (type === 'symbol') {
error(`Cannot stringify a Symbol primitive`, thing);
}
/** @type {string | Promise<any>} */
let str = '';
if (type !== 'object') {
str = stringify_primitive(type === 'number' ? number : ops.toPrimitive(thing));
} else if (ops.isThenable(thing)) {
if (!async) {
error(`Cannot stringify a Promise or thenable — use stringifyAsync instead`, thing);
}
// the callback runs after the synchronous walk has unwound `keys`,
// so the path to the thenable has to be captured and reinstated
const prev_keys = keys.slice();
str = ops.toPromise(thing).then((value) => {
keys = prev_keys;
const i = flatten(value, index);
if (i < 0) stringified[index] = i;
});
// Promises are awaited sequentially, and traversal may throw before
// reaching them. Handle rejections immediately, but keep the original
// promise so awaiting it still propagates the error to the caller.
str.catch(() => {});
} else {
const tag = ops.tagOf(thing);
switch (tag) {
case 'Number':
case 'String':
case 'Boolean':
case 'BigInt':
str = `["Object",${flatten(ops.unbox(thing))}]`;
break;
case 'Date':
str = `["Date","${ops.toISOString(thing)}"]`;
break;
case 'URL':
str = `["URL",${stringify_string(ops.toStringValue(thing))}]`;
break;
case 'URLSearchParams':
str = `["URLSearchParams",${stringify_string(ops.toStringValue(thing))}]`;
break;
case 'RegExp':
const { source, flags } = ops.regExpInfo(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;
const length = ops.lengthOf(thing);
str = '[';
for (let i = 0; i < length; i += 1) {
if (i > 0) str += ',';
if (ops.hasOwn(thing, i)) {
keys.push(i);
str += flatten(ops.get(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 = ops.indicesOf(thing);
const population = populated_keys.length;
const d = String(length).length;
const hole_cost = (length - population) * 3;
const sparse_cost = 4 + d + population * (d + 1);
if (hole_cost > sparse_cost) {
str = '[' + SPARSE + ',' + length;
for (let j = 0; j < populated_keys.length; j++) {
const key = populated_keys[j];
keys.push(+key);
str += ',' + key + ',' + flatten(ops.get(thing, key));
keys.pop();
}
break;
} else {
mostly_dense = true;
str += HOLE;
}
}
}
str += ']';
break;
}
case 'Set':
str = '["Set"';
for (const value of ops.valuesOf(thing)) {
str += `,${flatten(value)}`;
}
str += ']';
break;
case 'Map':
str = '["Map"';
for (const [key, value] of ops.entriesOf(thing)) {
keys.push(MAP_KEY, key);
str += `,${flatten(key)},${flatten(value)}`;
keys.pop();
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': {
const info = ops.viewInfo(thing);
str = '["' + tag + '",' + flatten(info.buffer);
// handle subarrays
if (info.byteLength !== info.bufferByteLength) {
str += `,${info.byteOffset},${info.length}`;
}
str += ']';
break;
}
case 'DataView': {
const info = ops.viewInfo(thing);
str = '["' + tag + '",' + flatten(info.buffer);
if (info.byteLength !== info.bufferByteLength) {
str += `,${info.byteOffset},${info.byteLength}`;
}
str += ']';
break;
}
case 'ArrayBuffer': {
const base64 = encode64(ops.toArrayBuffer(thing));
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 = `["${tag}",${stringify_string(ops.toStringValue(thing))}]`;
break;
default: {
const shape = ops.shapeOf(thing);
if (shape.kind === 'not-plain') {
error(`Cannot stringify arbitrary non-POJOs`, thing);
}
if (shape.kind === 'symbol-keys') {
error(`Cannot stringify POJOs with symbolic keys`, thing);
}
if (shape.kind === 'null-proto') {
str = '["null"';
for (const key of shape.keys) {
if (key === '__proto__') {
error(`Cannot stringify objects with __proto__ keys`, thing);
}
keys.push(key);
str += `,${quote_key(key)},${flatten(ops.get(thing, key))}`;
keys.pop();
}
str += ']';
} else {
str = '{';
let started = false;
for (const key of shape.keys) {
if (key === '__proto__') {
error(`Cannot stringify objects with __proto__ keys`, thing);
}
if (started) str += ',';
started = true;
keys.push(key);
str += `${quote_key(key)}:${flatten(ops.get(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);
}
+623
View File
@@ -0,0 +1,623 @@
/**
* @import { UnevalReplacer } from './types';
*/
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_key,
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;
}
+297
View File
@@ -0,0 +1,297 @@
import { MAX_ARRAY_INDEX, MAX_ARRAY_LEN } from './constants.js';
const name_chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_$';
const reserved_names =
/^(?:do|if|in|for|int|let|new|try|var|byte|case|char|else|enum|goto|long|this|void|with|await|break|catch|class|const|final|float|short|super|throw|while|yield|delete|double|export|import|native|return|switch|throws|typeof|boolean|default|extends|finally|package|private|abstract|continue|debugger|function|volatile|interface|protected|transient|implements|instanceof|synchronized)$/;
/** @type {Record<string, string>} */
export const escaped = {
'<': '\\u003C',
'\\': '\\\\',
'\b': '\\b',
'\f': '\\f',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
'\u2028': '\\u2028',
'\u2029': '\\u2029'
};
export class DevalueError extends Error {
/**
* @param {string} message
* @param {string[]} keys
* @param {any} [value] - The value that failed to be serialized
* @param {any} [root] - The root value being serialized
*/
constructor(message, keys, value, root) {
super(message);
this.name = 'DevalueError';
this.path = keys.join('');
this.value = value;
this.root = root;
}
}
/**
* Marks a Map key on the path being serialized. The key itself is recorded
* right after the marker, so nothing is formatted or allocated per entry.
*/
export const MAP_KEY = Symbol();
/**
* Formats the path recorded on the way to the value being serialized — a
* property key, an array index, or `MAP_KEY` followed by a Map key — into
* the segments a `DevalueError` joins. Only runs when an error is raised.
* @param {any[]} keys
* @param {(key: any) => string} format_map_key
* @returns {string[]}
*/
export function format_path(keys, format_map_key) {
/** @type {string[]} */
const path = [];
for (let i = 0; i < keys.length; i += 1) {
const segment = keys[i];
if (segment === MAP_KEY) {
path.push(`.get(${format_map_key(keys[++i])})`);
} else if (typeof segment === 'number') {
path.push(`[${segment}]`);
} else {
path.push(stringify_key(segment));
}
}
return path;
}
/** @param {any} thing */
export function is_primitive(thing) {
return thing === null || (typeof thing !== 'object' && typeof thing !== 'function');
}
const object_proto_names = /* @__PURE__ */ Object.getOwnPropertyNames(Object.prototype)
.sort()
.join('\0');
/** @param {any} thing */
export function is_plain_object(thing) {
const proto = Object.getPrototypeOf(thing);
return (
proto === Object.prototype ||
proto === null ||
Object.getPrototypeOf(proto) === null ||
Object.getOwnPropertyNames(proto).sort().join('\0') === object_proto_names
);
}
/** @param {any} thing */
export function get_type(thing) {
return Object.prototype.toString.call(thing).slice(8, -1);
}
/** @param {any} thing */
export function is_buffer(thing) {
return typeof Buffer !== 'undefined' && Buffer.isBuffer(thing);
}
/**
* Emit an array whose storage is not proportional to its declared length.
* Touching and deleting the largest valid index forces dictionary elements
* before setting the length; assigning .length on [] can still eagerly allocate.
* @param {number} length
*/
export function stringify_sparse_array(length) {
return `(function(a){a[${MAX_ARRAY_INDEX}]=0;delete a[${MAX_ARRAY_INDEX}];a.length=${length};return a}([]))`;
}
/** Returns the compact JavaScript identifier at `index`. @param {number} index */
export function get_name(index) {
let name = '';
do {
name = name_chars[index % name_chars.length] + name;
index = ~~(index / name_chars.length) - 1;
} while (index >= 0);
return reserved_names.test(name) ? `${name}0` : name;
}
/** @param {string} char */
function get_escaped_char(char) {
switch (char) {
case '"':
return '\\"';
case '<':
return '\\u003C';
case '\\':
return '\\\\';
case '\n':
return '\\n';
case '\r':
return '\\r';
case '\t':
return '\\t';
case '\b':
return '\\b';
case '\f':
return '\\f';
case '\u2028':
return '\\u2028';
case '\u2029':
return '\\u2029';
default:
return char < ' ' ? `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}` : '';
}
}
// characters `stringify_string` always escapes: quote, backslash, `<`, control
// characters and the line/paragraph separators. Surrogates are only escaped
// when unpaired, which `isWellFormed` detects natively, so well-formed pairs
// such as emoji stay on the fast path
const needs_escape = /["<\\\u0000-\u001f\u2028\u2029]/;
/** @param {string} str */
export function stringify_string(str) {
// the common case: nothing to escape, no per-character work
if (!needs_escape.test(str) && str.isWellFormed()) return `"${str}"`;
let result = '';
let last_pos = 0;
const len = str.length;
for (let i = 0; i < len; i += 1) {
const code = str.charCodeAt(i);
if (code >= 0xd800 && code <= 0xdfff) {
// A well-formed surrogate pair passes through untouched; an unpaired
// surrogate cannot survive UTF-8 transport, so it is escaped.
if (code <= 0xdbff && i + 1 < len && (str.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {
i += 1;
continue;
}
result += str.slice(last_pos, i) + `\\u${code.toString(16)}`;
last_pos = i + 1;
continue;
}
const replacement = get_escaped_char(str[i]);
if (replacement) {
result += str.slice(last_pos, i) + replacement;
last_pos = i + 1;
}
}
return `"${last_pos === 0 ? str : result + str.slice(last_pos)}"`;
}
// Property names repeat across the values of one call and across calls, so
// their quoted form is cached. Names come from the data being serialized, so
// the cache is bounded in both directions: only short names are kept, and the
// cache is emptied once it holds 1024 of them. Longer names are quoted on
// every visit, like every other string.
const MAX_CACHED_KEY_LENGTH = 64;
/** @type {Map<string, string>} */
const quoted_keys = new Map();
/** @param {string} key */
export function quote_key(key) {
if (key.length > MAX_CACHED_KEY_LENGTH) return stringify_string(key);
let quoted = quoted_keys.get(key);
if (quoted === undefined) {
if (quoted_keys.size >= 1024) quoted_keys.clear();
quoted = stringify_string(key);
quoted_keys.set(key, quoted);
}
return quoted;
}
/** @param {Record<string | symbol, any>} object */
export function enumerable_symbols(object) {
const symbols = Object.getOwnPropertySymbols(object);
// Own symbols always have a descriptor; the optional chain only satisfies the lib's
// `PropertyDescriptor | undefined` return type.
return symbols.length === 0
? symbols
: symbols.filter((symbol) => Object.getOwnPropertyDescriptor(object, symbol)?.enumerable);
}
const is_identifier = /^[a-zA-Z_$][a-zA-Z_$0-9]*$/;
/** @param {string} key */
export function stringify_key(key) {
return is_identifier.test(key) ? '.' + key : '[' + JSON.stringify(key) + ']';
}
/** @param {number} n */
export function is_valid_array_index(n) {
if (!Number.isInteger(n)) return false;
if (n < 0) return false;
if (n > MAX_ARRAY_INDEX) return false;
return true;
}
/** @param {number} n */
export function is_valid_array_len(n) {
if (!Number.isInteger(n)) return false;
if (n < 0) return false;
if (n > MAX_ARRAY_LEN) return false;
return true;
}
/** @param {string} s */
function is_valid_array_index_string(s) {
if (s.length === 0) return false;
if (s.length > 1 && s.charCodeAt(0) === 48) return false; // leading zero
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 48 || c > 57) return false;
}
// by this point we know it's a string of digits, but it has to be within
// the range of valid array indices
return is_valid_array_index(+s);
}
/**
* Returns the length of the leading run of valid array indices in `keys`.
* @param {readonly string[]} keys
*/
function array_index_cut(keys) {
for (var i = keys.length - 1; i >= 0; i--) {
if (is_valid_array_index_string(keys[i])) {
break;
}
}
return i + 1;
}
/**
* Finds the populated indices of an array.
* @param {unknown[]} array
*/
export function valid_array_indices(array) {
const keys = Object.keys(array);
keys.length = array_index_cut(keys);
return keys;
}
/**
* Given the own enumerable string keys of an array-like value, in property
* order, returns the leading run of them that are valid array indices.
*
* This is the filtering half of the `indicesOf` stringify operation,
* exposed so that custom operations — which typically already have the keys
* in hand, e.g. from a foreign runtime — don't have to reimplement it.
*
* Does not modify `keys`.
*
* @param {readonly string[]} keys
* @returns {string[]}
*/
export function filter_array_indices(keys) {
return keys.slice(0, array_index_cut(keys));
}