From f6ef267b035a4db49b7d0fce438ca5c5b686f547 Mon Sep 17 00:00:00 2001 From: Zachary Haber Date: Wed, 11 Oct 2023 03:37:13 -0500 Subject: [PATCH] refactor(typings): improve emit types (#4817) This commit fixes several issues with emit types: - calling `emit()` without calling `timeout()` first is now only available for events without acknowledgement - calling `emit()` after calling `timeout()` is now only available for events with an acknowledgement - calling `emitWithAck()` is now only available for events with an acknowledgement - `timeout()` must be called before calling `emitWithAck()` --- lib/broadcast-operator.ts | 19 +- lib/index.ts | 39 +-- lib/namespace.ts | 98 +++---- lib/parent-namespace.ts | 4 +- lib/socket.ts | 7 +- lib/typed-events.ts | 148 +++++++++-- package-lock.json | 89 ++++--- package.json | 2 +- test/socket.io.test-d.ts | 530 +++++++++++++++++++++++++------------- test/socket.ts | 8 +- test/support/util.ts | 8 +- 11 files changed, 631 insertions(+), 321 deletions(-) diff --git a/lib/broadcast-operator.ts b/lib/broadcast-operator.ts index 0dbeebb42d..255f52ded7 100644 --- a/lib/broadcast-operator.ts +++ b/lib/broadcast-operator.ts @@ -8,10 +8,10 @@ import type { EventsMap, TypedEventBroadcaster, DecorateAcknowledgements, - DecorateAcknowledgementsWithTimeoutAndMultipleResponses, AllButLast, Last, - SecondArg, + FirstNonErrorArg, + EventNamesWithError, } from "./typed-events"; export class BroadcastOperator @@ -177,7 +177,7 @@ export class BroadcastOperator public timeout(timeout: number) { const flags = Object.assign({}, this.flags, { timeout }); return new BroadcastOperator< - DecorateAcknowledgementsWithTimeoutAndMultipleResponses, + DecorateAcknowledgements, SocketData >(this.adapter, this.rooms, this.exceptRooms, flags); } @@ -300,10 +300,10 @@ export class BroadcastOperator * * @return a Promise that will be fulfilled when all clients have acknowledged the event */ - public emitWithAck>( + public emitWithAck>( ev: Ev, ...args: AllButLast> - ): Promise>>> { + ): Promise>>> { return new Promise((resolve, reject) => { args.push((err, responses) => { if (err) { @@ -516,11 +516,10 @@ export class RemoteSocket * * @param timeout */ - public timeout(timeout: number) { - return this.operator.timeout(timeout) as BroadcastOperator< - DecorateAcknowledgements, - SocketData - >; + public timeout( + timeout: number + ): BroadcastOperator, SocketData> { + return this.operator.timeout(timeout); } public emit>( diff --git a/lib/index.ts b/lib/index.ts index f5cfedf278..5bd1d03d9b 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -36,8 +36,9 @@ import { DecorateAcknowledgementsWithTimeoutAndMultipleResponses, AllButLast, Last, - FirstArg, - SecondArg, + RemoveAcknowledgements, + EventNamesWithAck, + FirstNonErrorArg, } from "./typed-events"; import { patchAdapter, restoreAdapter, serveFile } from "./uws"; import corsMiddleware from "cors"; @@ -140,7 +141,7 @@ export class Server< SocketData = any > extends StrictEventEmitter< ServerSideEvents, - EmitEvents, + RemoveAcknowledgements, ServerReservedEventsMap< ListenEvents, EmitEvents, @@ -846,26 +847,6 @@ export class Server< return this.sockets.except(room); } - /** - * Emits an event and waits for an acknowledgement from all clients. - * - * @example - * try { - * const responses = await io.timeout(1000).emitWithAck("some-event"); - * console.log(responses); // one response per client - * } catch (e) { - * // some clients did not acknowledge the event in the given delay - * } - * - * @return a Promise that will be fulfilled when all clients have acknowledged the event - */ - public emitWithAck>( - ev: Ev, - ...args: AllButLast> - ): Promise>>> { - return this.sockets.emitWithAck(ev, ...args); - } - /** * Sends a `message` event to all clients. * @@ -882,7 +863,9 @@ export class Server< * @return self */ public send(...args: EventParams): this { - this.sockets.emit("message", ...args); + // This type-cast is needed because EmitEvents likely doesn't have `message` as a key. + // if you specify the EmitEvents, the type of args will be never. + this.sockets.emit("message" as any, ...args); return this; } @@ -892,7 +875,9 @@ export class Server< * @return self */ public write(...args: EventParams): this { - this.sockets.emit("message", ...args); + // This type-cast is needed because EmitEvents likely doesn't have `message` as a key. + // if you specify the EmitEvents, the type of args will be never. + this.sockets.emit("message" as any, ...args); return this; } @@ -948,10 +933,10 @@ export class Server< * * @return a Promise that will be fulfilled when all servers have acknowledged the event */ - public serverSideEmitWithAck>( + public serverSideEmitWithAck>( ev: Ev, ...args: AllButLast> - ): Promise>>[]> { + ): Promise>>[]> { return this.sockets.serverSideEmitWithAck(ev, ...args); } diff --git a/lib/namespace.ts b/lib/namespace.ts index d153a0b23b..b4d3d28ad4 100644 --- a/lib/namespace.ts +++ b/lib/namespace.ts @@ -9,8 +9,12 @@ import { DecorateAcknowledgementsWithTimeoutAndMultipleResponses, AllButLast, Last, - FirstArg, - SecondArg, + DecorateAcknowledgementsWithMultipleResponses, + DecorateAcknowledgements, + RemoveAcknowledgements, + EventNamesWithAck, + FirstNonErrorArg, + EventNamesWithoutAck, } from "./typed-events"; import type { Client } from "./client"; import debugModule from "debug"; @@ -117,7 +121,7 @@ export class Namespace< SocketData = any > extends StrictEventEmitter< ServerSideEvents, - EmitEvents, + RemoveAcknowledgements, NamespaceReservedEventsMap< ListenEvents, EmitEvents, @@ -252,7 +256,10 @@ export class Namespace< * @return a new {@link BroadcastOperator} instance for chaining */ public to(room: Room | Room[]) { - return new BroadcastOperator(this.adapter).to(room); + return new BroadcastOperator< + DecorateAcknowledgementsWithMultipleResponses, + SocketData + >(this.adapter).to(room); } /** @@ -268,7 +275,10 @@ export class Namespace< * @return a new {@link BroadcastOperator} instance for chaining */ public in(room: Room | Room[]) { - return new BroadcastOperator(this.adapter).in(room); + return new BroadcastOperator< + DecorateAcknowledgementsWithMultipleResponses, + SocketData + >(this.adapter).in(room); } /** @@ -290,9 +300,10 @@ export class Namespace< * @return a new {@link BroadcastOperator} instance for chaining */ public except(room: Room | Room[]) { - return new BroadcastOperator(this.adapter).except( - room - ); + return new BroadcastOperator< + DecorateAcknowledgementsWithMultipleResponses, + SocketData + >(this.adapter).except(room); } /** @@ -430,7 +441,7 @@ export class Namespace< * * @return Always true */ - public emit>( + public emit>( ev: Ev, ...args: EventParams ): boolean { @@ -440,30 +451,6 @@ export class Namespace< ); } - /** - * Emits an event and waits for an acknowledgement from all clients. - * - * @example - * const myNamespace = io.of("/my-namespace"); - * - * try { - * const responses = await myNamespace.timeout(1000).emitWithAck("some-event"); - * console.log(responses); // one response per client - * } catch (e) { - * // some clients did not acknowledge the event in the given delay - * } - * - * @return a Promise that will be fulfilled when all clients have acknowledged the event - */ - public emitWithAck>( - ev: Ev, - ...args: AllButLast> - ): Promise>>> { - return new BroadcastOperator( - this.adapter - ).emitWithAck(ev, ...args); - } - /** * Sends a `message` event to all clients. * @@ -482,7 +469,9 @@ export class Namespace< * @return self */ public send(...args: EventParams): this { - this.emit("message", ...args); + // This type-cast is needed because EmitEvents likely doesn't have `message` as a key. + // if you specify the EmitEvents, the type of args will be never. + this.emit("message" as any, ...args); return this; } @@ -492,7 +481,9 @@ export class Namespace< * @return self */ public write(...args: EventParams): this { - this.emit("message", ...args); + // This type-cast is needed because EmitEvents likely doesn't have `message` as a key. + // if you specify the EmitEvents, the type of args will be never. + this.emit("message" as any, ...args); return this; } @@ -557,10 +548,10 @@ export class Namespace< * * @return a Promise that will be fulfilled when all servers have acknowledged the event */ - public serverSideEmitWithAck>( + public serverSideEmitWithAck>( ev: Ev, ...args: AllButLast> - ): Promise>>[]> { + ): Promise>>[]> { return new Promise((resolve, reject) => { args.push((err, responses) => { if (err) { @@ -612,9 +603,10 @@ export class Namespace< * @return self */ public compress(compress: boolean) { - return new BroadcastOperator(this.adapter).compress( - compress - ); + return new BroadcastOperator< + DecorateAcknowledgementsWithMultipleResponses, + SocketData + >(this.adapter).compress(compress); } /** @@ -630,7 +622,10 @@ export class Namespace< * @return self */ public get volatile() { - return new BroadcastOperator(this.adapter).volatile; + return new BroadcastOperator< + DecorateAcknowledgementsWithMultipleResponses, + SocketData + >(this.adapter).volatile; } /** @@ -645,7 +640,10 @@ export class Namespace< * @return a new {@link BroadcastOperator} instance for chaining */ public get local() { - return new BroadcastOperator(this.adapter).local; + return new BroadcastOperator< + DecorateAcknowledgementsWithMultipleResponses, + SocketData + >(this.adapter).local; } /** @@ -664,10 +662,18 @@ export class Namespace< * * @param timeout */ - public timeout(timeout: number) { - return new BroadcastOperator(this.adapter).timeout( - timeout - ); + public timeout( + timeout: number + ): BroadcastOperator< + DecorateAcknowledgements< + DecorateAcknowledgementsWithMultipleResponses + >, + SocketData + > { + return new BroadcastOperator< + DecorateAcknowledgementsWithMultipleResponses, + SocketData + >(this.adapter).timeout(timeout); } /** diff --git a/lib/parent-namespace.ts b/lib/parent-namespace.ts index 14787a060c..f44b052e3d 100644 --- a/lib/parent-namespace.ts +++ b/lib/parent-namespace.ts @@ -2,9 +2,9 @@ import { Namespace } from "./namespace"; import type { Server, RemoteSocket } from "./index"; import type { EventParams, - EventNames, EventsMap, DefaultEventsMap, + EventNamesWithoutAck, } from "./typed-events"; import type { BroadcastOptions } from "socket.io-adapter"; import debugModule from "debug"; @@ -56,7 +56,7 @@ export class ParentNamespace< this.adapter = { broadcast }; } - public emit>( + public emit>( ev: Ev, ...args: EventParams ): boolean { diff --git a/lib/socket.ts b/lib/socket.ts index 0d065c0263..d37e099017 100644 --- a/lib/socket.ts +++ b/lib/socket.ts @@ -7,9 +7,10 @@ import { DecorateAcknowledgementsWithMultipleResponses, DefaultEventsMap, EventNames, + EventNamesWithAck, EventParams, EventsMap, - FirstArg, + FirstNonErrorArg, Last, StrictEventEmitter, } from "./typed-events"; @@ -383,10 +384,10 @@ export class Socket< * * @return a Promise that will be fulfilled when the client acknowledges the event */ - public emitWithAck>( + public emitWithAck>( ev: Ev, ...args: AllButLast> - ): Promise>>> { + ): Promise>>> { // the timeout flag is optional const withErr = this.flags.timeout !== undefined; return new Promise((resolve, reject) => { diff --git a/lib/typed-events.ts b/lib/typed-events.ts index 0dca57a6f6..19f3db6e16 100644 --- a/lib/typed-events.ts +++ b/lib/typed-events.ts @@ -1,5 +1,4 @@ import { EventEmitter } from "events"; - /** * An events map is an interface that maps event names to their value, which * represents the type of the `on` listener. @@ -21,6 +20,62 @@ export interface DefaultEventsMap { */ export type EventNames = keyof Map & (string | symbol); +/** + * Returns a union type containing all the keys of an event map that have an acknowledgement callback. + * + * That also have *some* data coming in. + */ +export type EventNamesWithAck< + Map extends EventsMap, + K extends EventNames = EventNames +> = IfAny< + Last> | Map[K], + K, + K extends ( + Last> extends (...args: any[]) => any + ? FirstNonErrorArg>> extends void + ? never + : K + : never + ) + ? K + : never +>; +/** + * Returns a union type containing all the keys of an event map that have an acknowledgement callback. + * + * That also have *some* data coming in. + */ +export type EventNamesWithoutAck< + Map extends EventsMap, + K extends EventNames = EventNames +> = IfAny< + Last> | Map[K], + K, + K extends ( + Last> extends (...args: any[]) => any ? never : K + ) + ? K + : never +>; + +export type RemoveAcknowledgements = { + [K in EventNamesWithoutAck]: E[K]; +}; + +export type EventNamesWithError< + Map extends EventsMap, + K extends EventNamesWithAck = EventNamesWithAck +> = IfAny< + Last> | Map[K], + K, + K extends ( + LooseParameters>>[0] extends Error ? K : never + ) + ? K + : never +>; + /** The tuple type representing the parameters of an event listener */ export type EventParams< Map extends EventsMap, @@ -178,33 +233,96 @@ export abstract class StrictEventEmitter< >[]; } } +/** +Returns a boolean for whether the given type is `any`. + +@link https://stackoverflow.com/a/49928360/1490091 -export type Last = T extends [...infer H, infer L] ? L : any; +Useful in type utilities, such as disallowing `any`s to be passed to a function. + +@author sindresorhus +@link https://github.com/sindresorhus/type-fest +*/ +type IsAny = 0 extends 1 & T ? true : false; + +/** +An if-else-like type that resolves depending on whether the given type is `any`. + +@see {@link IsAny} + +@author sindresorhus +@link https://github.com/sindresorhus/type-fest +*/ +type IfAny = IsAny extends true + ? TypeIfAny + : TypeIfNotAny; + +/** +Extracts the type of the last element of an array. + +Use-case: Defining the return type of functions that extract the last element of an array, for example [`lodash.last`](https://lodash.com/docs/4.17.15#last). + +@author sindresorhus +@link https://github.com/sindresorhus/type-fest +*/ +export type Last = + ValueType extends readonly [infer ElementType] + ? ElementType + : ValueType extends readonly [infer _, ...infer Tail] + ? Last + : ValueType extends ReadonlyArray + ? ElementType + : never; + +export type FirstNonErrorTuple = T[0] extends Error + ? T[1] + : T[0]; export type AllButLast = T extends [...infer H, infer L] ? H : any[]; -export type FirstArg = T extends (arg: infer Param) => infer Result - ? Param - : any; -export type SecondArg = T extends ( - err: Error, - arg: infer Param -) => infer Result - ? Param +/** + * Like `Parameters`, but doesn't require `T` to be a function ahead of time. + */ +type LooseParameters = T extends (...args: infer P) => any ? P : never; +export type FirstArg = T extends (arg: infer Param) => any ? Param : any; +export type FirstNonErrorArg = T extends (...args: infer Params) => any + ? FirstNonErrorTuple : any; - type PrependTimeoutError = { [K in keyof T]: T[K] extends (...args: infer Params) => infer Result - ? (err: Error, ...args: Params) => Result + ? Params[0] extends Error + ? T[K] + : (err: Error, ...args: Params) => Result : T[K]; }; +export type MultiplyArray = { + [K in keyof T]: T[K][]; +}; +type InferFirstAndPreserveLabel = T extends [any, ...infer R] + ? T extends [...infer H, ...R] + ? H + : never + : never; + +/** + * Utility type to decorate the acknowledgement callbacks multiple values + * on the first non error element while removing any elements after + */ type ExpectMultipleResponses = { - [K in keyof T]: T[K] extends (err: Error, arg: infer Param) => infer Result - ? (err: Error, arg: Param[]) => Result + [K in keyof T]: T[K] extends (...args: infer Params) => infer Result + ? Params extends [Error] + ? (err: Error) => Result + : Params extends [Error, ...infer Rest] + ? ( + err: Error, + ...args: InferFirstAndPreserveLabel> + ) => Result + : Params extends [] + ? () => Result + : (...args: InferFirstAndPreserveLabel>) => Result : T[K]; }; - /** * Utility type to decorate the acknowledgement callbacks with a timeout error. * diff --git a/package-lock.json b/package-lock.json index 11523411c7..162426c90a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "socket.io", - "version": "4.7.1", + "version": "4.7.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "socket.io", - "version": "4.7.1", + "version": "4.7.2", "license": "MIT", "dependencies": { "accepts": "~1.3.4", @@ -29,7 +29,7 @@ "superagent": "^8.0.0", "supertest": "^6.1.6", "ts-node": "^10.2.1", - "tsd": "^0.21.0", + "tsd": "^0.27.0", "typescript": "^4.4.2", "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.30.0" }, @@ -646,14 +646,10 @@ "dev": true }, "node_modules/@tsd/typescript": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-4.7.4.tgz", - "integrity": "sha512-jbtC+RgKZ9Kk65zuRZbKLTACf+tvFW4Rfq0JEMXrlmV3P3yme+Hm+pnb5fJRyt61SjIitcrC810wj7+1tgsEmg==", - "dev": true, - "bin": { - "tsc": "typescript/bin/tsc", - "tsserver": "typescript/bin/tsserver" - } + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-+UgxOvJUl5rQdPFSSOOwhmSmpThm8DJ3HwHxAOq5XYe7CcmG1LcM2QeqWwILzUIT5tbeMqY8qABiCsRtIjk/2g==", + "dev": true }, "node_modules/@types/cookie": { "version": "0.4.1", @@ -1877,6 +1873,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/hasha/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -3199,6 +3204,15 @@ "node": ">=8" } }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/read-pkg/node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -4014,12 +4028,12 @@ } }, "node_modules/tsd": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.21.0.tgz", - "integrity": "sha512-6DugCw1Q4H8HYwDT3itzgALjeDxN4RO3iqu7gRdC/YNVSCRSGXRGQRRasftL1uKDuKxlFffYKHv5j5G7YnKGxQ==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.27.0.tgz", + "integrity": "sha512-G/2Sejk9N21TcuWlHwrvVWwIyIl2mpECFPbnJvFMsFN1xQCIbi2QnvG4fkw3VitFhNF6dy38cXxKJ8Paq8kOGQ==", "dev": true, "dependencies": { - "@tsd/typescript": "~4.7.3", + "@tsd/typescript": "~4.9.5", "eslint-formatter-pretty": "^4.1.0", "globby": "^11.0.1", "meow": "^9.0.0", @@ -4030,16 +4044,7 @@ "tsd": "dist/cli.js" }, "engines": { - "node": ">=12" - } - }, - "node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true, - "engines": { - "node": ">=8" + "node": ">=14.16" } }, "node_modules/typedarray-to-buffer": { @@ -4825,9 +4830,9 @@ "dev": true }, "@tsd/typescript": { - "version": "4.7.4", - "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-4.7.4.tgz", - "integrity": "sha512-jbtC+RgKZ9Kk65zuRZbKLTACf+tvFW4Rfq0JEMXrlmV3P3yme+Hm+pnb5fJRyt61SjIitcrC810wj7+1tgsEmg==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-+UgxOvJUl5rQdPFSSOOwhmSmpThm8DJ3HwHxAOq5XYe7CcmG1LcM2QeqWwILzUIT5tbeMqY8qABiCsRtIjk/2g==", "dev": true }, "@types/cookie": { @@ -5754,6 +5759,14 @@ "requires": { "is-stream": "^2.0.0", "type-fest": "^0.8.0" + }, + "dependencies": { + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } } }, "he": { @@ -6754,6 +6767,12 @@ "requires": { "p-limit": "^2.2.0" } + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true } } }, @@ -7346,12 +7365,12 @@ } }, "tsd": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.21.0.tgz", - "integrity": "sha512-6DugCw1Q4H8HYwDT3itzgALjeDxN4RO3iqu7gRdC/YNVSCRSGXRGQRRasftL1uKDuKxlFffYKHv5j5G7YnKGxQ==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.27.0.tgz", + "integrity": "sha512-G/2Sejk9N21TcuWlHwrvVWwIyIl2mpECFPbnJvFMsFN1xQCIbi2QnvG4fkw3VitFhNF6dy38cXxKJ8Paq8kOGQ==", "dev": true, "requires": { - "@tsd/typescript": "~4.7.3", + "@tsd/typescript": "~4.9.5", "eslint-formatter-pretty": "^4.1.0", "globby": "^11.0.1", "meow": "^9.0.0", @@ -7359,12 +7378,6 @@ "read-pkg-up": "^7.0.0" } }, - "type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true - }, "typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", diff --git a/package.json b/package.json index c11926a451..ed91570d0b 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "superagent": "^8.0.0", "supertest": "^6.1.6", "ts-node": "^10.2.1", - "tsd": "^0.21.0", + "tsd": "^0.27.0", "typescript": "^4.4.2", "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.30.0" }, diff --git a/test/socket.io.test-d.ts b/test/socket.io.test-d.ts index a4e20f0a70..d03f1af7e8 100644 --- a/test/socket.io.test-d.ts +++ b/test/socket.io.test-d.ts @@ -1,10 +1,14 @@ "use strict"; -import { Namespace, Server, Socket } from ".."; -import type { DefaultEventsMap } from "../lib/typed-events"; import { createServer } from "http"; -import { expectError, expectType } from "tsd"; import { Adapter } from "socket.io-adapter"; +import { expectType } from "tsd"; +import { BroadcastOperator, Server, Socket } from "../lib/index"; import type { DisconnectReason } from "../lib/socket"; +import type { + DefaultEventsMap, + EventNamesWithoutAck, + EventsMap, +} from "../lib/typed-events"; // This file is run by tsd, not mocha. @@ -61,8 +65,7 @@ describe("server", () => { } sio.on(Events.CONNECTION, (socket) => { - // TODO(#3833): Make this expect `Socket` - expectType(socket); + expectType>(socket); socket.on("test", (a, b, c) => { expectType(a); @@ -85,6 +88,8 @@ describe("server", () => { const srv = createServer(); const sio = new Server(srv); srv.listen(() => { + sio.emit("random", 1, "2", [3]); + sio.emit("no parameters"); sio.on("connection", (s) => { s.emit("random", 1, "2", [3]); s.emit("no parameters"); @@ -92,6 +97,17 @@ describe("server", () => { }); }); }); + describe("send", () => { + it("accepts any parameters", () => { + const srv = createServer(); + const sio = new Server(srv); + const nio = sio.of("/test"); + sio.send(1, "2", [3]); + sio.send(); + nio.send(1, "2", [3]); + nio.send(); + }); + }); describe("emitWithAck", () => { it("accepts any parameters", () => { @@ -144,79 +160,344 @@ describe("server", () => { const sio = new Server( srv ); - expectError(sio.on("random", (a, b, c) => {})); + // @ts-expect-error - shouldn't accept arguments of the wrong types + sio.on("random", (a, b, c) => {}); srv.listen(() => { - expectError(sio.on("wrong name", (s) => {})); + // @ts-expect-error - shouldn't accept arguments of the wrong types + sio.on("wrong name", (s) => {}); sio.on("connection", (s) => { s.on("random", (a, b, c) => {}); - expectError(s.on("random")); - expectError(s.on("random", (a, b, c, d) => {})); - expectError(s.on(2, 3)); + // @ts-expect-error - shouldn't accept arguments of the wrong types + s.on("random"); + // @ts-expect-error - shouldn't accept arguments of the wrong types + s.on("random", (a, b, c, d) => {}); + // @ts-expect-error - shouldn't accept arguments of the wrong types + s.on(2, 3); }); }); }); }); - + }); + type ToEmit = ( + ev: Ev, + ...args: Parameters + ) => boolean; + type ToEmitWithAck< + Map extends EventsMap, + Ev extends keyof Map = keyof Map + > = (ev: Ev, ...args: Parameters) => ReturnType; + interface ClientToServerEvents { + helloFromClient: (message: string) => void; + ackFromClient: ( + a: string, + b: number, + ack: (c: string, d: number) => void + ) => void; + } + + interface ServerToClientEvents { + helloFromServer: (message: string, x: number) => void; + ackFromServer: ( + a: boolean, + b: string, + ack: (c: boolean, d: string) => void + ) => void; + ackFromServerSingleArg: ( + a: boolean, + b: string, + ack: (c: string) => void + ) => void; + onlyCallback: (a: () => void) => void; + } + // While these could be generated using the types from typed-events, + // it's likely better to just write them out, so that both the types and this are tested properly + interface ServerToClientEventsNoAck { + helloFromServer: (message: string, x: number) => void; + ackFromServer: never; + ackFromServerSingleArg: never; + onlyCallback: never; + } + interface ServerToClientEventsWithError { + helloFromServer: (message: string, x: number) => void; + ackFromServer: ( + a: boolean, + b: string, + ack: (err: Error, c: boolean, d: string) => void + ) => void; + ackFromServerSingleArg: ( + a: boolean, + b: string, + ack: (err: Error, c: string) => void + ) => void; + onlyCallback: (a: (err: Error) => void) => void; + } + + interface ServerToClientEventsWithMultiple { + helloFromServer: (message: string, x: number) => void; + ackFromServer: (a: boolean, b: string, ack: (c: boolean[]) => void) => void; + ackFromServerSingleArg: ( + a: boolean, + b: string, + ack: (c: string[]) => void + ) => void; + onlyCallback: (a: () => void) => void; + } + interface ServerToClientEventsWithMultipleAndError { + helloFromServer: (message: string, x: number) => void; + ackFromServer: ( + a: boolean, + b: string, + ack: (err: Error, c: boolean[]) => void + ) => void; + ackFromServerSingleArg: ( + a: boolean, + b: string, + ack: (err: Error, c: string[]) => void + ) => void; + onlyCallback: (a: (err: Error) => void) => void; + } + interface ServerToClientEventsWithMultipleWithAck { + ackFromServer: (a: boolean, b: string) => Promise; + ackFromServerSingleArg: (a: boolean, b: string) => Promise; + // This should technically be `undefined[]`, but this doesn't work currently *only* with emitWithAck + // you can use an empty callback with emit, but not emitWithAck + onlyCallback: () => Promise; + } + interface ServerToClientEventsWithAck { + ackFromServer: (a: boolean, b: string) => Promise; + ackFromServerSingleArg: (a: boolean, b: string) => Promise; + // This doesn't work currently *only* with emitWithAck + // you can use an empty callback with emit, but not emitWithAck + onlyCallback: () => Promise; + } + describe("Emitting Types", () => { + describe("send", () => { + it("prevents arguments if EmitEvents doesn't have message", () => { + const sio = new Server(); + const nio = sio.of("/test"); + // @ts-expect-error - ServerToClientEvents doesn't have a message event + sio.send(1, "2", [3]); + // @ts-expect-error - ServerToClientEvents doesn't have a message event + nio.send(1, "2", [3]); + // This should likely be an error, but I don't know how to make it one + sio.send(); + nio.send(); + }); + it("has the correct types", () => { + const sio = new Server< + {}, + { message: (a: number, b: string, c: number[]) => void } + >(); + const nio = sio.of("/test"); + sio.send(1, "2", [3]); + nio.send(1, "2", [3]); + // @ts-expect-error - message requires arguments + sio.send(); + // @ts-expect-error - message requires arguments + nio.send(); + // @ts-expect-error - message requires the correct arguments + sio.send(1, 2, [3]); + // @ts-expect-error - message requires the correct arguments + nio.send(1, 2, [3]); + }); + }); + describe("Broadcast Operator", () => { + it("works untyped", () => { + const untyped = new Server(); + untyped.emit("random", 1, 2, Function, Boolean); + untyped.of("/").emit("random2", 2, "string", Server); + expectType>(untyped.to("1").emitWithAck("random", "test")); + expectType<(ev: string, ...args: any[]) => Promise>( + untyped.to("1").emitWithAck + ); + }); + it("has the correct types", () => { + // Ensuring that all paths to BroadcastOperator have the correct types + // means that we only need one set of tests for emitting once the + // socket/namespace/server becomes a broadcast emitter + const sio = new Server(); + const nio = sio.of("/"); + for (const emitter of [sio, nio]) { + expectType>( + emitter.to("1") + ); + expectType>( + emitter.in("1") + ); + expectType>( + emitter.except("1") + ); + expectType>( + emitter.except("1") + ); + expectType>( + emitter.compress(true) + ); + expectType>( + emitter.volatile + ); + expectType>( + emitter.local + ); + expectType< + BroadcastOperator + >(emitter.timeout(0)); + expectType< + BroadcastOperator + >(emitter.timeout(0).timeout(0)); + } + sio.on("connection", (s) => { + expectType< + Socket< + ClientToServerEvents, + ServerToClientEventsWithError, + DefaultEventsMap, + any + > + >(s.timeout(0)); + expectType< + BroadcastOperator + >(s.timeout(0).broadcast); + // ensure that turning socket to a broadcast works correctly + expectType>( + s.broadcast + ); + expectType>( + s.in("1") + ); + expectType>( + s.except("1") + ); + expectType>( + s.to("1") + ); + // Ensure that adding a timeout to a broadcast works after the fact + expectType< + BroadcastOperator + >(s.broadcast.timeout(0)); + // Ensure that adding a timeout to a broadcast works after the fact + expectType< + BroadcastOperator + >(s.broadcast.timeout(0).timeout(0)); + }); + }); + it("has the correct types for `emit`", () => { + const sio = new Server(); + expectType< + ToEmit + >(sio.timeout(0).emit<"helloFromServer">); + expectType< + ToEmit< + ServerToClientEventsWithMultipleAndError, + "ackFromServerSingleArg" + > + >(sio.timeout(0).emit<"ackFromServerSingleArg">); + expectType< + ToEmit + >(sio.timeout(0).emit<"ackFromServer">); + expectType< + ToEmit + >(sio.timeout(0).emit<"onlyCallback">); + }); + it("has the correct types for `emitWithAck`", () => { + const sio = new Server(); + const sansTimeout = sio.in("1"); + // Without timeout, `emitWithAck` shouldn't accept any events + expectType( + undefined as Parameters[0] + ); + // @ts-expect-error - "helloFromServer" doesn't have a callback and is thus excluded + sio.timeout(0).emitWithAck("helloFromServer"); + // @ts-expect-error - "onlyCallback" doesn't have a callback and is thus excluded + sio.timeout(0).emitWithAck("onlyCallback"); + expectType< + ToEmitWithAck< + ServerToClientEventsWithMultipleWithAck, + "ackFromServerSingleArg" + > + >(sio.timeout(0).emitWithAck<"ackFromServerSingleArg">); + expectType< + ToEmitWithAck< + ServerToClientEventsWithMultipleWithAck, + "ackFromServer" + > + >(sio.timeout(0).emitWithAck<"ackFromServer">); + }); + }); describe("emit", () => { - it("accepts arguments of the correct types", () => { - const srv = createServer(); - const sio = new Server(srv); - srv.listen(() => { - sio.on("connection", (s) => { - s.emit("random", 1, "2", [3]); - }); + it("Infers correct types", () => { + const sio = new Server(); + const nio = sio.of("/test"); + + expectType>( + // These errors will dissapear once the TS version is updated from 4.7.4 + // the TSD instance is using a newer version of TS than the workspace version + // to enable the ability to compare against `any` + sio.emit<"helloFromServer"> + ); + expectType>( + nio.emit<"helloFromServer"> + ); + sio.on("connection", (s) => { + expectType>( + s.emit<"helloFromServer"> + ); + expectType>( + s.emit<"ackFromServerSingleArg"> + ); + expectType>( + s.emit<"ackFromServer"> + ); + expectType>( + s.emit<"onlyCallback"> + ); }); }); - - it("does not accept arguments of the wrong types", () => { - const srv = createServer(); - const sio = new Server(srv); - srv.listen(() => { - sio.on("connection", (s) => { - expectError(s.emit("noParameter", 2)); - expectError(s.emit("oneParameter")); - expectError(s.emit("random")); - expectError(s.emit("oneParameter", 2, 3)); - expectError(s.emit("random", (a, b, c) => {})); - expectError(s.emit("wrong name", () => {})); - expectError(s.emit("complicated name with spaces", 2)); - }); + it("does not allow events with acks", () => { + const sio = new Server(); + const nio = sio.of("/test"); + // @ts-expect-error - "ackFromServerSingleArg" has a callback and is thus excluded + sio.emit<"ackFromServerSingleArg">; + // @ts-expect-error - "ackFromServer" has a callback and is thus excluded + sio.emit<"ackFromServer">; + // @ts-expect-error - "onlyCallback" has a callback and is thus excluded + sio.emit<"onlyCallback">; + // @ts-expect-error - "ackFromServerSingleArg" has a callback and is thus excluded + nio.emit<"ackFromServerSingleArg">; + // @ts-expect-error - "ackFromServer" has a callback and is thus excluded + nio.emit<"ackFromServer">; + // @ts-expect-error - "onlyCallback" has a callback and is thus excluded + nio.emit<"onlyCallback">; + }); + }); + describe("emitWithAck", () => { + it("Infers correct types", () => { + const sio = new Server(); + sio.on("connection", (s) => { + // @ts-expect-error - "helloFromServer" doesn't have a callback and is thus excluded + s.emitWithAck("helloFromServer"); + // @ts-expect-error - "onlyCallback" doesn't have a callback and is thus excluded + s.emitWithAck("onlyCallback"); + // @ts-expect-error - "onlyCallback" doesn't have a callback and is thus excluded + s.timeout(0).emitWithAck("onlyCallback"); + expectType< + ToEmitWithAck + >(s.emitWithAck<"ackFromServerSingleArg">); + expectType< + ToEmitWithAck + >(s.emitWithAck<"ackFromServer">); + + expectType< + ToEmitWithAck + >(s.timeout(0).emitWithAck<"ackFromServerSingleArg">); + expectType< + ToEmitWithAck + >(s.timeout(0).emitWithAck<"ackFromServer">); }); }); }); }); - describe("listen and emit event maps", () => { - interface ClientToServerEvents { - helloFromClient: (message: string) => void; - ackFromClient: ( - a: string, - b: number, - ack: (c: string, d: number) => void - ) => void; - } - - interface ServerToClientEvents { - helloFromServer: (message: string, x: number) => void; - ackFromServer: ( - a: boolean, - b: string, - ack: (c: boolean, d: string) => void - ) => void; - - ackFromServerSingleArg: ( - a: boolean, - b: string, - ack: (c: string) => void - ) => void; - - multipleAckFromServer: ( - a: boolean, - b: string, - ack: (c: string) => void - ) => void; - } - describe("on", () => { it("infers correct types for listener parameters", (done) => { const srv = createServer(); @@ -245,117 +526,10 @@ describe("server", () => { const sio = new Server(srv); srv.listen(() => { sio.on("connection", (s) => { - expectError( - s.on("helloFromServer", (message, number) => { - done(); - }) - ); - }); - }); - }); - }); - - describe("emit", () => { - it("accepts arguments of the correct types", (done) => { - const srv = createServer(); - const sio = new Server(srv); - srv.listen(() => { - sio.emit("helloFromServer", "hi", 1); - sio.to("room").emit("helloFromServer", "hi", 1); - sio.timeout(1000).emit("helloFromServer", "hi", 1); - - sio - .timeout(1000) - .emit("multipleAckFromServer", true, "123", (err, c) => { - expectType(err); - expectType(c); - }); - - sio.on("connection", (s) => { - s.emit("helloFromServer", "hi", 10); - - s.emit("ackFromServer", true, "123", (c, d) => { - expectType(c); - expectType(d); - }); - - s.timeout(1000).emit("ackFromServer", true, "123", (err, c, d) => { - expectType(err); - expectType(c); - expectType(d); + // @ts-expect-error - shouldn't accept emit events + s.on("helloFromServer", (message, number) => { + done(); }); - - s.timeout(1000) - .to("room") - .emit("multipleAckFromServer", true, "123", (err, c) => { - expectType(err); - expectType(c); - }); - - s.to("room") - .timeout(1000) - .emit("multipleAckFromServer", true, "123", (err, c) => { - expectType(err); - expectType(c); - }); - - done(); - }); - }); - }); - - it("does not accept arguments of wrong types", (done) => { - const srv = createServer(); - const sio = new Server(srv); - srv.listen(() => { - expectError(sio.emit("helloFromClient")); - expectError(sio.to("room").emit("helloFromClient")); - expectError(sio.timeout(1000).to("room").emit("helloFromClient")); - - sio.on("connection", (s) => { - expectError(s.emit("helloFromClient", "hi")); - expectError(s.emit("helloFromServer", "hi", 10, "10")); - expectError(s.emit("helloFromServer", "hi", "10")); - expectError(s.emit("helloFromServer", 0, 0)); - expectError(s.emit("wrong name", 10)); - expectError(s.emit("wrong name")); - done(); - }); - }); - }); - }); - - describe("emitWithAck", () => { - it("accepts arguments of the correct types", (done) => { - const srv = createServer(); - const sio = new Server(srv); - srv.listen(async () => { - const value = await sio - .timeout(1000) - .emitWithAck("multipleAckFromServer", true, "123"); - expectType(value); - - sio.on("connection", async (s) => { - const value1 = await s - .timeout(1000) - .to("room") - .emitWithAck("multipleAckFromServer", true, "123"); - expectType(value1); - - const value2 = await s - .to("room") - .timeout(1000) - .emitWithAck("multipleAckFromServer", true, "123"); - expectType(value2); - - const value3 = await s.emitWithAck( - "ackFromServerSingleArg", - true, - "123" - ); - expectType(value3); - - done(); }); }); }); @@ -403,6 +577,13 @@ describe("server", () => { expectType(x); }); + //@ts-expect-error - "helloFromServerToServer" does not have a callback + sio.serverSideEmitWithAck("helloFromServerToServer", "hello"); + + sio.on("ackFromServerToServer", (...args) => { + expectType<[string, (bar: number) => void]>(args); + }); + sio.serverSideEmit("ackFromServerToServer", "foo", (err, bar) => { expectType(err); expectType(bar); @@ -440,7 +621,8 @@ describe("server", () => { it("does not accept arguments of wrong types", () => { const io = new Server(); - expectError(io.adapter((nsp) => "nope")); + // @ts-expect-error - shouldn't accept arguments of the wrong types + io.adapter((nsp) => "nope"); }); }); }); diff --git a/test/socket.ts b/test/socket.ts index 10e2924a42..f9c93ff00b 100644 --- a/test/socket.ts +++ b/test/socket.ts @@ -606,9 +606,11 @@ describe("socket", () => { }); it("should emit an event and wait for the acknowledgement", (done) => { - const io = new Server(0); - const socket = createClient(io); - + type Events = { + hi: (a: number, b: number, cb: (c: number) => void) => void; + }; + const io = new Server<{}, Events>(0); + const socket = createClient(io); io.on("connection", async (s) => { socket.on("hi", (a, b, fn) => { expect(a).to.be(1); diff --git a/test/support/util.ts b/test/support/util.ts index 4467159e3a..f44fedb480 100644 --- a/test/support/util.ts +++ b/test/support/util.ts @@ -6,6 +6,7 @@ import { SocketOptions, } from "socket.io-client"; import request from "supertest"; +import type { DefaultEventsMap, EventsMap } from "../../lib/typed-events"; const expect = require("expect.js"); const i = expect.stringify; @@ -30,11 +31,14 @@ expect.Assertion.prototype.contain = function (...args) { return contain.apply(this, args); }; -export function createClient( +export function createClient< + CTS extends EventsMap = DefaultEventsMap, + STC extends EventsMap = DefaultEventsMap +>( io: Server, nsp: string = "/", opts?: Partial -): ClientSocket { +): ClientSocket { // @ts-ignore const port = io.httpServer.address().port; return ioc(`http://localhost:${port}${nsp}`, opts);