first commit
This commit is contained in:
+64
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user