From 47b979d57388e9b5e9a332f3f4a9873211f0d844 Mon Sep 17 00:00:00 2001 From: Damien Arrachequesne Date: Mon, 30 Jan 2023 07:56:44 +0100 Subject: [PATCH] feat: add promise-based acknowledgements This commit adds some syntactic sugar around acknowledgements: ```js // without timeout const response = await socket.emitWithAck("hello", "world"); // with a specific timeout try { const response = await socket.timeout(1000).emitWithAck("hello", "world"); } catch (err) { // the server did not acknowledge the event in the given delay } ``` Note: enviroments that do not support Promises ([1]) will need to add a polyfill in order to use this feature See also: https://github.com/socketio/socket.io/commit/184f3cf7af57acc4b0948eee307f25f8536eb6c8 [1]: https://caniuse.com/promises --- lib/socket.ts | 42 +++++++++++++++++++++++++++++++++++ test/socket.ts | 38 ++++++++++++++++++++++++++++++++ test/typed-events.test-d.ts | 44 ++++++++++++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/lib/socket.ts b/lib/socket.ts index b4df804f..1689f4ac 100644 --- a/lib/socket.ts +++ b/lib/socket.ts @@ -43,6 +43,14 @@ export type DecorateAcknowledgements = { : E[K]; }; +export type Last = T extends [...infer H, infer L] ? L : any; +export type AllButLast = T extends [...infer H, infer L] + ? H + : any[]; +export type FirstArg = T extends (arg: infer Param) => infer Result + ? Param + : any; + export interface SocketOptions { /** * the authentication payload sent when connecting to the Namespace @@ -407,6 +415,40 @@ export class Socket< }; } + /** + * Emits an event and waits for an acknowledgement + * + * @example + * // without timeout + * const response = await socket.emitWithAck("hello", "world"); + * + * // with a specific timeout + * try { + * const response = await socket.timeout(1000).emitWithAck("hello", "world"); + * } catch (err) { + * // the server did not acknowledge the event in the given delay + * } + * + * @return a Promise that will be fulfilled when the server acknowledges the event + */ + public emitWithAck>( + ev: Ev, + ...args: AllButLast> + ): Promise>>> { + // the timeout flag is optional + const withErr = this.flags.timeout !== undefined; + return new Promise((resolve, reject) => { + args.push((arg1, arg2) => { + if (withErr) { + return arg1 ? reject(arg1) : resolve(arg2); + } else { + return resolve(arg1); + } + }); + this.emit(ev, ...(args as any[] as EventParams)); + }); + } + /** * Sends a packet. * diff --git a/test/socket.ts b/test/socket.ts index 289b072c..13dc1dec 100644 --- a/test/socket.ts +++ b/test/socket.ts @@ -330,6 +330,17 @@ describe("socket", () => { }); }); + it("should emit an event and wait for the acknowledgement", () => { + return wrap(async (done) => { + const socket = io(BASE_URL, { forceNew: true }); + + const val = await socket.emitWithAck("echo", 123); + expect(val).to.be(123); + + success(done, socket); + }); + }); + describe("volatile packets", () => { it("should discard a volatile packet when the socket is not connected", () => { return wrap((done) => { @@ -561,5 +572,32 @@ describe("socket", () => { }); }); }); + + it("should timeout when the server does not acknowledge the event (promise)", () => { + return wrap(async (done) => { + const socket = io(BASE_URL + "/"); + + try { + await socket.timeout(50).emitWithAck("unknown"); + expect.fail(); + } catch (e) { + success(done, socket); + } + }); + }); + + it("should not timeout when the server does acknowledge the event (promise)", () => { + return wrap(async (done) => { + const socket = io(BASE_URL + "/"); + + try { + const value = await socket.timeout(50).emitWithAck("echo", 42); + expect(value).to.be(42); + success(done, socket); + } catch (e) { + expect.fail(); + } + }); + }); }); }); diff --git a/test/typed-events.test-d.ts b/test/typed-events.test-d.ts index 99d46f27..89d59e37 100644 --- a/test/typed-events.test-d.ts +++ b/test/typed-events.test-d.ts @@ -1,6 +1,7 @@ import { io, Socket } from ".."; import type { DefaultEventsMap } from "@socket.io/component-emitter"; import { expectError, expectType } from "tsd"; +import { createServer } from "http"; // This file is run by tsd, not mocha. @@ -54,7 +55,7 @@ describe("typed events", () => { }); describe("emit", () => { - it("accepts any parameters", () => { + it("accepts any parameters", async () => { const socket = io(); socket.emit("random", 1, "2", [3]); @@ -72,6 +73,24 @@ describe("typed events", () => { }); }); }); + + describe("emitWithAck", () => { + it("accepts any parameters", async () => { + const socket = io(); + + const value = await socket.emitWithAck( + "ackFromClientSingleArg", + "1", + 2 + ); + expectType(value); + + const value2 = await socket + .timeout(1000) + .emitWithAck("ackFromClientSingleArg", "3", 4); + expectType(value2); + }); + }); }); describe("single event map", () => { @@ -127,6 +146,11 @@ describe("typed events", () => { b: number, ack: (c: string, d: boolean) => void ) => void; + ackFromClientSingleArg: ( + a: string, + b: number, + ack: (c: string) => void + ) => void; ackFromClientNoArg: (ack: () => void) => void; } @@ -189,5 +213,23 @@ describe("typed events", () => { expectError(socket.emit("wrong name")); }); }); + + describe("emitWithAck", () => { + it("accepts arguments of the correct types", async () => { + const socket: Socket = io(); + + const value = await socket.emitWithAck( + "ackFromClientSingleArg", + "1", + 2 + ); + expectType(value); + + const value2 = await socket + .timeout(1000) + .emitWithAck("ackFromClientSingleArg", "3", 4); + expectType(value2); + }); + }); }); });