diff --git a/README.md b/README.md index e34276e0..fc4ee545 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,27 @@ const tson = createTson({ // Pick which types you want to support tsonSet, ], + // 🫷 Guard against unwanted values + guards: [tsonNumberGuard, tsonUnknownObjectGuard], }); +const scarryClass = new (class ScarryClass { + foo = "bar"; +})(); + +const invalidNumber = 1 / 0; + const myObj = { foo: "bar", set: new Set([1, 2, 3]), }; +tson.stringify(scarryClass); +// -> throws, since we didn't register a serializer for `ScarryClass`! + +tson.stringify(invalidNumber); +// -> throws, since we didn't register a serializer for `Infinity`! + const str = tson.stringify(myObj, 2); console.log(str); // (👀 All non-JSON values are replaced with a tuple, hence the name) diff --git a/cspell.json b/cspell.json index bb39e3ea..6c684234 100644 --- a/cspell.json +++ b/cspell.json @@ -1,5 +1,7 @@ { - "dictionaries": ["typescript"], + "dictionaries": [ + "typescript" + ], "ignorePaths": [ ".github", "CHANGELOG.md", @@ -9,6 +11,7 @@ "pnpm-lock.yaml" ], "words": [ + "asyncs", "clsx", "Codecov", "codespace", @@ -24,13 +27,17 @@ "knip", "lcov", "markdownlintignore", + "marshaller", "npmpackagejsonlintrc", "openai", "outro", "packagejson", "quickstart", + "streamified", + "streamify", "stringifier", "superjson", + "thunkable", "tson", "tsup", "tupleson", diff --git a/src/async/asyncTypes.ts b/src/async/asyncTypes.ts index 5490d866..c2c5d2c0 100644 --- a/src/async/asyncTypes.ts +++ b/src/async/asyncTypes.ts @@ -76,9 +76,5 @@ export interface TsonAsyncOptions { /** * The list of types to use */ - types: ( - | TsonAsyncType - | TsonType - | TsonType - )[]; + types: (TsonAsyncType | TsonType)[]; } diff --git a/src/async/asyncTypes2.ts b/src/async/asyncTypes2.ts new file mode 100644 index 00000000..5298e2be --- /dev/null +++ b/src/async/asyncTypes2.ts @@ -0,0 +1,152 @@ +import { + SerializedType, + TsonNonce, + TsonType, + TsonTypeTesterCustom, +} from "../sync/syncTypes.js"; +import { TsonGuard } from "../tsonAssert.js"; +import { + TsonAsyncUnfolderFactory, + createTsonAsyncUnfoldFn, +} from "./createUnfoldAsyncFn.js"; + +export const ChunkTypes = { + BODY: "BODY", + ERROR: "ERROR", + HEAD: "HEAD", + LEAF: "LEAF", + REF: "REF", + TAIL: "TAIL", +} as const; + +export type ChunkTypes = { + [key in keyof typeof ChunkTypes]: (typeof ChunkTypes)[key]; +}; + +export const TsonStatus = { + //MULTI_STATUS: 207, + ERROR: 500, + INCOMPLETE: 203, + OK: 200, +} as const; + +export type TsonStatus = { + [key in keyof typeof TsonStatus]: (typeof TsonStatus)[key]; +}; + +export const TsonStructures = { + ARRAY: 0, + ITERABLE: 2, + POJO: 1, +} as const; + +export type TsonStructures = typeof TsonStructures; + +export interface TsonAsyncChunk { + chunk: T; + key?: null | number | string | undefined; +} + +export interface TsonAsyncMarshaller< + TValue, + TSerializedType extends SerializedType, +> { + async: true; + fold: ( + iter: AsyncGenerator< + TsonAsyncChunk, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + number | undefined | void, + undefined + >, + ) => Promise>; + key: string; + unfold: ReturnType< + typeof createTsonAsyncUnfoldFn> + >; +} + +export type TsonAsyncType< + /** + * The type of the value + */ + TValue, + /** + * JSON-serializable value how it's stored after it's serialized + */ + TSerializedType extends SerializedType, +> = TsonTypeTesterCustom & TsonAsyncMarshaller; + +export interface TsonAsyncOptions { + /** + * A list of guards to apply to every value + */ + guards?: TsonGuard[]; + /** + * The nonce function every time we start serializing a new object + * Should return a unique value every time it's called + * @default `${crypto.randomUUID} if available, otherwise a random string generated by Math.random` + */ + nonce?: () => string; + /** + * The list of types to use + */ + types: (TsonAsyncType | TsonType)[]; +} + +export type TsonAsyncTupleHeader = [ + Id: `${TsonNonce}${number}`, + ParentId: `${TsonNonce}${"" | number}`, + Key?: null | number | string | undefined, +]; + +export type TsonAsyncLeafTuple = [ + ChunkType: ChunkTypes["LEAF"], + Header: TsonAsyncTupleHeader, + Value: unknown, + TypeHandlerKey?: string | undefined, +]; + +export type TsonAsyncBodyTuple = [ + ChunkType: ChunkTypes["BODY"], + Header: TsonAsyncTupleHeader, + Head: TsonAsyncHeadTuple, + TypeHandlerKey?: string | undefined, +]; + +export type TsonAsyncHeadTuple = [ + ChunkType: ChunkTypes["HEAD"], + Header: TsonAsyncTupleHeader, + TypeHandlerKey?: TsonStructures[keyof TsonStructures] | string | undefined, +]; + +export type TsonAsyncReferenceTuple = [ + ChunkType: ChunkTypes["REF"], + Header: TsonAsyncTupleHeader, + OriginalNodeId: `${TsonNonce}${number}`, +]; + +export type TsonAsyncErrorTuple = [ + ChunkType: ChunkTypes["ERROR"], + Header: TsonAsyncTupleHeader, + Error: unknown, +]; + +export type TsonAsyncTailTuple = [ + ChunkType: ChunkTypes["TAIL"], + Header: [ + Id: TsonAsyncTupleHeader[0], + ParentId: TsonAsyncTupleHeader[1], + Key?: null | undefined, + ], + StatusCode: number, +]; + +export type TsonAsyncTuple = + | TsonAsyncBodyTuple + | TsonAsyncErrorTuple + | TsonAsyncHeadTuple + | TsonAsyncLeafTuple + | TsonAsyncReferenceTuple + | TsonAsyncTailTuple; + diff --git a/src/async/createFoldAsyncFn.ts b/src/async/createFoldAsyncFn.ts new file mode 100644 index 00000000..b7c8dd16 --- /dev/null +++ b/src/async/createFoldAsyncFn.ts @@ -0,0 +1,120 @@ +import { TsonAbortError } from "./asyncErrors.js"; +import { + TsonAsyncBodyTuple, + TsonAsyncTailTuple, + TsonAsyncTuple, +} from "./asyncTypes2.js"; +import { TsonAsyncUnfoldedValue } from "./createUnfoldAsyncFn.js"; +import { MaybePromise } from "./iterableUtils.js"; + +export type TsonAsyncReducer = ( + ctx: TsonReducerCtx, +) => Promise>; + +export interface TsonAsyncReducerResult { + abort?: boolean; + accumulator: MaybePromise; + error?: any; + return?: TsonAsyncTailTuple | undefined; +} + +export type TsonAsyncFoldFn = ({ + initialAccumulator, + reduce, +}: { + initialAccumulator: TInitial; + reduce: TsonAsyncReducer; +}) => (sequence: TsonAsyncUnfoldedValue) => Promise; + +export type TsonReducerCtx = + | TsonAsyncReducerReturnCtx + | TsonAsyncReducerYieldCtx; + +export type TsonAsyncFoldFnFactory = (opts: { + initialAccumulator?: TInitial | undefined; +}) => TsonAsyncFoldFn; + +export const createTsonAsyncFoldFn = ({ + initializeAccumulator, + reduce, +}: { + initializeAccumulator: () => MaybePromise; + reduce: TsonAsyncReducer; +}) => { + //TODO: would it be better to use bigint for generator indexes? Can one imagine a request that long, with that many items? + let i = 0; + + return async function fold(sequence: TsonAsyncUnfoldedValue) { + let result: { + abort?: boolean; + accumulator: MaybePromise; + error?: any; + return?: TsonAsyncTailTuple | undefined; + } = { + accumulator: initializeAccumulator(), + }; + + let current = await sequence.next(); + + if (current.done) { + const output = await reduce({ + accumulator: await result.accumulator, + current, + key: i++, + source: sequence, + }); + + return output.accumulator; + } + + while (!current.done) { + result = await reduce({ + accumulator: await result.accumulator, + current, + key: i++, + source: sequence, + }); + + if (result.abort) { + if (result.return) { + current = await sequence.return(result.return); + } + + current = await sequence.throw(result.error); + + if (!current.done) { + throw new TsonAbortError( + "Unexpected result from `throw` in reducer: expected done", + ); + } + } else { + current = await sequence.next(); + } + } + + const output = await reduce({ + accumulator: await result.accumulator, + current, + key: i++, + source: sequence, + }); + + return output.accumulator; + }; +}; + +interface TsonAsyncReducerYieldCtx { + accumulator: TAccumulator; + current: MaybePromise< + IteratorYieldResult> + >; + key?: null | number | string | undefined; + source: TsonAsyncUnfoldedValue; +} + +interface TsonAsyncReducerReturnCtx { + accumulator: TAccumulator; + current: MaybePromise>; + key?: null | number | string | undefined; + source?: TsonAsyncUnfoldedValue | undefined; +} diff --git a/src/async/createUnfoldAsyncFn.ts b/src/async/createUnfoldAsyncFn.ts new file mode 100644 index 00000000..33703534 --- /dev/null +++ b/src/async/createUnfoldAsyncFn.ts @@ -0,0 +1,62 @@ +import { + TsonAsyncChunk, + TsonAsyncHeadTuple, + TsonAsyncLeafTuple, + TsonAsyncTailTuple, +} from "./asyncTypes2.js"; +import { MaybePromise } from "./iterableUtils.js"; + +export type TsonAsyncUnfoldedValue = AsyncGenerator< + TsonAsyncHeadTuple | TsonAsyncLeafTuple, + TsonAsyncTailTuple, + // could insert something into the generator, but that's more complexity for plugin authors + undefined +>; + +export interface TsonAsyncUnfoldFn + extends Omit { + (source: TSource): MaybePromise; +} + +export type TsonAsyncUnfolderFactory = ( + source: T, +) => + | AsyncGenerator + | AsyncIterable + | AsyncIterator; + +export function createTsonAsyncUnfoldFn< + TFactory extends TsonAsyncUnfolderFactory, +>( + factory: TFactory, +): ( + source: TFactory extends TsonAsyncUnfolderFactory ? TSource + : never, +) => AsyncGenerator< + TsonAsyncChunk, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + number | undefined | void, + // could insert something into the generator, but that's more complexity for plugin authors + undefined +> { + return async function* unfold(source) { + const unfolder = factory(source); + const iterator = + Symbol.asyncIterator in unfolder ? + unfolder[Symbol.asyncIterator]() + : unfolder; + + let nextResult = await iterator.next(); + + while (!nextResult.done) { + yield nextResult.value; + nextResult = await iterator.next(); + } + + return ( + typeof nextResult.value === "number" ? nextResult.value + : nextResult.value instanceof Error ? 500 + : 200 + ); + }; +} diff --git a/src/async/deserializeAsync.ts b/src/async/deserializeAsync.ts index 68ddd842..c13b1b57 100644 --- a/src/async/deserializeAsync.ts +++ b/src/async/deserializeAsync.ts @@ -6,9 +6,9 @@ import { assert } from "../internals/assert.js"; import { isTsonTuple } from "../internals/isTsonTuple.js"; import { mapOrReturn } from "../internals/mapOrReturn.js"; import { + TsonMarshaller, TsonNonce, TsonSerialized, - TsonTransformerSerializeDeserialize, } from "../sync/syncTypes.js"; import { TsonAbortError, TsonStreamInterruptedError } from "./asyncErrors.js"; import { @@ -27,9 +27,7 @@ import { TsonAsyncValueTuple } from "./serializeAsync.js"; type WalkFn = (value: unknown) => unknown; type WalkerFactory = (nonce: TsonNonce) => WalkFn; -type AnyTsonTransformerSerializeDeserialize = - | TsonAsyncType - | TsonTransformerSerializeDeserialize; +type AnyTsonMarshaller = TsonAsyncType | TsonMarshaller; export interface TsonParseAsyncOptions { /** @@ -56,7 +54,7 @@ type TsonParseAsync = ( type TsonDeserializeIterableValue = TsonAsyncValueTuple | TsonSerialized; type TsonDeserializeIterable = AsyncIterable; function createTsonDeserializer(opts: TsonAsyncOptions) { - const typeByKey: Record = {}; + const typeByKey: Record = {}; for (const handler of opts.types) { if (handler.key) { @@ -64,8 +62,7 @@ function createTsonDeserializer(opts: TsonAsyncOptions) { throw new Error(`Multiple handlers for key ${handler.key} found`); } - typeByKey[handler.key] = - handler as AnyTsonTransformerSerializeDeserialize; + typeByKey[handler.key] = handler as AnyTsonMarshaller; } } diff --git a/src/async/handlers/tsonPromise2.test.ts b/src/async/handlers/tsonPromise2.test.ts new file mode 100644 index 00000000..7c990f2e --- /dev/null +++ b/src/async/handlers/tsonPromise2.test.ts @@ -0,0 +1,244 @@ +import { expect, test } from "vitest"; + +import { TsonType } from "../../index.js"; +import { createPromise, expectSequence } from "../../internals/testUtils.js"; +import { ChunkTypes, TsonAsyncTuple, TsonStatus } from "../asyncTypes2.js"; +import { createTsonSerializeAsync } from "../serializeAsync2.js"; +import { tsonPromise } from "./tsonPromise2.js"; + +const nonce = "__tson"; +const anyId = expect.stringMatching(`^${nonce}[0-9]+$`); +const idOf = (id: TsonAsyncTuple | number | string) => { + if (Array.isArray(id)) { + return id[1][0]; + } + + return `${nonce}${id}`; +}; + +const tsonError: TsonType = { + deserialize: (v) => { + const err = new Error(v.message); + return err; + }, + key: "Error", + serialize: (v) => ({ + message: v.message, + }), + test: (v): v is Error => v instanceof Error, +}; + +test("serialize promise", async () => { + const serialize = createTsonSerializeAsync({ + nonce: () => nonce, + types: [tsonPromise], + }); + + const promise = Promise.resolve(42); + const iterator = serialize(promise); + + const chunks: TsonAsyncTuple[] = []; + for await (const value of iterator) { + chunks.push(value); + } + + const heads = chunks.filter((chunk) => chunk[0] === ChunkTypes.HEAD); + const tails = chunks.filter((chunk) => chunk[0] === ChunkTypes.TAIL); + const leaves = chunks.filter((chunk) => chunk[0] === ChunkTypes.LEAF); + + expect(chunks.length).toBe(6); + expect(heads).toHaveLength(2); + expect(tails).toHaveLength(2); + expect(leaves).toHaveLength(2); + + expectSequence(chunks) + .toHaveAll(heads) + .beforeAll([...leaves, ...tails]); + + heads.forEach((_, i) => { + expectSequence(chunks).toHave(heads[i]!).beforeAll([tails[i]!, leaves[i]!]); + expectSequence(chunks).toHave(tails[i]!).afterAll([heads[i]!, leaves[i]!]); + }); +}); + +test("serialize promise that returns a promise", async () => { + const serialize = createTsonSerializeAsync({ + nonce: () => nonce, + types: [tsonPromise], + }); + + const expected = 42; + + const obj = { + promise: createPromise(() => { + return { + anotherPromise: createPromise(() => { + return expected; + }), + }; + }), + }; + + const iterator = serialize(obj); + const chunks: TsonAsyncTuple[] = []; + + for await (const value of iterator) { + chunks.push(value); + } + + const heads = chunks.filter((chunk) => chunk[0] === ChunkTypes.HEAD); + const tails = chunks.filter((chunk) => chunk[0] === ChunkTypes.TAIL); + const leaves = chunks.filter((chunk) => chunk[0] === ChunkTypes.LEAF); + + expect(chunks).toHaveLength(15); + expect(heads).toHaveLength(6); + expect(leaves).toHaveLength(3); + expect(tails).toHaveLength(6); + + heads.forEach((_, i) => { + expect(tails.filter((v) => v[1][1] === heads[i]![1][0])).toHaveLength(1); + expectSequence(chunks) + .toHave(heads[i]!) + .beforeAll( + [...tails, ...leaves].filter((v) => v[1][1] === heads[i]![1][0]), + ); + expectSequence(chunks) + .toHave(tails[i]!) + .afterAll( + [...heads, ...leaves].filter((v) => v[1][1] === tails[i]![1][0]), + ); + }); + + expect(heads[0]![1][0]).toBe(idOf(0)); + + expect(heads).toHaveLength(6); + expect(leaves).toHaveLength(3); + expect(tails).toHaveLength(6); + + expect(heads[0]).toStrictEqual([ChunkTypes.HEAD, [idOf(0), nonce, null], 1]); + expect(heads[1]).toStrictEqual([ + ChunkTypes.HEAD, + [anyId, idOf(0), "promise"], + tsonPromise.key, + ]); + + expect(heads[2]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, null], 0]); + expect(heads[3]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, 1], 1]); + expect(heads[4]).toStrictEqual([ + ChunkTypes.HEAD, + [anyId, anyId, "anotherPromise"], + tsonPromise.key, + ]); + expect(heads[5]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, null], 0]); +}); + +test("promise that rejects", async () => { + const serialize = createTsonSerializeAsync({ + nonce: () => nonce, + types: [tsonPromise, tsonError], + }); + + const promise = Promise.reject(new Error("foo")); + const iterator = serialize(promise); + + const chunks: TsonAsyncTuple[] = []; + const expected = { message: "foo" }; + + for await (const value of iterator) { + chunks.push(value); + } + + expect(chunks.length).toBe(6); + + const heads = chunks.filter((chunk) => chunk[0] === ChunkTypes.HEAD); + const tails = chunks.filter((chunk) => chunk[0] === ChunkTypes.TAIL); + const leaves = chunks.filter((chunk) => chunk[0] === ChunkTypes.LEAF); + + expectSequence(chunks) + .toHaveAll(heads) + .beforeAll([...leaves, ...tails]); + + heads.forEach((_, i) => { + expect(tails.filter((v) => v[1][1] === heads[i]![1][0])).toHaveLength(1); + expectSequence(chunks) + .toHave(heads[i]!) + .beforeAll( + [...tails, ...leaves].filter((v) => v[1][1] === heads[i]![1][0]), + ); + expectSequence(chunks) + .toHave(tails[i]!) + .afterAll( + [...heads, ...leaves].filter((v) => v[1][1] === tails[i]![1][0]), + ); + }); + + expect(heads[0]![1][0]).toBe(idOf(0)); + + expect(heads).toHaveLength(2); + expect(tails).toHaveLength(2); + expect(leaves).toHaveLength(2); + + expect(heads[0]).toStrictEqual([ + ChunkTypes.HEAD, + [idOf(0), nonce, null], + tsonPromise.key, + ]); + + expect(heads[1]).toEqual([ChunkTypes.HEAD, [anyId, idOf(0), null], 0]); + expect(leaves[0]).toEqual([ChunkTypes.LEAF, [anyId, anyId, 0], 1]); + expect(leaves[1]).toEqual([ + ChunkTypes.LEAF, + [anyId, anyId, 1], + expected, + tsonError.key, + ]); +}); + +test("racing promises", async () => { + const serialize = createTsonSerializeAsync({ + nonce: () => nonce, + types: [tsonPromise], + }); + + const iterator = serialize({ + promise: createPromise(() => { + return { + promise1: createPromise(() => { + return 42; + }, Math.random() * 100), + promise2: createPromise(() => { + return 43; + }, Math.random() * 100), + }; + }), + }); + + const chunks: TsonAsyncTuple[] = []; + + for await (const value of iterator) { + chunks.push(value); + } + + const heads = chunks.filter((chunk) => chunk[0] === ChunkTypes.HEAD); + const leaves = chunks.filter((chunk) => chunk[0] === ChunkTypes.LEAF); + const tails = chunks.filter((chunk) => chunk[0] === ChunkTypes.TAIL); + + expect(chunks).toHaveLength(21); + expect(heads).toHaveLength(8); + expect(leaves).toHaveLength(5); + expect(tails).toHaveLength(8); + + heads.forEach((_, i) => { + expect(tails.filter((v) => v[1][1] === heads[i]![1][0])).toHaveLength(1); + expectSequence(chunks) + .toHave(heads[i]!) + .beforeAll( + [...tails, ...leaves].filter((v) => v[1][1] === heads[i]![1][0]), + ); + expectSequence(chunks) + .toHave(tails[i]!) + .afterAll( + [...heads, ...leaves].filter((v) => v[1][1] === tails[i]![1][0]), + ); + }); +}); diff --git a/src/async/handlers/tsonPromise2.ts b/src/async/handlers/tsonPromise2.ts new file mode 100644 index 00000000..0ee840fd --- /dev/null +++ b/src/async/handlers/tsonPromise2.ts @@ -0,0 +1,61 @@ +import { + TsonPromiseRejectionError, + TsonStreamInterruptedError, +} from "../asyncErrors.js"; +import { TsonAsyncType } from "../asyncTypes2.js"; + +function isPromise(value: unknown): value is Promise { + return ( + !!value && + typeof value === "object" && + "then" in value && + typeof (value as any).catch === "function" + ); +} + +const PROMISE_RESOLVED = 0; +const PROMISE_REJECTED = 1; + +type SerializedPromiseValue = + | [typeof PROMISE_REJECTED, unknown] + | [typeof PROMISE_RESOLVED, unknown]; + +export const tsonPromise: TsonAsyncType< + Promise, + SerializedPromiseValue +> = { + async: true, + fold: async function (iter) { + const result = await iter.next(); + if (result.done) { + throw new TsonStreamInterruptedError("Expected promise value, got done"); + } + + const value = result.value.chunk; + const [status, resultValue] = value; + + if (status === PROMISE_RESOLVED) { + return resultValue; + } + + throw TsonPromiseRejectionError.from(resultValue); + }, + key: "Promise", + test: isPromise, + unfold: async function* (source) { + let code; + + try { + const value = await source; + yield { chunk: [PROMISE_RESOLVED, value] }; + code = 200; + } catch (err) { + yield { chunk: [PROMISE_REJECTED, err] }; + code = 200; + } finally { + code ??= 500; + } + + return code; + }, +}; diff --git a/src/async/iterableUtils.ts b/src/async/iterableUtils.ts index 8dd2fbdf..ea3e0165 100644 --- a/src/async/iterableUtils.ts +++ b/src/async/iterableUtils.ts @@ -150,3 +150,35 @@ function addIfProvided( return `${key}: ${value as any}\n`; } + +export interface AsyncIterableEsque { + [Symbol.asyncIterator](): AsyncIterator; +} + +export function isAsyncIterableEsque( + maybeAsyncIterable: unknown, +): maybeAsyncIterable is AsyncIterableEsque { + return ( + !!maybeAsyncIterable && + (typeof maybeAsyncIterable === "object" || + typeof maybeAsyncIterable === "function") && + Symbol.asyncIterator in maybeAsyncIterable + ); +} + +export interface IterableEsque { + [Symbol.iterator](): Iterator; +} + +export function isIterableEsque( + maybeIterable: unknown, +): maybeIterable is IterableEsque { + return ( + !!maybeIterable && + (typeof maybeIterable === "object" || + typeof maybeIterable === "function") && + Symbol.iterator in maybeIterable + ); +} + +export type MaybePromise = Promise | T; diff --git a/src/async/serializeAsync.ts b/src/async/serializeAsync.ts index 92613317..1d359f69 100644 --- a/src/async/serializeAsync.ts +++ b/src/async/serializeAsync.ts @@ -33,7 +33,7 @@ function walkerFactory(nonce: TsonNonce, types: TsonAsyncOptions["types"]) { const iterators = new Map>(); - const iterator = { + const iterable: AsyncIterable = { async *[Symbol.asyncIterator]() { // race all active iterators and yield next value as they come // when one iterator is done, remove it from the list @@ -94,24 +94,25 @@ function walkerFactory(nonce: TsonNonce, types: TsonAsyncOptions["types"]) { walk: WalkFn, ) => TsonSerializedValue; - const $serialize: Serializer = handler.serializeIterator - ? (value): TsonTuple => { - const idx = asyncIndex++ as TsonAsyncIndex; - - const iterator = handler.serializeIterator({ - value, - }); - iterators.set(idx, iterator[Symbol.asyncIterator]()); - - return [handler.key as TsonTypeHandlerKey, idx, nonce]; - } - : handler.serialize - ? (value, nonce, walk): TsonTuple => [ - handler.key as TsonTypeHandlerKey, - walk(handler.serialize(value)), - nonce, - ] - : (value, _nonce, walk) => walk(value); + const $serialize: Serializer = + "serializeIterator" in handler + ? (value): TsonTuple => { + const idx = asyncIndex++ as TsonAsyncIndex; + + const iterator = handler.serializeIterator({ + value, + }); + iterators.set(idx, iterator[Symbol.asyncIterator]()); + + return [handler.key as TsonTypeHandlerKey, idx, nonce]; + } + : "serialize" in handler + ? (value, nonce, walk): TsonTuple => [ + handler.key as TsonTypeHandlerKey, + walk(handler.serialize(value)), + nonce, + ] + : (value, _nonce, walk) => walk(value); return { ...handler, $serialize, @@ -185,7 +186,7 @@ function walkerFactory(nonce: TsonNonce, types: TsonAsyncOptions["types"]) { return cacheAndReturn(mapOrReturn(value, walk)); }; - return [walk, iterator] as const; + return [walk, iterable] as const; } type TsonAsyncSerializer = ( diff --git a/src/async/serializeAsync2.test.ts b/src/async/serializeAsync2.test.ts new file mode 100644 index 00000000..7f29491f --- /dev/null +++ b/src/async/serializeAsync2.test.ts @@ -0,0 +1,320 @@ +import { assertType, describe, expect, test } from "vitest"; + +import { tsonBigint } from "../index.js"; +import { expectSequence } from "../internals/testUtils.js"; +import { + ChunkTypes, + TsonAsyncTuple, + TsonStatus, + TsonStructures, +} from "./asyncTypes2.js"; +import { tsonPromise } from "./handlers/tsonPromise2.js"; +import { createTsonSerializeAsync } from "./serializeAsync2.js"; + +const nonce = "__tson"; +const anyId = expect.stringMatching(`^${nonce}[0-9]+$`); +const idOf = (id: number | string) => `${nonce}${id}`; + +describe("AsyncSerialize", (it) => { + it("should handle primitives correctly", async ({ expect }) => { + const options = { + guards: [], + nonce: () => nonce, + types: [ + // Primitive handler mock + { + deserialize: (val: string) => val.toLowerCase(), + key: "string", + primitive: "string" as const, + serialize: (val: string) => val.toUpperCase(), + }, + ], + }; + + const serialize = createTsonSerializeAsync(options); + const source = "hello"; + const chunks: TsonAsyncTuple[] = []; + + for await (const chunk of serialize(source)) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(1); + expect(chunks).toEqual([ + [ChunkTypes.LEAF, [idOf(0), nonce, null], "HELLO", "string"], + ]); + }); + + it("should handle circular references", async ({ expect }) => { + const options = { guards: [], nonce: () => nonce, types: [] }; + const serialize = createTsonSerializeAsync(options); + const object: any = {}; + const chunks = []; + const rootId = idOf(0); + + // Create a circular reference + object.self = object; + + for await (const chunk of serialize(object)) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(3); + expect(chunks).toEqual([ + [ChunkTypes.HEAD, [rootId, nonce, null], TsonStructures.POJO], + [ChunkTypes.REF, [anyId, rootId, "self"], rootId], + [ChunkTypes.TAIL, [anyId, rootId, null], TsonStatus.OK], + ]); + }); + + test.each([ + ["number", 0], + ["string", "hello"], + ["boolean", true], + ["null", null], + ])(`should serialize %s primitives without a handler`, async (_, value) => { + const options = { guards: [], nonce: () => nonce, types: [] }; + const serialize = createTsonSerializeAsync(options); + const chunks: TsonAsyncTuple[] = []; + for await (const chunk of serialize(value)) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(1); + expect(chunks).toEqual([[ChunkTypes.LEAF, [idOf(0), nonce, null], value]]); + }); + + it("should serialize values with a sync handler", async ({ expect }) => { + const options = { + guards: [], + nonce: () => nonce, + types: [tsonBigint], + }; + + const serialize = createTsonSerializeAsync(options); + const source = 0n; + const chunks = []; + + for await (const chunk of serialize(source)) { + chunks.push(chunk); + } + + assertType(chunks); + expect(chunks.length).toBe(1); + expect(chunks).toEqual([ + [ChunkTypes.LEAF, [idOf(0), nonce, null], "0", "bigint"], + ]); + }); + + it("should serialize values with an async handler", async ({ expect }) => { + const options = { + guards: [], + nonce: () => nonce, + types: [tsonPromise], + }; + + const serialize = createTsonSerializeAsync(options); + const source = Promise.resolve("hello"); + const chunks: TsonAsyncTuple[] = []; + + for await (const chunk of serialize(source)) { + chunks.push(chunk); + } + + const heads = chunks.filter((chunk) => chunk[0] === ChunkTypes.HEAD); + const tails = chunks.filter((chunk) => chunk[0] === ChunkTypes.TAIL); + const leaves = chunks.filter((chunk) => chunk[0] === ChunkTypes.LEAF); + + const head_1_id = heads[0]![1][0]; + const head_2_id = heads[1]![1][0]; + + expect(chunks.length).toBe(6); + + expectSequence(chunks) + .toHaveAll(heads) + .beforeAll([...leaves, ...tails]); + + heads.forEach((_, i) => { + expectSequence(chunks) + .toHave(heads[i]!) + .beforeAll([tails[i]!, leaves[i]!]); + expectSequence(chunks) + .toHave(tails[i]!) + .afterAll([heads[i]!, leaves[i]!]); + }); + + expect(head_1_id).toBe(idOf(0)); + + expect(heads).toHaveLength(2); + expect(tails).toHaveLength(2); + expect(leaves).toHaveLength(2); + + expect(heads).toStrictEqual([ + [ChunkTypes.HEAD, [head_1_id, nonce, null], tsonPromise.key], + [ChunkTypes.HEAD, [head_2_id, head_1_id, null], 0], + ]); + + expect(leaves).toStrictEqual([ + [ChunkTypes.LEAF, [anyId, head_2_id, 0], 0], + [ChunkTypes.LEAF, [anyId, head_2_id, 1], "hello"], + ]); + + expect(tails).toStrictEqual([ + [ChunkTypes.TAIL, [anyId, head_1_id, null], TsonStatus.OK], + [ChunkTypes.TAIL, [anyId, head_2_id, null], TsonStatus.OK], + ]); + }); +}); + +describe("TsonGuards", (it) => { + it("should apply guards and throw if they fail", async ({ expect }) => { + const options = { + guards: [{ assert: (val: unknown) => val !== "fail", key: "testGuard" }], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const failingValue = "fail"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(failingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty( + "message", + "Guard testGuard failed on value fail", + ); + }); + + it("should apply guards and not throw if they pass", async ({ expect }) => { + const options = { + guards: [{ assert: (val: unknown) => val !== "fail", key: "testGuard" }], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const passingValue = "pass"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(passingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeUndefined(); + }); + + it("should apply guards and not throw if they return undefined", async ({ + expect, + }) => { + const options = { + guards: [{ assert: () => undefined, key: "testGuard" }], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const passingValue = "pass"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(passingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeUndefined(); + }); + + it("should apply guards and throw if they return false", async ({ + expect, + }) => { + const options = { + guards: [{ assert: () => false, key: "testGuard" }], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const passingValue = "pass"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(passingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty( + "message", + "Guard testGuard failed on value pass", + ); + }); + + it("should apply guards and not throw if they return true", async ({ + expect, + }) => { + const options = { + guards: [{ assert: () => true, key: "testGuard" }], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const passingValue = "pass"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(passingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeUndefined(); + }); + + it("should apply guards and throw if they throw", async ({ expect }) => { + const options = { + guards: [ + { + assert: () => { + throw new Error("testGuard error"); + }, + key: "testGuard", + }, + ], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const passingValue = "pass"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(passingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty("message", "testGuard error"); + }); +}); diff --git a/src/async/serializeAsync2.ts b/src/async/serializeAsync2.ts new file mode 100644 index 00000000..cc7b984f --- /dev/null +++ b/src/async/serializeAsync2.ts @@ -0,0 +1,390 @@ +import { GetNonce, getDefaultNonce } from "../internals/getNonce.js"; +import { isComplexValue } from "../internals/isComplexValue.js"; +import { + TsonAllTypes, + TsonNonce, + TsonType, + TsonTypeTesterCustom, + TsonTypeTesterPrimitive, +} from "../sync/syncTypes.js"; +import { + ChunkTypes, + TsonAsyncBodyTuple, + TsonAsyncChunk, + TsonAsyncHeadTuple, + TsonAsyncOptions, + TsonAsyncReferenceTuple, + TsonAsyncTailTuple, + TsonAsyncTuple, + TsonAsyncType, + TsonStatus, + TsonStructures, +} from "./asyncTypes2.js"; +import { isAsyncIterableEsque, isIterableEsque } from "./iterableUtils.js"; + +function getHandlers(opts: TsonAsyncOptions) { + const primitives = new Map< + TsonAllTypes, + Extract, TsonTypeTesterPrimitive> + >(); + + const asyncs = new Set>(); + const syncs = new Set, TsonTypeTesterCustom>>(); + + for (const marshaller of opts.types) { + if (marshaller.primitive) { + if (primitives.has(marshaller.primitive)) { + throw new Error( + `Multiple handlers for primitive ${marshaller.primitive} found`, + ); + } + + primitives.set(marshaller.primitive, marshaller); + } else if (marshaller.async) { + asyncs.add(marshaller); + } else { + syncs.add(marshaller); + } + } + + const getNonce = (opts.nonce ? opts.nonce : getDefaultNonce) as GetNonce; + + function applyGuards(value: unknown) { + for (const guard of opts.guards ?? []) { + const isOk = guard.assert(value); + if (typeof isOk === "boolean" && !isOk) { + throw new Error(`Guard ${guard.key} failed on value ${String(value)}`); + } + } + } + + return [getNonce, { asyncs, primitives, syncs }, applyGuards] as const; +} + +// Serializer factory function +export function createTsonSerializeAsync(opts: TsonAsyncOptions) { + let currentId = 0; + const objectCache = new WeakMap(); + /** + * A cache of running iterators mapped to their header tuple. + * When a head is emitted for an iterator, it is added to this map. + * When the iterator is done, a tail is emitted and the iterator is removed from the map. + */ + const workerMap = new WeakMap< + TsonAsyncHeadTuple, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + AsyncGenerator + >(); + + const queue = new Map<`${TsonNonce}${number}`, Promise>(); + const [getNonce, handlers, applyGuards] = getHandlers(opts); + const nonce = getNonce(); + const getNextId = () => `${nonce}${currentId++}` as const; + + const createCircularRefChunk = ( + key: null | number | string, + value: object, + id: `${TsonNonce}${number}`, + parentId: `${TsonNonce}${"" | number}`, + ): TsonAsyncReferenceTuple | undefined => { + const originalNodeId = objectCache.get(value); + if (originalNodeId === undefined) { + return undefined; + } + + return [ChunkTypes.REF, [id, parentId, key], originalNodeId]; + }; + + const initializeIterable = ( + source: AsyncGenerator< + TsonAsyncChunk, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + number | undefined | void, + undefined + >, + ): ((head: TsonAsyncHeadTuple) => TsonAsyncHeadTuple) => { + return (head) => { + workerMap.set(head, source); + const newId = getNextId(); + queue.set( + newId, + source.next().then(async (result) => { + if (result.done) { + workerMap.delete(head); + return Promise.resolve([ + ChunkTypes.TAIL, + [newId, head[1][0], null], + result.value ?? TsonStatus.OK, + ] as TsonAsyncTailTuple); + } + + addToQueue(result.value.key ?? null, result.value.chunk, head[1][0]); + return Promise.resolve([ + ChunkTypes.BODY, + [newId, head[1][0], null], + head, + ] as TsonAsyncBodyTuple); + }), + ); + + return head; + }; + }; + + const addToQueue = ( + key: null | number | string, + value: unknown, + parentId: `${TsonNonce}${"" | number}`, + ) => { + const thisId = getNextId(); + if (isComplexValue(value)) { + const circularRef = createCircularRefChunk(key, value, thisId, parentId); + if (circularRef) { + queue.set(circularRef[1][0], Promise.resolve(circularRef)); + return; + } + + objectCache.set(value, thisId); + } + + // Try to find a matching handler and initiate serialization + const handler = selectHandler({ handlers, value }); + + if (!handler) { + applyGuards(value); + + if (isComplexValue(value)) { + const iterator = toAsyncGenerator(value); + + queue.set( + thisId, + Promise.resolve([ + ChunkTypes.HEAD, + [thisId, parentId, key], + typeofStruct(value), + ] as TsonAsyncHeadTuple).then(initializeIterable(iterator)), + ); + + return; + } + + queue.set( + thisId, + Promise.resolve([ChunkTypes.LEAF, [thisId, parentId, key], value]), + ); + + return; + } + + if (!handler.async) { + queue.set( + thisId, + Promise.resolve([ + ChunkTypes.LEAF, + [thisId, parentId, key], + handler.serialize(value), + handler.key, + ]), + ); + + return; + } + + // Async handler + const iterator = handler.unfold(value); + + // Ensure the head is sent before the body + queue.set( + thisId, + Promise.resolve([ + ChunkTypes.HEAD, + [thisId, parentId, key], + handler.key, + ] as TsonAsyncHeadTuple).then(initializeIterable(iterator)), + ); + }; + + return async function* serialize(source: unknown) { + addToQueue(null, source, `${nonce}`); + + while (queue.size > 0) { + const chunk = await Promise.race([...queue.values()]); + + if (chunk[0] !== ChunkTypes.BODY) { + queue.delete(chunk[1][0]); + yield chunk; + continue; + } + + const headId = chunk[2][1][0]; + const chunkId = chunk[1][0]; + const chunkKey = chunk[1][2] ?? null; + const worker = workerMap.get(chunk[2]); + + if (!worker) { + throw new Error("Worker not found"); + } + + queue.set( + chunkId, + worker.next().then(async (result) => { + if (result.done) { + workerMap.delete(chunk[2]); + return Promise.resolve([ + ChunkTypes.TAIL, + [chunkId, headId, chunkKey], + result.value ?? TsonStatus.OK, + ] as TsonAsyncTailTuple); + } + + addToQueue(result.value.key ?? null, result.value.chunk, headId); + + return Promise.resolve([ + ChunkTypes.BODY, + [chunkId, headId, chunkKey], + chunk[2], + ] as TsonAsyncBodyTuple); + }), + ); + } + }; +} + +function selectHandler({ + handlers: { asyncs, primitives, syncs }, + value, +}: { + handlers: { + asyncs: Set>; + primitives: Map< + TsonAllTypes, + Extract, TsonTypeTesterPrimitive> + >; + syncs: Set, TsonTypeTesterCustom>>; + }; + value: unknown; +}) { + let handler; + const maybePrimitive = primitives.get(typeof value); + + if (!maybePrimitive?.test || maybePrimitive.test(value)) { + handler = maybePrimitive; + } + + handler ??= [...syncs].find((handler) => handler.test(value)); + handler ??= [...asyncs].find((handler) => handler.test(value)); + + return handler; +} + +async function* toAsyncGenerator( + item: T, +): AsyncGenerator { + let code; + + try { + if (isIterableEsque(item) || isAsyncIterableEsque(item)) { + let i = 0; + for await (const chunk of item) { + yield { + chunk, + key: i++, + }; + } + } else { + for (const key in item) { + yield { + chunk: item[key], + key, + }; + } + } + + code = TsonStatus.OK; + return code; + } catch { + code = TsonStatus.ERROR; + return code; + } finally { + code ??= TsonStatus.INCOMPLETE; + } +} + +function typeofStruct< + T extends + | AsyncIterable + | Iterable + | Record + | any[], +>(item: T): TsonStructures[keyof TsonStructures] { + switch (true) { + case Symbol.asyncIterator in item: + return TsonStructures.ITERABLE; + case Array.isArray(item): + return TsonStructures.ARRAY; + case Symbol.iterator in item: + return TsonStructures.ITERABLE; + case typeof item === "object": + case typeof item === "function": + // we intentionally treat functions as pojos + return TsonStructures.POJO; + default: + throw new Error("Unexpected type"); + } +} + +// /** +// * - Async iterables are iterated, and each value yielded is walked. +// * To be able to reconstruct the reference graph, each value is +// * assigned a negative-indexed label indicating both the order in +// * which it was yielded, and that it is a child of an async iterable. +// * Upon deserialization, each [key, value] pair is set as a property +// * on an object with a [Symbol.asyncIterator] method which yields +// * the values, preserving the order. +// * +// * - Arrays are iterated with their indices as labels and +// * then reconstructed as arrays. +// * +// * - Maps are iterated as objects +// * +// * - Sets are iterated as arrays +// * +// * - All other iterables are iterated as if they were async. +// * +// * - All other objects are iterated with their keys as labels and +// * reconstructed as objects, effectively replicating +// * the behavior of `Object.fromEntries(Object.entries(obj))` +// * @yields {TsonAsyncChunk} +// */ +// async function* toAsyncGenerator( +// item: T, +// ): AsyncGenerator { +// let code; + +// try { +// if (isIterableEsque(item) || isAsyncIterableEsque(item)) { +// let i = 0; +// for await (const chunk of item) { +// yield { +// chunk, +// key: i++, +// }; +// } +// } else { +// for (const key in item) { +// yield { +// chunk: item[key], +// key, +// }; +// } +// } + +// code = TSON_STATUS.OK; +// return code; +// } catch { +// code = TSON_STATUS.ERROR; +// return code; +// } finally { +// code ??= TSON_STATUS.INCOMPLETE; +// } +// } diff --git a/src/index.test.ts b/src/index.test.ts index 21d52860..27c55ef0 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -5,9 +5,9 @@ import { TsonOptions, TsonType, createTson } from "./index.js"; import { expectError, waitError } from "./internals/testUtils.js"; test("multiple handlers for primitive string found", () => { - const stringHandler: TsonType = { + const stringHandler = { primitive: "string", - }; + } as TsonType; const opts: TsonOptions = { types: [stringHandler, stringHandler], }; @@ -34,20 +34,6 @@ test("duplicate keys", () => { ); }); -test("no max call stack", () => { - const t = createTson({ - types: [], - }); - - const expected: Record = {}; - expected["a"] = expected; - - // stringify should fail b/c of JSON limitations - const err = expectError(() => t.stringify(expected)); - - expect(err.message).toMatchInlineSnapshot('"Circular reference detected"'); -}); - test("allow duplicate objects", () => { const t = createTson({ types: [], @@ -98,9 +84,9 @@ test("async: duplicate keys", async () => { }); test("async: multiple handlers for primitive string found", async () => { - const stringHandler: TsonType = { + const stringHandler = { primitive: "string", - }; + } as TsonType; const err = await waitError(async () => { const iterator = createTsonAsync({ diff --git a/src/internals/createAcyclicCacheRegistrar.test.ts b/src/internals/createAcyclicCacheRegistrar.test.ts new file mode 100644 index 00000000..65d732a3 --- /dev/null +++ b/src/internals/createAcyclicCacheRegistrar.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "vitest"; + +import { createTson } from "../index.js"; +import { expectError } from "./testUtils.js"; + +test("no max call stack", () => { + const t = createTson({ + types: [], + }); + + const expected: Record = {}; + expected["a"] = expected; + + // stringify should fail b/c of JSON limitations + const err = expectError(() => t.stringify(expected)); + + expect(err.message).toMatchInlineSnapshot('"Circular reference detected"'); +}); diff --git a/src/internals/createAcyclicCacheRegistrar.ts b/src/internals/createAcyclicCacheRegistrar.ts new file mode 100644 index 00000000..07383da0 --- /dev/null +++ b/src/internals/createAcyclicCacheRegistrar.ts @@ -0,0 +1,33 @@ +import { TsonCircularReferenceError } from "../index.js"; +import { isComplexValue } from "./isComplexValue.js"; + +export function createAcyclicCacheRegistrar() { + const seen = new WeakSet(); + const cache = new WeakMap(); + + return function register(value: T) { + const $unset = Symbol("Not undefined or null, but unset"); + let cached: T | typeof $unset = $unset; + + if (isComplexValue(value)) { + if (seen.has(value)) { + cached = cache.get(value) as T; + if (!cached) { + throw new TsonCircularReferenceError(value); + } + } else { + seen.add(value); + } + } + + return function cacheAndReturn(result: T) { + if (isComplexValue(value) && cached === $unset) { + cache.set(value, result); + + return result; + } + + return result; + }; + }; +} diff --git a/src/internals/isComplexValue.ts b/src/internals/isComplexValue.ts new file mode 100644 index 00000000..93b7af19 --- /dev/null +++ b/src/internals/isComplexValue.ts @@ -0,0 +1,7 @@ +export function isComplexValue(arg: unknown): arg is object { + if (typeof arg === "function") { + throw new TypeError("Serializing functions is not currently supported."); + } + + return arg !== null && typeof arg === "object"; +} diff --git a/src/internals/isPlainObject.ts b/src/internals/isPlainObject.ts index 8f47a397..eeb16f24 100644 --- a/src/internals/isPlainObject.ts +++ b/src/internals/isPlainObject.ts @@ -1,4 +1,6 @@ -export const isPlainObject = (obj: unknown): obj is Record => { +export const isPlainObject = ( + obj: unknown, +): obj is Record => { if (!obj || typeof obj !== "object") { return false; } diff --git a/src/internals/testUtils.ts b/src/internals/testUtils.ts index f8e07362..9a4bf726 100644 --- a/src/internals/testUtils.ts +++ b/src/internals/testUtils.ts @@ -1,6 +1,8 @@ import http from "node:http"; import { expect } from "vitest"; +import { assert } from "./assert.js"; + export const expectError = (fn: () => unknown) => { let err: unknown; try { @@ -101,3 +103,89 @@ export const createPromise = (result: () => T, wait = 1) => { }, wait); }); }; + +export const expectSequence = (sequence: T[]) => ({ + toHave(value: T) { + expect(sequence).toContain(value); + assert(value); + + return { + after(preceding: T) { + expect(preceding).toBeDefined(); + assert(preceding); + + const index = sequence.indexOf(value); + const precedingIndex = sequence.indexOf(preceding); + expect(index).toBeGreaterThanOrEqual(0); + expect(precedingIndex).toBeGreaterThanOrEqual(0); + expect( + index, + `Expected ${JSON.stringify( + value, + null, + 2, + )} to come after ${JSON.stringify(preceding, null, 2)}`, + ).toBeGreaterThan(precedingIndex); + }, + afterAll(following: T[]) { + expect(following).toBeDefined(); + assert(following); + for (const followingValue of following) { + this.after(followingValue); + } + }, + before(following: T) { + expect(following, "following").toBeDefined(); + assert(following); + + const index = sequence.indexOf(value); + const followingIndex = sequence.indexOf(following); + expect(index).toBeGreaterThanOrEqual(0); + expect(followingIndex).toBeGreaterThanOrEqual(0); + expect( + index, + `Expected ${JSON.stringify( + value, + null, + 2, + )} to come before ${JSON.stringify(following, null, 2)}`, + ).toBeLessThan(followingIndex); + }, + beforeAll(following: T[]) { + for (const followingValue of following) { + this.before(followingValue); + } + }, + }; + }, + toHaveAll(values: T[]) { + const thisHas = this.toHave.bind(this); + + for (const value of values) { + thisHas(value); + } + + return { + after(preceding: T) { + for (const value of values) { + thisHas(value).after(preceding); + } + }, + afterAll(following: T[]) { + for (const value of values) { + thisHas(value).afterAll(following); + } + }, + before(following: T) { + for (const value of values) { + thisHas(value).before(following); + } + }, + beforeAll(following: T[]) { + for (const value of values) { + thisHas(value).beforeAll(following); + } + }, + }; + }, +}); diff --git a/src/iterableTypes.test.ts b/src/iterableTypes.test.ts new file mode 100644 index 00000000..da81f847 --- /dev/null +++ b/src/iterableTypes.test.ts @@ -0,0 +1,28 @@ +import * as v from "vitest"; + +import { AsyncGenerator, Generator } from "./iterableTypes.js"; + +v.describe("Async Iterable Types", () => { + v.it("should be interchangeable with the original type signatures", () => { + const generator = (async function* () { + await Promise.resolve(); + yield 1; + yield 2; + yield 3; + })(); + + v.expectTypeOf(generator).toMatchTypeOf>(); + }); +}); + +v.describe("Iterable Types", () => { + v.it("should be interchangeable with the original type signatures", () => { + const generator = (function* () { + yield 1; + yield 2; + yield 3; + })(); + + v.expectTypeOf(generator).toMatchTypeOf>(); + }); +}); diff --git a/src/iterableTypes.ts b/src/iterableTypes.ts new file mode 100644 index 00000000..0c33b885 --- /dev/null +++ b/src/iterableTypes.ts @@ -0,0 +1,33 @@ +/** + * @file + * This file originally contained types for the `iterable` and `asyncIterable` + * (as well as `iterableIterator` and `asyncIterableIterator`) types, but + * ultimately they were decided against, as they were not ultimately useful. + * The extensions essentially provided useless information, given that the + * `next` and `return` methods cannot be relied upon to be present. For that, + * Generators are a better choice, as they expose the `next` and `return` + * methods through the GeneratorFunction syntax. + * @see https://github.com/microsoft/TypeScript/issues/32682 for information + * about the types that were removed. + */ + +/* eslint-disable @typescript-eslint/no-empty-interface */ + +/** + * A stronger type for Generator + */ +export interface Generator< + T = unknown, + TOptionalReturn = unknown, + TOptionalNext = unknown, +> extends globalThis.Generator {} +/** + * A stronger type for AsyncGenerator + */ +export interface AsyncGenerator< + T = unknown, + TOptionalReturn = unknown, + TOptionalNext = unknown, +> extends globalThis.AsyncGenerator {} + +/* eslint-enable @typescript-eslint/no-empty-interface */ diff --git a/src/sync/deserialize.ts b/src/sync/deserialize.ts index da27568a..74f312c2 100644 --- a/src/sync/deserialize.ts +++ b/src/sync/deserialize.ts @@ -2,30 +2,27 @@ import { isTsonTuple } from "../internals/isTsonTuple.js"; import { mapOrReturn } from "../internals/mapOrReturn.js"; import { TsonDeserializeFn, + TsonMarshaller, TsonNonce, TsonOptions, TsonParseFn, TsonSerialized, - TsonTransformerSerializeDeserialize, } from "./syncTypes.js"; type WalkFn = (value: unknown) => unknown; type WalkerFactory = (nonce: TsonNonce) => WalkFn; -type AnyTsonTransformerSerializeDeserialize = - TsonTransformerSerializeDeserialize; +type AnyTsonMarshaller = TsonMarshaller; export function createTsonDeserialize(opts: TsonOptions): TsonDeserializeFn { - const typeByKey: Record = {}; - + const typeByKey: Record = {}; for (const handler of opts.types) { if (handler.key) { if (typeByKey[handler.key]) { throw new Error(`Multiple handlers for key ${handler.key} found`); } - typeByKey[handler.key] = - handler as AnyTsonTransformerSerializeDeserialize; + typeByKey[handler.key] = handler as AnyTsonMarshaller; } } @@ -33,6 +30,7 @@ export function createTsonDeserialize(opts: TsonOptions): TsonDeserializeFn { const walk: WalkFn = (value) => { if (isTsonTuple(value, nonce)) { const [type, serializedValue] = value; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const transformer = typeByKey[type]!; return transformer.deserialize(walk(serializedValue)); diff --git a/src/sync/handlers/tsonNumberGuard.test.ts b/src/sync/handlers/tsonNumberGuard.test.ts index b5713517..702afc2a 100644 --- a/src/sync/handlers/tsonNumberGuard.test.ts +++ b/src/sync/handlers/tsonNumberGuard.test.ts @@ -5,7 +5,8 @@ import { expectError } from "../../internals/testUtils.js"; test("number", () => { const t = createTson({ - types: [tsonNumberGuard], + guards: [tsonNumberGuard], + types: [], }); const bad = [ @@ -14,7 +15,7 @@ test("number", () => { Infinity, -Infinity, ]; - const good = [1, 0, -1, 1.1, -1.1]; + const good = [1, 0, -1, 1.1, -1.1, "01"]; const errors: unknown[] = []; diff --git a/src/sync/handlers/tsonNumberGuard.ts b/src/sync/handlers/tsonNumberGuard.ts index c3b4da57..5e569b7f 100644 --- a/src/sync/handlers/tsonNumberGuard.ts +++ b/src/sync/handlers/tsonNumberGuard.ts @@ -1,21 +1,22 @@ -import { TsonType } from "../syncTypes.js"; +import { TsonGuard } from "../../tsonAssert.js"; /** * Prevents `NaN` and `Infinity` from being serialized */ -export const tsonNumberGuard: TsonType = { - primitive: "number", - test: (v) => { - const value = v as number; - if (isNaN(value)) { +export const tsonNumberGuard: TsonGuard> = { + assert(v: unknown) { + if (typeof v !== "number") { + return; + } + + if (isNaN(v)) { throw new Error("Encountered NaN"); } - if (!isFinite(value)) { + if (!isFinite(v)) { throw new Error("Encountered Infinity"); } - - return false; }, + key: "tsonAssertNotInfinite", }; diff --git a/src/sync/handlers/tsonUnknownObjectGuard.test.ts b/src/sync/handlers/tsonUnknownObjectGuard.test.ts index 0739c857..b7d03899 100644 --- a/src/sync/handlers/tsonUnknownObjectGuard.test.ts +++ b/src/sync/handlers/tsonUnknownObjectGuard.test.ts @@ -11,11 +11,8 @@ import { expectError } from "../../internals/testUtils.js"; test("guard unwanted objects", () => { // Sets are okay, but not Maps const t = createTson({ - types: [ - tsonSet, - // defined last so it runs last - tsonUnknownObjectGuard, - ], + guards: [tsonUnknownObjectGuard], + types: [tsonSet], }); { diff --git a/src/sync/handlers/tsonUnknownObjectGuard.ts b/src/sync/handlers/tsonUnknownObjectGuard.ts index f4316d7e..6116e00a 100644 --- a/src/sync/handlers/tsonUnknownObjectGuard.ts +++ b/src/sync/handlers/tsonUnknownObjectGuard.ts @@ -1,6 +1,6 @@ import { TsonError } from "../../errors.js"; import { isPlainObject } from "../../internals/isPlainObject.js"; -import { TsonType } from "../syncTypes.js"; +import { TsonGuard } from "../../tsonAssert.js"; export class TsonUnknownObjectGuardError extends TsonError { /** @@ -24,12 +24,11 @@ export class TsonUnknownObjectGuardError extends TsonError { * Make sure to define this last in the list of types. * @throws {TsonUnknownObjectGuardError} if an unknown object is found */ -export const tsonUnknownObjectGuard: TsonType = { - test: (v) => { +export const tsonUnknownObjectGuard: TsonGuard> = { + assert(v: unknown) { if (v && typeof v === "object" && !Array.isArray(v) && !isPlainObject(v)) { throw new TsonUnknownObjectGuardError(v); } - - return false; }, + key: "tsonUnknownObjectGuard", }; diff --git a/src/sync/serialize.ts b/src/sync/serialize.ts index bc1d2d6f..bb0f2171 100644 --- a/src/sync/serialize.ts +++ b/src/sync/serialize.ts @@ -1,14 +1,17 @@ -import { TsonCircularReferenceError } from "../errors.js"; +import { createAcyclicCacheRegistrar } from "../internals/createAcyclicCacheRegistrar.js"; import { GetNonce, getDefaultNonce } from "../internals/getNonce.js"; import { mapOrReturn } from "../internals/mapOrReturn.js"; import { TsonAllTypes, + TsonMarshaller, TsonNonce, TsonOptions, TsonSerializeFn, TsonSerialized, TsonStringifyFn, TsonTuple, + TsonType, + TsonTypeHandlerKey, TsonTypeTesterCustom, TsonTypeTesterPrimitive, } from "./syncTypes.js"; @@ -19,30 +22,39 @@ type WalkerFactory = (nonce: TsonNonce) => WalkFn; function getHandlers(opts: TsonOptions) { type Handler = (typeof opts.types)[number]; - const byPrimitive: Partial< - Record> - > = {}; - const nonPrimitives: Extract[] = []; + const primitives = new Map< + TsonAllTypes, + Extract + >(); - for (const handler of opts.types) { - if (handler.primitive) { - if (byPrimitive[handler.primitive]) { + const customs = new Set>(); + + for (const marshaller of opts.types) { + if (marshaller.primitive) { + if (primitives.has(marshaller.primitive)) { throw new Error( - `Multiple handlers for primitive ${handler.primitive} found`, + `Multiple handlers for primitive ${marshaller.primitive} found`, ); } - byPrimitive[handler.primitive] = handler; + primitives.set(marshaller.primitive, marshaller); } else { - nonPrimitives.push(handler); + customs.add(marshaller); } } - const getNonce: GetNonce = opts.nonce - ? (opts.nonce as GetNonce) - : getDefaultNonce; + const getNonce = (opts.nonce ? opts.nonce : getDefaultNonce) as GetNonce; + + function runGuards(value: unknown) { + for (const guard of opts.guards ?? []) { + const isOk = guard.assert(value); + if (typeof isOk === "boolean" && !isOk) { + throw new Error(`Guard ${guard.key} failed on value ${String(value)}`); + } + } + } - return [getNonce, nonPrimitives, byPrimitive] as const; + return [getNonce, customs, primitives, runGuards] as const; } export function createTsonStringify(opts: TsonOptions): TsonStringifyFn { @@ -53,65 +65,53 @@ export function createTsonStringify(opts: TsonOptions): TsonStringifyFn { } export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { - const [getNonce, nonPrimitive, byPrimitive] = getHandlers(opts); + const [getNonce, nonPrimitives, primitives, runGuards] = getHandlers(opts); const walker: WalkerFactory = (nonce) => { - const seen = new WeakSet(); - const cache = new WeakMap(); + // create a persistent cache shared across recursions + const register = createAcyclicCacheRegistrar(); const walk: WalkFn = (value) => { - const type = typeof value; - const isComplex = !!value && type === "object"; + const cacheAndReturn = register(value); + const primitiveHandler = primitives.get(typeof value); - if (isComplex) { - if (seen.has(value)) { - const cached = cache.get(value); - if (!cached) { - throw new TsonCircularReferenceError(value); - } + let handler: TsonType | undefined; - return cached; - } - - seen.add(value); + // primitive handlers take precedence + if (!primitiveHandler?.test || primitiveHandler.test(value)) { + handler = primitiveHandler; } - const cacheAndReturn = (result: unknown) => { - if (isComplex) { - cache.set(value, result); - } - - return result; - }; - - const primitiveHandler = byPrimitive[type]; - if ( - primitiveHandler && - (!primitiveHandler.test || primitiveHandler.test(value)) - ) { - return cacheAndReturn([ - primitiveHandler.key, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - walk(primitiveHandler.serialize!(value)), - nonce, - ] as TsonTuple); - } + // first passing handler wins + handler ??= [...nonPrimitives].find((handler) => handler.test(value)); - for (const handler of nonPrimitive) { - if (handler.test(value)) { - return cacheAndReturn([ - handler.key, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - walk(handler.serialize!(value)), - nonce, - ] as TsonTuple); - } + /* If we have a handler, cache and return a TSON tuple for + the result of recursively walking the serialized value */ + if (handler) { + return cacheAndReturn(recurseWithHandler(handler, value)); } + // apply guards to unhanded values + runGuards(value); + + // recursively walk children return cacheAndReturn(mapOrReturn(value, walk)); }; return walk; + + function recurseWithHandler( + handler: + | (TsonTypeTesterCustom & TsonMarshaller) + | (TsonTypeTesterPrimitive & TsonMarshaller), + v: unknown, + ) { + return [ + handler.key as TsonTypeHandlerKey, + walk(handler.serialize(v)), + nonce, + ] as TsonTuple; + } }; return ((obj): TsonSerialized => { diff --git a/src/sync/syncTypes.ts b/src/sync/syncTypes.ts index bb45241c..447e2751 100644 --- a/src/sync/syncTypes.ts +++ b/src/sync/syncTypes.ts @@ -1,3 +1,5 @@ +import { TsonGuard } from "../tsonAssert.js"; + const brand = Symbol("branded"); export type TsonBranded = TType & { [brand]: TBrand }; @@ -5,7 +7,6 @@ export type TsonBranded = TType & { [brand]: TBrand }; export type TsonNonce = TsonBranded; export type TsonTypeHandlerKey = TsonBranded; export type TsonSerializedValue = unknown; - export type TsonTuple = [TsonTypeHandlerKey, TsonSerializedValue, TsonNonce]; // there's probably a better way of getting this type @@ -19,25 +20,14 @@ export type TsonAllTypes = | "symbol" | "undefined"; -type SerializedType = +export type SerializedType = | Record | boolean | number | string | unknown[]; -export interface TsonTransformerNone { - async?: false; - deserialize?: never; - - /** - * The key to use when serialized - */ - key?: never; - serialize?: never; - serializeIterator?: never; -} -export interface TsonTransformerSerializeDeserialize< +export interface TsonMarshaller< TValue, TSerializedType extends SerializedType, > { @@ -55,13 +45,8 @@ export interface TsonTransformerSerializeDeserialize< * JSON-serializable value */ serialize: (v: TValue) => TSerializedType; - serializeIterator?: never; } -export type TsonTransformer = - | TsonTransformerNone - | TsonTransformerSerializeDeserialize; - export interface TsonTypeTesterPrimitive { /** * The type of the primitive @@ -83,8 +68,6 @@ export interface TsonTypeTesterCustom { test: (v: unknown) => boolean; } -type TsonTypeTester = TsonTypeTesterCustom | TsonTypeTesterPrimitive; - export type TsonType< /** * The type of the value @@ -94,9 +77,19 @@ export type TsonType< * JSON-serializable value how it's stored after it's serialized */ TSerializedType extends SerializedType, -> = TsonTypeTester & TsonTransformer; +> = + | (TsonTypeTesterCustom & TsonMarshaller) + | (TsonTypeTesterPrimitive & TsonMarshaller); export interface TsonOptions { + /* eslint-disable jsdoc/informative-docs */ + /** + * The list of guards to apply to values before serializing them. + * Guards must throw on invalid values. + * @default [] + */ + /* eslint-enable jsdoc/informative-docs */ + guards?: TsonGuard[]; /** * The nonce function every time we start serializing a new object * Should return a unique value every time it's called @@ -106,7 +99,7 @@ export interface TsonOptions { /** * The list of types to use */ - types: (TsonType | TsonType)[]; + types: TsonType[]; } export const serialized = Symbol("serialized"); diff --git a/src/tsonAssert.test.ts b/src/tsonAssert.test.ts new file mode 100644 index 00000000..c30b9826 --- /dev/null +++ b/src/tsonAssert.test.ts @@ -0,0 +1,54 @@ +import * as v from "vitest"; + +import type { TsonGuard } from "./tsonAssert.js"; + +import { createTson } from "./index.js"; + +v.describe("TsonGuard", () => { + v.it("should work if the guard is a type guard", () => { + const guard = { + assert: (v: unknown): v is string => typeof v === "string", + key: "string", + }; + + v.expectTypeOf(guard).toMatchTypeOf>(); + + // create a tson instance with the guard + // serialize and deserialize a string + const tson = createTson({ guards: [guard], types: [] }); + const serialized = tson.stringify("hello"); + const deserialized = tson.parse(serialized); + v.expect(deserialized).toEqual("hello"); + + // serialize and deserialize a number should throw + + v.expect(() => + tson.parse(tson.stringify(1)), + ).toThrowErrorMatchingInlineSnapshot(`"Guard string failed on value 1"`); + }); + + v.it("should work if the guard is an assertion", () => { + const guard = { + assert: (v: unknown): asserts v is string => { + if (typeof v !== "string") { + throw new Error("Not a string"); + } + }, + key: "string", + }; + + v.expectTypeOf(guard).toMatchTypeOf>(); + + // create a tson instance with the guard + // serialize and deserialize a string + const tson = createTson({ guards: [guard], types: [] }); + const serialized = tson.stringify("hello"); + const deserialized = tson.parse(serialized); + v.expect(deserialized).toEqual("hello"); + + // serialize and deserialize a number should throw + v.expect(() => + tson.parse(tson.stringify(1)), + ).toThrowErrorMatchingInlineSnapshot(`"Not a string"`); + }); +}); diff --git a/src/tsonAssert.ts b/src/tsonAssert.ts new file mode 100644 index 00000000..c9ebc78b --- /dev/null +++ b/src/tsonAssert.ts @@ -0,0 +1,24 @@ +interface TsonGuardBase { + key: string; +} +interface TsonAssertionGuard extends TsonGuardBase { + /** + * @param v - The value to assert + * @returns `void | true` if the value is of the type + * @returns `false` if the value is not of the type + * @throws `any` if the value is not of the type + */ + assert: ((v: any) => asserts v is T) | ((v: any) => v is T); +} + +// // todo: maybe guard.parse can have guard.parse.input and guard.parse.output? +// interface TsonParserGuard extends TsonGuardBase { +// /** +// * +// * @param v - The value to parse +// * @returns {T} - A value that will be used in place of the original value +// */ +// parse: (v: any) => T; +// } + +export type TsonGuard = TsonAssertionGuard /* | TsonParserGuard */;