diff --git a/src/createTsonAsync.ts b/src/createTsonAsync.ts index ffaf6f7c..32b0f9de 100644 --- a/src/createTsonAsync.ts +++ b/src/createTsonAsync.ts @@ -1,3 +1,6 @@ +import { createAsyncTsonSerializer } from "./serializeAsync.js"; import { TsonAsyncOptions } from "./types.js"; -export const createTsonAsync = (opts: TsonAsyncOptions) => ({}); +export const createTsonAsync = (opts: TsonAsyncOptions) => ({ + serializeAsync: createAsyncTsonSerializer(opts), +}); diff --git a/src/errors.ts b/src/errors.ts index 00872124..352e654e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,11 +1,13 @@ export class TsonError extends Error { constructor(message: string, opts?: ErrorOptions) { super(message, opts); - this.name = this.constructor.name; + this.name = "TsonError"; + + // set prototype } } -export class CircularReferenceError extends TsonError { +export class TsonCircularReferenceError extends TsonError { /** * The circular reference that was found */ @@ -13,14 +15,14 @@ export class CircularReferenceError extends TsonError { constructor(value: unknown) { super(`Circular reference detected`); - this.name = this.constructor.name; + this.name = "TsonCircularReferenceError"; this.value = value; } } -export class PromiseRejectionError extends TsonError { +export class TsonPromiseRejectionError extends TsonError { constructor(cause: unknown) { super(`Promise rejected`, { cause }); - this.name = this.constructor.name; + this.name = "TsonPromiseRejectionError"; } } diff --git a/src/handlers/tsonPromise.test.ts b/src/handlers/tsonPromise.test.ts index b34d28af..c32e2ad0 100644 --- a/src/handlers/tsonPromise.test.ts +++ b/src/handlers/tsonPromise.test.ts @@ -1,14 +1,146 @@ -import { test } from "vitest"; +import { expect, test } from "vitest"; -import { createTson, createTsonAsync, tsonPromise } from "../index.js"; +import { createTsonAsync, tsonPromise } from "../index.js"; -test("tsonPromise", async () => { +const createPromise = (result: () => T) => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(result()); + }, 1); + }); +}; + +test("serialize promise", async () => { const tson = createTsonAsync({ + nonce: () => "__tson", types: [tsonPromise], }); const promise = Promise.resolve(42); - const serialized = tson.stringify(promise); - const deserialized = tson.parse(serialized); + const [head, iterator] = tson.serializeAsync(promise); + + expect(head).toMatchInlineSnapshot(` + { + "json": [ + "Promise", + 0, + "__tson", + ], + "nonce": "__tson", + } + `); + + const values = []; + for await (const value of iterator) { + values.push(value); + } + + expect(values).toMatchInlineSnapshot(` + [ + [ + 0, + 0, + 42, + ], + ] + `); +}); + +test("serialize promise that returns a promise", async () => { + const tson = createTsonAsync({ + nonce: () => "__tson", + types: [tsonPromise], + }); + + const obj = { + promise: createPromise(() => { + return { + anotherPromise: createPromise(() => { + return 42; + }), + }; + }), + }; + + const [head, iterator] = tson.serializeAsync(obj); + + expect(head).toMatchInlineSnapshot(` + { + "json": { + "promise": [ + "Promise", + 0, + "__tson", + ], + }, + "nonce": "__tson", + } + `); + + const values = []; + for await (const value of iterator) { + values.push(value); + } + + expect(values).toHaveLength(2); + + expect(values).toMatchInlineSnapshot(` + [ + [ + 0, + 0, + { + "anotherPromise": [ + "Promise", + 1, + "__tson", + ], + }, + ], + [ + 1, + 0, + 42, + ], + ] + `); +}); + +test("promise that rejects", async () => { + const tson = createTsonAsync({ + nonce: () => "__tson", + types: [tsonPromise], + }); + + const promise = Promise.reject(new Error("foo")); + + const [head, iterator] = tson.serializeAsync(promise); + + expect(head).toMatchInlineSnapshot(` + { + "json": [ + "Promise", + 0, + "__tson", + ], + "nonce": "__tson", + } + `); + + const values = []; + + for await (const value of iterator) { + values.push(value); + } + + expect(values).toMatchInlineSnapshot(` + [ + [ + 0, + 1, + [TsonPromiseRejectionError: Promise rejected], + ], + ] + `); }); diff --git a/src/serialize.ts b/src/serialize.ts index 81cdc913..4cb06569 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any, eslint-comments/disable-enable-pair */ -import { CircularReferenceError } from "./errors.js"; +import { TsonCircularReferenceError } from "./errors.js"; import { GetNonce, getNonce } from "./internals/getNonce.js"; import { mapOrReturn } from "./internals/mapOrReturn.js"; import { @@ -85,7 +85,7 @@ export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { if (seen.has(value)) { const cached = cache.get(value); if (!cached) { - throw new CircularReferenceError(value); + throw new TsonCircularReferenceError(value); } return cached; diff --git a/src/serializeAsync.ts b/src/serializeAsync.ts index d1f68483..caaec0d1 100644 --- a/src/serializeAsync.ts +++ b/src/serializeAsync.ts @@ -1,4 +1,7 @@ -import { CircularReferenceError, PromiseRejectionError } from "./errors.js"; +import { + TsonCircularReferenceError, + TsonPromiseRejectionError, +} from "./errors.js"; import { getNonce } from "./internals/getNonce.js"; import { mapOrReturn } from "./internals/mapOrReturn.js"; import { @@ -6,6 +9,7 @@ import { TsonAsyncIndex, TsonAsyncOptions, TsonNonce, + TsonSerialized, TsonSerializedValue, TsonTuple, TsonTypeHandlerKey, @@ -14,7 +18,6 @@ import { } from "./types.js"; type WalkFn = (value: unknown) => unknown; -type WalkerFactory = (nonce: TsonNonce) => WalkFn; const PROMISE_RESOLVED = 0 as const; const PROMISE_REJECTED = 1 as const; @@ -25,23 +28,27 @@ type TsonAsyncValueTuple = [ unknown, ]; -function walkerFactory(opts: TsonAsyncOptions) { +function walkerFactory(nonce: TsonNonce, types: TsonAsyncOptions["types"]) { // instance variables let promiseIndex = 0 as TsonAsyncIndex; - const promises = new Map< - TsonAsyncIndex, - [TsonAsyncIndex, Promise] - >(); + const promises = new Map>(); const seen = new WeakSet(); const cache = new WeakMap(); - const nonce: TsonNonce = opts.nonce - ? (opts.nonce() as TsonNonce) - : getNonce(); + const iterator = { + async *[Symbol.asyncIterator]() { + while (promises.size > 0) { + const tuple = await Promise.race(promises.values()); + + promises.delete(tuple[0]); + yield walk(tuple) as typeof tuple; + } + }, + }; // helper fns function registerPromise(promise: Promise): TsonAsyncIndex { const index = promiseIndex++ as TsonAsyncIndex; - promises.set(index, [ + promises.set( index, promise .then((result) => { @@ -50,17 +57,21 @@ function walkerFactory(opts: TsonAsyncOptions) { }) // ^? .catch((err) => { - const tuple: TsonAsyncValueTuple = [index, PROMISE_REJECTED, err]; + const tuple: TsonAsyncValueTuple = [ + index, + PROMISE_REJECTED, + new TsonPromiseRejectionError(err), + ]; return tuple; }), - ]); + ); return index; } const handlers = (() => { - const types = opts.types.map((handler) => { + const all = types.map((handler) => { type Serializer = ( value: unknown, nonce: TsonNonce, @@ -79,14 +90,14 @@ function walkerFactory(opts: TsonAsyncOptions) { $serialize, }; }); - type Handler = (typeof types)[number]; + type Handler = (typeof all)[number]; const byPrimitive: Partial< Record> > = {}; const nonPrimitive: Extract[] = []; - for (const handler of types) { + for (const handler of all) { if (handler.primitive) { if (byPrimitive[handler.primitive]) { throw new Error( @@ -113,7 +124,7 @@ function walkerFactory(opts: TsonAsyncOptions) { if (seen.has(value)) { const cached = cache.get(value); if (!cached) { - throw new CircularReferenceError(value); + throw new TsonCircularReferenceError(value); } return cached; @@ -147,7 +158,28 @@ function walkerFactory(opts: TsonAsyncOptions) { return cacheAndReturn(mapOrReturn(value, walk)); }; - return walk; + return [walk, iterator] as const; } -export function createAsyncTsonSerializer(opts: TsonAsyncOptions) {} +type TsonAsyncSerializer = ( + value: T, +) => [TsonSerialized, AsyncIterable]; + +export function createAsyncTsonSerializer( + opts: TsonAsyncOptions, +): TsonAsyncSerializer { + return (value) => { + const nonce: TsonNonce = opts.nonce + ? (opts.nonce() as TsonNonce) + : getNonce(); + const [walk, iterator] = walkerFactory(nonce, opts.types); + + return [ + { + json: walk(value), + nonce, + } as TsonSerialized, + iterator, + ]; + }; +}