diff --git a/index.js b/index.js index 2bdd761..57ab2dc 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,3 @@ export { uneval } from './src/uneval.js'; -export { parse, unflatten } from './src/parse.js'; +export { parse, unflatten, parseAsync, unflattenAsync } from './src/parse.js'; export { stringify } from './src/stringify.js'; diff --git a/src/parse.js b/src/parse.js index 54cdeba..2a1b86b 100644 --- a/src/parse.js +++ b/src/parse.js @@ -131,3 +131,128 @@ export function unflatten(parsed, revivers) { return hydrate(0); } + +/** + * Revive a value serialized with `devalue.stringify` asynchronously + * @param { string } serialized + * @param {Record Promise>} [revivers] + */ +export async function parseAsync(serialized, revivers) { + return await unflattenAsync(JSON.parse(serialized), revivers); +} + +/** + * Revive a value flattened with `devalue.stringify` asynchronously + * @param {number | any[]} parsed + * @param {Record Promise>} [revivers] + */ +export async function unflattenAsync(parsed, revivers) { + if (typeof parsed === 'number') return await hydrate(parsed, true); + + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error('Invalid input'); + } + + const values = /** @type {any[]} */ (parsed); + + const hydrated = Array(values.length); + + /** + * @param {number} index + * @returns {Promise} + */ + async function hydrate(index, standalone = false) { + if (index === UNDEFINED) return undefined; + if (index === NAN) return NaN; + if (index === POSITIVE_INFINITY) return Infinity; + if (index === NEGATIVE_INFINITY) return -Infinity; + if (index === NEGATIVE_ZERO) return -0; + + if (standalone) throw new Error(`Invalid input`); + + if (index in hydrated) return hydrated[index]; + + const value = values[index]; + + if (!value || typeof value !== 'object') { + hydrated[index] = value; + } else if (Array.isArray(value)) { + if (typeof value[0] === 'string') { + const type = value[0]; + + const reviver = revivers?.[type]; + if (reviver) { + return (hydrated[index] = await reviver(await hydrate(value[1]))); + } + + switch (type) { + case 'Date': + hydrated[index] = new Date(value[1]); + break; + + case 'Set': + const set = new Set(); + hydrated[index] = set; + for (let i = 1; i < value.length; i += 1) { + set.add(await hydrate(value[i])); + } + break; + + case 'Map': + const map = new Map(); + hydrated[index] = map; + for (let i = 1; i < value.length; i += 2) { + map.set(await hydrate(value[i]), await hydrate(value[i + 1])); + } + break; + + case 'RegExp': + hydrated[index] = new RegExp(value[1], value[2]); + break; + + case 'Object': + hydrated[index] = Object(value[1]); + break; + + case 'BigInt': + hydrated[index] = BigInt(value[1]); + break; + + case 'null': + const obj = Object.create(null); + hydrated[index] = obj; + for (let i = 1; i < value.length; i += 2) { + obj[value[i]] = await hydrate(value[i + 1]); + } + break; + + default: + throw new Error(`Unknown type ${type}`); + } + } else { + const array = new Array(value.length); + hydrated[index] = array; + + for (let i = 0; i < value.length; i += 1) { + const n = value[i]; + if (n === HOLE) continue; + + array[i] = await hydrate(n); + } + } + } else { + /** @type {Record} */ + const object = {}; + hydrated[index] = object; + + for (const key in value) { + const n = value[key]; + object[key] = await hydrate(n); + } + } + + return hydrated[index]; + } + + return await hydrate(0); +} diff --git a/test/test.js b/test/test.js index afe3557..8205b0f 100644 --- a/test/test.js +++ b/test/test.js @@ -1,7 +1,7 @@ import * as vm from 'vm'; import * as assert from 'uvu/assert'; import * as uvu from 'uvu'; -import { uneval, unflatten, parse, stringify } from '../index.js'; +import { uneval, unflatten, parse, stringify, unflattenAsync, parseAsync } from '../index.js'; class Custom { constructor(value) { @@ -399,6 +399,9 @@ const fixtures = { revivers: { Custom: (x) => new Custom(x) }, + asyncRevivers: { + Custom: async (x) => new Custom(x) + }, validate: ([obj1, obj2]) => { assert.is(obj1, obj2); assert.ok(obj1 instanceof Custom); @@ -439,6 +442,16 @@ for (const [name, tests] of Object.entries(fixtures)) { const actual = parse(t.json, t.revivers); const expected = t.value; + if (t.validate) { + t.validate(actual); + } else { + assert.equal(actual, expected); + } + }); + test(`${t.name} (async)`, async () => { + const actual = await parseAsync(t.json, t.asyncRevivers); + const expected = t.value; + if (t.validate) { t.validate(actual); } else { @@ -456,6 +469,16 @@ for (const [name, tests] of Object.entries(fixtures)) { const actual = unflatten(JSON.parse(t.json), t.revivers); const expected = t.value; + if (t.validate) { + t.validate(actual); + } else { + assert.equal(actual, expected); + } + }); + test(`${t.name} (async)`, async () => { + const actual = await unflattenAsync(JSON.parse(t.json), t.asyncRevivers); + const expected = t.value; + if (t.validate) { t.validate(actual); } else { @@ -514,6 +537,7 @@ const invalid = [ } ]; +// SYNC for (const { name, json, message } of invalid) { uvu.test(`parse error: ${name}`, () => { assert.throws( @@ -522,6 +546,18 @@ for (const { name, json, message } of invalid) { ); }); } +// ASYNC +for (const { name, json, message } of invalid) { + uvu.test(`parseAsync error: ${name}`, async () => { + try { + await parseAsync(json); + assert.unreachable('Expected parseAsync to throw') + } + catch (error) { + assert.equal(error.message, message); + } + }); +} for (const fn of [uneval, stringify]) { uvu.test(`${fn.name} throws for non-POJOs`, () => {