diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e8e8922..a30726d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,6 +54,8 @@ jobs: - uses: actions/setup-node@v6 - run: npm ci - run: npm run test-type-definitions + - run: npm i typescript@4.7 @types/node@18.0.0 + - run: npm run test-type-definitions-legacy lint: name: ESLint diff --git a/lib/types/index.d.ts b/lib/types/index.d.ts index 2de1d20..8c32fe5 100644 --- a/lib/types/index.d.ts +++ b/lib/types/index.d.ts @@ -8,6 +8,8 @@ export type NodeWebStream = NodeWebReadableStream; type Stream = WebStream | NodeWebStream; type MaybeStream = T | Stream; +/** Extract the type parameter `X` given `MaybeStream` */ +type MaybeStreamDataType> = T extends Stream ? X : T; export function readToEnd any = (chunks: T[]) => T>( input: MaybeStream, @@ -25,3 +27,61 @@ export function slice>( begin: number, end?: number | typeof Infinity ): InputType; // same as 'typeof input' + +type Defined = T extends undefined ? never : T; +/** + * This type takes care of streamed return types for stream transform functions. + * This is needed to avoid adding support for `undefined` payloads in the Stream type: + * - if the callbacks return `Data | undefined`, we cannot infer output type info. Since this is considered a rare edge case, + * this is translated into the overly-generic Stream. + * - if CallbackReturnType is `undefined`, then an empty stream is returned, and it is modeled as Stream. + */ +type TransformOutputMaybeToStream< + InputType, + CallbackReturnType extends Data | undefined | void +> = InputType extends Data ? + CallbackReturnType : + Stream; +export function transform< + InputType extends MaybeStream, + OutData extends Data, + ProcessFn extends undefined | ((chunk: MaybeStreamDataType) => OutData | undefined | void) = undefined, + FinishFn extends undefined | (() => OutData | undefined | void) = undefined, +>( + input: InputType, + process?: ProcessFn, + finish?: FinishFn, + queuingStrategy?: { highWaterMark: number } +): TransformOutputMaybeToStream< + InputType, + ProcessFn extends undefined ? + FinishFn extends undefined ? + undefined : + // we do not cover the case where FinishFn or ProcessFn are (undefined | function) + ReturnType> : + FinishFn extends undefined ? + ReturnType> : + (ReturnType> | ReturnType>) +>; + +export function transformAsync< + InputType extends MaybeStream, + OutData extends Data, + ProcessFn extends undefined | ((chunk: MaybeStreamDataType) => Promise) = undefined, + FinishFn extends undefined | (() => Promise) = undefined, +>( + input: InputType, + process?: ProcessFn, + finish?: FinishFn, + queuingStrategy?: { highWaterMark: number } +): Promise> : + FinishFn extends undefined ? + ReturnType> : + (ReturnType> | ReturnType>) + >>>; diff --git a/lib/types/v4.7-v5.4/index.d.ts b/lib/types/v4.7-v5.4/index.d.ts index 0ee8066..cff5dcc 100644 --- a/lib/types/v4.7-v5.4/index.d.ts +++ b/lib/types/v4.7-v5.4/index.d.ts @@ -10,6 +10,7 @@ export type NodeWebStream = NodeWebReadableStream; type Stream = WebStream | NodeWebStream; type MaybeStream = T | Stream; +type MaybeStreamDataType> = T extends Stream ? X : T; export function readToEnd any = (chunks: T[]) => T>( input: MaybeStream, @@ -27,3 +28,54 @@ export function slice>( begin: number, end?: number | typeof Infinity ): InputType; // same as 'typeof input' + +type Defined = T extends undefined ? never : T; +type TransformOutputMaybeToStream< + InputType, + CallbackReturnType extends Data | undefined | void +> = InputType extends Data ? + CallbackReturnType : + Stream; +export function transform< + InputType extends MaybeStream, + OutData extends Data, + ProcessFn extends undefined | ((chunk: MaybeStreamDataType) => OutData | undefined | void) = undefined, + FinishFn extends undefined | (() => OutData | undefined | void) = undefined, +>( + input: InputType, + process?: ProcessFn, + finish?: FinishFn, + queuingStrategy?: { highWaterMark: number } +): TransformOutputMaybeToStream< + InputType, + ProcessFn extends undefined ? + FinishFn extends undefined ? + undefined : + // we do not cover the case where FinishFn or ProcessFn are (undefined | function) + ReturnType> : + FinishFn extends undefined ? + ReturnType> : + (ReturnType> | ReturnType>) +>; + +export function transformAsync< + InputType extends MaybeStream, + OutData extends Data, + ProcessFn extends undefined | ((chunk: MaybeStreamDataType) => Promise) = undefined, + FinishFn extends undefined | (() => Promise) = undefined, +>( + input: InputType, + process?: ProcessFn, + finish?: FinishFn, + queuingStrategy?: { highWaterMark: number } +): Promise> : + FinishFn extends undefined ? + ReturnType> : + (ReturnType> | ReturnType>) + >>>; diff --git a/package.json b/package.json index 74a22ff..b33bea7 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "scripts": { "test-type-definitions": "tsc --project test/tsconfig.json && tsx test/typescript.test.ts && npm run test-type-definitions-es5", "test-type-definitions-es5": "tsc --project test/tsconfig.es5.json && tsx --tsconfig test/tsconfig.es5.json test/typescript.test.ts", + "test-type-definitions-legacy": "tsc --project test/tsconfig.v4.7.json && tsx test/typescript.v4.7.test.ts", "test-browser": "karma start karma.conf.cjs", "test-node": "NODE_OPTIONS='--import=tsx' mocha ./test/node.test.ts ./test/common.test.ts", "lint": "eslint lib test eslint.config.js", diff --git a/test/common.test.ts b/test/common.test.ts index 7406910..99ea7c3 100644 --- a/test/common.test.ts +++ b/test/common.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; // @ts-expect-error Missing type definitions -import { toStream, toArrayStream, readToEnd, slice, pipe, ArrayStream, transform, transformAsync } from '@openpgp/web-stream-tools'; +import { toStream, toArrayStream, readToEnd, slice, pipe, ArrayStream, transform, transformAsync, type Stream } from '@openpgp/web-stream-tools'; describe('Common integration tests', () => { it('toStream/readToEnd', async () => { @@ -42,7 +42,7 @@ describe('Common integration tests', () => { it('pipe from arraystream to arraystream', async () => { const input = 'chunk'; - const inputStream = toArrayStream(input); + const inputStream: Stream = toArrayStream(input); const outputStream = new ArrayStream(); pipe(inputStream, outputStream); expect(await readToEnd(outputStream)).to.equal('chunk'); @@ -56,7 +56,7 @@ describe('Common integration tests', () => { it('transform arraystream', async () => { const input = 'chunk'; - const streamedData = toArrayStream(input); + const streamedData: Stream = toArrayStream(input); const transformed = transform(streamedData, (str: string) => str.toUpperCase()); expect(await readToEnd(transformed)).to.equal('CHUNK'); }); @@ -76,7 +76,7 @@ describe('Common integration tests', () => { it('transformAsync arraystream', async () => { const input = 'chunk'; - const streamedData = toArrayStream(input); + const streamedData: Stream = toArrayStream(input); const transformed = await transformAsync(streamedData, async (str: string) => str.toUpperCase()); expect(await readToEnd(transformed)).to.equal('CHUNK'); }); diff --git a/test/tsconfig.v4.7.json b/test/tsconfig.v4.7.json new file mode 100644 index 0000000..18a09f2 --- /dev/null +++ b/test/tsconfig.v4.7.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, // code tested by tsx + }, + "include": ["./typescript.v4.7.test.ts"] +} diff --git a/test/typescript.test.ts b/test/typescript.test.ts index 252ddb4..52df43f 100644 --- a/test/typescript.test.ts +++ b/test/typescript.test.ts @@ -3,8 +3,7 @@ import assert from 'assert'; import { Readable as NodeNativeReadableStream } from 'stream'; import { ReadableStream as NodeWebReadableStream } from 'node:stream/web'; import { ReadableStream as PonyfilledWebReadableStream } from 'web-streams-polyfill'; -import { type WebStream, type NodeWebStream, type Stream, toStream, type Data } from '@openpgp/web-stream-tools'; -import { readToEnd } from '@openpgp/web-stream-tools'; +import { type WebStream, type NodeWebStream, type Stream, toStream, type Data, transform, transformAsync, type MaybeStream, readToEnd } from '@openpgp/web-stream-tools'; // @ts-expect-error missing defs import { ArrayStream, isArrayStream } from '@openpgp/web-stream-tools'; @@ -46,6 +45,45 @@ const newEmptyWebStream = (): WebStream => ( assert(isArrayStream(new ArrayStream())) ; // ensure Array is actually extended in e.g. es5 + const transformDefaultOutput: undefined = transform('string'); + assert(transformDefaultOutput === undefined); + const transformUndefinedOutput: void = transform('string', () => {}); + assert(transformUndefinedOutput === undefined); + const transformStreamUndefinedOutput: Stream = transform(newEmptyWebStream(), () => {}); + assert(transformStreamUndefinedOutput instanceof NodeWebReadableStream); + const transformOutputStreamString: Stream = transform(newEmptyWebStream(), () => ''); + assert(transformOutputStreamString instanceof NodeWebReadableStream); + const transformProcessOutputString: string = transform('string', () => ''); + assert(typeof transformProcessOutputString === 'string'); + const transformConcatOutputString: string = transform('string', () => '', () => ''); + assert(typeof transformConcatOutputString === 'string'); + const transformFinishOutputString: string = transform('string', undefined, () => ''); + assert(typeof transformFinishOutputString === 'string'); + const transformProcessOutputStreamBytes: Stream> = transform( + newEmptyWebStream(), + () => new Uint8Array() + ); + assert(transformProcessOutputStreamBytes instanceof NodeWebReadableStream); + const filterStream = >(stream: S, filterFn: (data: any) => boolean) => ( + transform(stream, (chunk: Data) => filterFn(chunk) ? '' : undefined) + ); + const transformUnionOutput: string | undefined = filterStream('string', () => false); + assert(transformUnionOutput === undefined); + const transformMismatchingProcessFinishOutput: Stream> = + transform(newEmptyWebStream(), () => new Uint8Array(), () => ''); + assert(transformMismatchingProcessFinishOutput instanceof NodeWebReadableStream); + + // @ts-expect-error on async callback + transform(newEmptyWebStream(), async () => 'string'); + const transformAsyncOutputStreamStringToBytes: Stream = await transformAsync( + newEmptyWebStream(), + async () => new Uint8Array(), + async () => new Uint8Array() + ); + assert(transformAsyncOutputStreamStringToBytes instanceof NodeWebReadableStream); + // @ts-expect-error on sync callback + transformAsync(newEmptyWebStream(), async () => new Uint8Array(), () => new Uint8Array()); + console.log('TypeScript definitions are correct'); })().catch(e => { console.error('TypeScript definitions tests failed by throwing the following error'); diff --git a/test/typescript.v4.7.test.ts b/test/typescript.v4.7.test.ts new file mode 100644 index 0000000..03126bc --- /dev/null +++ b/test/typescript.v4.7.test.ts @@ -0,0 +1,81 @@ +/* global process */ +/** + * Tests cases are taken from `typescript.test.ts` but do not include the WebStreamPonyfill + * since the v3.3.3 ponyfill module resolution (required with the legacy TS version) does not work properly. + */ +import assert from 'assert'; +import { Readable as NodeNativeReadableStream } from 'stream'; +import { ReadableStream as NodeWebReadableStream } from 'node:stream/web'; +import { type NodeWebStream,type Stream, toStream, transform, transformAsync, type Data, readToEnd } from '@openpgp/web-stream-tools'; +// @ts-expect-error missing defs +import { ArrayStream, isArrayStream } from '@openpgp/web-stream-tools'; + +const newEmptyTypedNodeWebStream = () => ( + new NodeWebReadableStream({ start(ctrl) { ctrl.close(); } }) as NodeWebStream +); + +(async () => { + const nodeWebStream: NodeWebStream = NodeNativeReadableStream.toWeb(new NodeNativeReadableStream()); + assert(nodeWebStream instanceof NodeWebReadableStream); + // @ts-expect-error detect node stream type mismatch + const nodeNativeStream: NodeWebStream = new NodeNativeReadableStream(); + assert(nodeNativeStream instanceof NodeNativeReadableStream); + + await readToEnd(new Uint8Array([1])) as Uint8Array; + await readToEnd(new Uint8Array([1]), _ => _) as Uint8Array[]; + // @ts-expect-error expect string type + await readToEnd(newEmptyTypedNodeWebStream()) as Uint8Array; + await readToEnd(newEmptyTypedNodeWebStream(), () => new Uint8Array()) as Uint8Array; + + const anotherNodeWebStream: NodeWebStream = toStream(nodeWebStream); + assert(anotherNodeWebStream instanceof NodeWebReadableStream); + // The following type assertion may fail or not depending on the TS and @types/node versions; + // it doesn't fail with TS v4.7 + // // @ts-expect-error expect node stream in output + // const expectedWebStreamButActualNodeStream: WebStream = toStream(nodeWebStream); + // assert(expectedWebStreamButActualNodeStream instanceof NodeWebReadableStream); + const newStringStream: Stream = toStream('chunk'); + assert(newStringStream instanceof NodeWebReadableStream); + // @ts-expect-error detect type parameter mismatch + const anotherStringStream: Stream = toStream('chunk'); + assert(anotherStringStream instanceof NodeWebReadableStream); + + assert(isArrayStream(new ArrayStream())) ; // ensure Array is actually extended in e.g. es5 + + const transformDefaultOutput: undefined = transform('string'); + assert(transformDefaultOutput === undefined); + const transformStreamUndefinedOutput: Stream = transform(newEmptyTypedNodeWebStream(), () => {}); + assert(transformStreamUndefinedOutput instanceof NodeWebReadableStream); + const transformOutputStreamString: Stream = transform(newEmptyTypedNodeWebStream(), () => ''); + assert(transformOutputStreamString instanceof NodeWebReadableStream); + const transformProcessOutputString: string = transform('string', () => ''); + assert(typeof transformProcessOutputString === 'string'); + const transformConcatOutputString: string = transform('string', () => '', () => ''); + assert(typeof transformConcatOutputString === 'string'); + const transformFinishOutputString: string = transform('string', undefined, () => ''); + assert(typeof transformFinishOutputString === 'string'); + const transformProcessOutputStreamBytes: Stream = transform( + newEmptyTypedNodeWebStream(), + () => new Uint8Array() + ); + assert(transformProcessOutputStreamBytes instanceof NodeWebReadableStream); + const transformMismatchingProcessFinishOutput: Stream = + transform(newEmptyTypedNodeWebStream(), () => new Uint8Array(), () => ''); + assert(transformMismatchingProcessFinishOutput instanceof NodeWebReadableStream); + // @ts-expect-error on async callback + transform(newEmptyTypedNodeWebStream(), async () => 'string'); + const transformAsyncOutputStreamStringToBytes: Stream = await transformAsync( + newEmptyTypedNodeWebStream(), + async () => new Uint8Array(), + async () => new Uint8Array() + ); + assert(transformAsyncOutputStreamStringToBytes instanceof NodeWebReadableStream); + // @ts-expect-error on sync callback + transformAsync(newEmptyTypedNodeWebStream(), async () => new Uint8Array(), () => new Uint8Array()); + + console.log('TypeScript definitions are correct'); +})().catch(e => { + console.error('TypeScript definitions tests failed by throwing the following error'); + console.error(e); + process.exit(1); +});