diff --git a/src/actions/client.ts b/src/actions/client.ts index 35b72d7b..1320400d 100644 --- a/src/actions/client.ts +++ b/src/actions/client.ts @@ -69,7 +69,7 @@ export class ActionClient { * @returns A disposable that, when disposed, removes the listener. */ public onDialDown = object>(listener: (ev: DialDownEvent) => void): IDisposable { - return this.connection.addDisposableListener("dialDown", (ev: api.DialDown) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); + return this.connection.disposableOn("dialDown", (ev: api.DialDown) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); } /** @@ -79,7 +79,7 @@ export class ActionClient { * @returns A disposable that, when disposed, removes the listener. */ public onDialRotate = object>(listener: (ev: DialRotateEvent) => void): IDisposable { - return this.connection.addDisposableListener("dialRotate", (ev: api.DialRotate) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); + return this.connection.disposableOn("dialRotate", (ev: api.DialRotate) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); } /** @@ -91,7 +91,7 @@ export class ActionClient { * @returns A disposable that, when disposed, removes the listener. */ public onDialUp = object>(listener: (ev: DialUpEvent) => void): IDisposable { - return this.connection.addDisposableListener("dialUp", (ev: api.DialUp) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); + return this.connection.disposableOn("dialUp", (ev: api.DialUp) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); } /** @@ -103,7 +103,7 @@ export class ActionClient { * @returns A disposable that, when disposed, removes the listener. */ public onKeyDown = object>(listener: (ev: KeyDownEvent) => void): IDisposable { - return this.connection.addDisposableListener("keyDown", (ev: api.KeyDown) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); + return this.connection.disposableOn("keyDown", (ev: api.KeyDown) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); } /** @@ -115,7 +115,7 @@ export class ActionClient { * @returns A disposable that, when disposed, removes the listener. */ public onKeyUp = object>(listener: (ev: KeyUpEvent) => void): IDisposable { - return this.connection.addDisposableListener("keyUp", (ev: api.KeyUp) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); + return this.connection.disposableOn("keyUp", (ev: api.KeyUp) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); } /** @@ -125,7 +125,7 @@ export class ActionClient { * @returns A disposable that, when disposed, removes the listener. */ public onTitleParametersDidChange = object>(listener: (ev: TitleParametersDidChangeEvent) => void): IDisposable { - return this.connection.addDisposableListener("titleParametersDidChange", (ev: api.TitleParametersDidChange) => + return this.connection.disposableOn("titleParametersDidChange", (ev: api.TitleParametersDidChange) => listener(new ActionEvent>(new Action(this.connection, ev), ev)) ); } @@ -137,7 +137,7 @@ export class ActionClient { * @returns A disposable that, when disposed, removes the listener. */ public onTouchTap = object>(listener: (ev: TouchTapEvent) => void): IDisposable { - return this.connection.addDisposableListener("touchTap", (ev: api.TouchTap) => + return this.connection.disposableOn("touchTap", (ev: api.TouchTap) => listener(new ActionEvent>(new Action(this.connection, ev), ev)) ); } @@ -150,7 +150,7 @@ export class ActionClient { * @returns A disposable that, when disposed, removes the listener. */ public onWillAppear = object>(listener: (ev: WillAppearEvent) => void): IDisposable { - return this.connection.addDisposableListener("willAppear", (ev: api.WillAppear) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); + return this.connection.disposableOn("willAppear", (ev: api.WillAppear) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); } /** @@ -161,9 +161,7 @@ export class ActionClient { * @returns A disposable that, when disposed, removes the listener. */ public onWillDisappear = object>(listener: (ev: WillDisappearEvent) => void): IDisposable { - return this.connection.addDisposableListener("willDisappear", (ev: api.WillDisappear) => - listener(new ActionEvent>(new Action(this.connection, ev), ev)) - ); + return this.connection.disposableOn("willDisappear", (ev: api.WillDisappear) => listener(new ActionEvent>(new Action(this.connection, ev), ev))); } /** diff --git a/src/common/__tests__/event-emitter.test.ts b/src/common/__tests__/event-emitter.test.ts new file mode 100644 index 00000000..bde2ae6b --- /dev/null +++ b/src/common/__tests__/event-emitter.test.ts @@ -0,0 +1,373 @@ +import { EventEmitter } from "../event-emitter"; + +describe("EventEmitter", () => { + describe("adding listeners", () => { + /** + * Asserts adding a listener with {@link EventEmitter.addListener}. + */ + test("addListener", () => { + // Arrange. + const emitter = new EventEmitter(); + const listener = jest.fn(); + + // Act. + emitter.addListener("message", listener); + + // Assert. + emitter.emit("message", "First"); + emitter.emit("message", "Second"); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenNthCalledWith(1, "First"); + expect(listener).toHaveBeenNthCalledWith(2, "Second"); + }); + + /** + * Asserts adding a listener with {@link EventEmitter.on}. + */ + test("on", () => { + // Arrange. + const emitter = new EventEmitter(); + const listener = jest.fn(); + + // Act. + emitter.on("message", listener); + + // Assert. + emitter.emit("message", "First"); + emitter.emit("message", "Second"); + + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenNthCalledWith(1, "First"); + expect(listener).toHaveBeenNthCalledWith(2, "Second"); + }); + + /** + * Asserts adding a listener with {@link EventEmitter.once}. + */ + test("once", () => { + // Arrange. + const emitter = new EventEmitter(); + const listener = jest.fn(); + + // Act. + emitter.once("message", listener); + + // Assert. + emitter.emit("message", "First"); + emitter.emit("message", "Second"); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith("First"); + }); + }); + + describe("disposable listeners", () => { + /** + * Asserts the {@link EventEmitter.disposableOn} adds the event listener. + */ + it("adds the listener", async () => { + // Arrange. + const emitter = new EventEmitter(); + const listener = jest.fn(); + + // Act. + emitter.disposableOn("message", listener); + emitter.emit("message", "Hello world"); + + // Assert. + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith("Hello world"); + }); + + /** + * Asserts listeners added via {@link EventEmitter.disposableOn} can be removed by disposing. + */ + it("can remove after emitting", async () => { + // Arrange. + const emitter = new EventEmitter(); + const listener = jest.fn(); + + // Act. + { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + using handler = emitter.disposableOn("message", listener); + emitter.emit("message", "One"); + } + + emitter.emit("message", "Two"); + + // Assert. + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenNthCalledWith(1, "One"); + }); + + /** + * Asserts the event listener is removed when disposing the result {@link EventEmitter.disposableOn} via `dispose()`. + */ + it("dispose", async () => { + // Arrange. + const emitter = new EventEmitter(); + const listener = jest.fn(); + const handler = emitter.disposableOn("message", listener); + + // Act. + handler.dispose(); + emitter.emit("message", "Hello world"); + + // Assert. + expect(listener).not.toHaveBeenCalled(); + }); + + /** + * Asserts the event listener is removed when disposing the result {@link EventEmitter.disposableOn} via `[Symbol.dispose()]` + */ + it("[Symbol.dispose]", async () => { + // Arrange. + const emitter = new EventEmitter(); + const listener = jest.fn(); + + // Act. + { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + using handler = emitter.disposableOn("message", listener); + } + + emitter.emit("message", "Hello world"); + + // Assert. + expect(listener).not.toHaveBeenCalled(); + }); + }); + + /** + * Asserts emitting an event with {@link EventEmitter.emit}. + */ + it("emits to all listeners", () => { + // Arrange. + const emitter = new EventEmitter(); + const [listener, other] = [jest.fn(), jest.fn(), jest.fn(), jest.fn()]; + + emitter.addListener("message", listener); + emitter.addListener("message", listener); + emitter.addListener("message", listener); + emitter.addListener("other", other); + + emitter.emit("message", "Hello world"); + + expect(listener).toHaveBeenCalledTimes(3); + expect(listener).toHaveBeenCalledWith("Hello world"); + expect(other).toBeCalledTimes(0); + }); + + /** + * Asserts getting event names with listeners with {@link EventEmitter.eventNames}. + */ + test("eventNames", () => { + // Arrange. + const emitter = new EventEmitter(); + const listener = jest.fn(); + + // Act, assert - no events. + expect(emitter.eventNames()).toStrictEqual([]); + + // Act, assert - "message" event. + emitter.addListener("message", listener); + expect(emitter.eventNames()).toStrictEqual(["message"]); + + // Act, assert - "message" and "other" event. + emitter.addListener("other", listener); + expect(emitter.eventNames()).toStrictEqual(["message", "other"]); + }); + + describe("listenerCount", () => { + /** + * Asserts the listener count with {@link EventEmitter.listenerCount} when a listener is defined. + */ + it("with listener", () => { + // Arrange. + const emitter = new EventEmitter(); + const listener = jest.fn(); + + emitter.addListener("message", listener); + emitter.addListener("message", listener); + emitter.addListener("message", jest.fn()); + emitter.addListener("other", jest.fn()); + + // Act, assert. + expect(emitter.listenerCount("message", listener)).toBe(2); + expect(emitter.listenerCount("message", listener)).toBe(2); + }); + + /** + * Asserts the listener count with {@link EventEmitter.listenerCount} when a listener is not defined. + */ + it("without listener", () => { + // Arrange. + const emitter = new EventEmitter(); + + emitter.addListener("message", jest.fn()); + emitter.addListener("message", jest.fn()); + emitter.addListener("message", jest.fn()); + emitter.addListener("other", jest.fn()); + + // Act, assert. + expect(emitter.listenerCount("message")).toBe(3); + expect(emitter.listenerCount("other")).toBe(1); + expect(emitter.listenerCount("another")).toBe(0); + }); + }); + + /** + * Asserts getting event listeners with {@link EventEmitter.listeners}. + */ + test("listeners", () => { + // Arrange. + const emitter = new EventEmitter(); + const [one, two] = [jest.fn(), jest.fn()]; + + emitter.addListener("message", one); + emitter.addListener("message", two); + emitter.addListener("message", two); + + // Act, assert. + expect(emitter.listeners("message")).toStrictEqual([one, two, two]); + expect(emitter.listeners("other")).toStrictEqual([]); + }); + + describe("prepending listeners", () => { + /** + * Asserts prepending a listener with {@link EventEmitter.prependListener}. + */ + test("prependListener", () => { + // Arrange. + const emitter = new EventEmitter(); + const [on, prepend] = [jest.fn(), jest.fn()]; + + const order: unknown[] = []; + on.mockImplementation(() => order.push(on)); + prepend.mockImplementation(() => order.push(prepend)); + + // Act. + emitter.on("message", on); + emitter.prependListener("message", prepend); + + // Assert. + emitter.emit("message", "Hello world"); + + expect(on).toHaveBeenCalledTimes(1); + expect(on).toBeCalledWith("Hello world"); + expect(prepend).toHaveBeenCalledTimes(1); + expect(prepend).toBeCalledWith("Hello world"); + expect(order).toStrictEqual([prepend, on]); + }); + + /** + * Asserts prepending a listener with {@link EventEmitter.prependOnceListener}. + */ + test("prependOnceListener", () => { + // Arrange. + const emitter = new EventEmitter(); + const [on, prepend] = [jest.fn(), jest.fn()]; + + const order: unknown[] = []; + on.mockImplementation(() => { + order.push(on); + console.log("ON"); + }); + prepend.mockImplementation(() => order.push(prepend)); + + // Act. + emitter.on("message", on); + emitter.prependOnceListener("message", prepend); + + // Assert. + emitter.emit("message", "Hello world"); + emitter.emit("message", "Hello world"); + + expect(on).toHaveBeenCalledTimes(2); + expect(on).toBeCalledWith("Hello world"); + expect(prepend).toHaveBeenCalledTimes(1); + expect(prepend).toBeCalledWith("Hello world"); + expect(order).toStrictEqual([prepend, on, on]); + }); + }); + + describe("removing listeners", () => { + /** + * Asserts removing all listeners with {@link EventEmitter.off}. + */ + test("off", () => { + // Arrange. + const emitter = new EventEmitter(); + const [one, two] = [jest.fn(), jest.fn()]; + + emitter.off("message", one); // Assert removing before any are added. + + emitter.on("message", one); + emitter.on("message", two); + emitter.on("other", one); + emitter.on("other", two); + + // Act. + emitter.off("message", one); + + // Assert. + emitter.emit("message", "Hello world"); + expect(one).not.toHaveBeenCalled(); + expect(two).toHaveBeenCalledTimes(1); + expect(two).toHaveBeenCalledWith("Hello world"); + }); + + /** + * Asserts removing all listeners with {@link EventEmitter.removeAllListeners}. + */ + test("removeAllListeners", () => { + // Arrange. + const emitter = new EventEmitter(); + const [one, two] = [jest.fn(), jest.fn()]; + + emitter.on("message", one); + emitter.on("message", two); + emitter.on("other", one); + emitter.on("other", two); + + // Act. + emitter.removeAllListeners("message"); + + // Assert. + emitter.emit("message", "Hello world"); + expect(one).not.toHaveBeenCalled(); + expect(two).not.toHaveBeenCalled(); + }); + + /** + * Asserts removing a listener with {@link EventEmitter.removeListener}. + */ + test("removeListener", () => { + // Arrange. + const emitter = new EventEmitter(); + const [one, two] = [jest.fn(), jest.fn()]; + + emitter.on("message", one); + emitter.on("message", two); + emitter.on("other", one); + emitter.on("other", two); + + // Act. + emitter.removeListener("message", one); + + // Assert. + emitter.emit("message", "Hello world"); + expect(one).not.toHaveBeenCalled(); + expect(two).toHaveBeenCalledTimes(1); + expect(two).toHaveBeenCalledWith("Hello world"); + }); + }); +}); + +type EventMap = { + message: [message: string]; + other: [id: number]; + another: [id: number]; +}; diff --git a/src/common/event-emitter.ts b/src/common/event-emitter.ts new file mode 100644 index 00000000..3f5494fb --- /dev/null +++ b/src/common/event-emitter.ts @@ -0,0 +1,240 @@ +import { deferredDisposable, type IDisposable } from "./disposable"; + +/** + * An event emitter that enables the listening for, and emitting of, events. + */ +export class EventEmitter> { + /** + * Underlying collection of events and their listeners. + */ + private readonly events = new Map, EventListener[]>(); + + /** + * Adds the event {@link listener} for the event named {@link eventName}. + * @param eventName Name of the event. + * @param listener Event handler function. + * @returns This instance with the {@link listener} added. + */ + public addListener, TArgs extends EventsOf[TEventName]>(eventName: TEventName, listener: (...args: TArgs) => void): this { + return this.on(eventName, listener); + } + + /** + * Adds the event {@link listener} for the event named {@link eventName}, and returns a disposable capable of removing the event listener. + * @param eventName Name of the event. + * @param listener Event handler function. + * @returns A disposable that removes the listener when disposed. + */ + public disposableOn, TArgs extends EventsOf[TEventName]>(eventName: TEventName, listener: (...args: TArgs) => void): IDisposable { + this.addListener(eventName, listener); + return deferredDisposable(() => { + console.log("removing"); + this.removeListener(eventName, listener); + }); + } + + /** + * Emits the {@link eventName}, invoking all event listeners with the specified {@link args}. + * @param eventName Name of the event. + * @param args Arguments supplied to each event listener. + * @returns `true` when there was a listener associated with the event; otherwise `false`. + */ + public emit, TArgs extends EventsOf[TEventName]>(eventName: TEventName, ...args: TArgs): boolean { + const listeners = this.events.get(eventName); + if (listeners === undefined) { + return false; + } + + for (let i = 0; i < listeners.length; ) { + const { listener, once } = listeners[i]; + if (once) { + listeners.splice(i, 1); + } else { + i++; + } + + listener(...args); + } + + return true; + } + + /** + * Gets the event names with event listeners. + * @returns Event names. + */ + public eventNames(): (keyof EventsOf)[] { + return Array.from(this.events.keys()); + } + + /** + * Gets the number of event listeners for the event named {@link eventName}. When a {@link listener} is defined, only matching event listeners are counted. + * @param eventName Name of the event. + * @param listener Optional event listener to count. + * @returns Number of event listeners. + */ + public listenerCount, TArgs extends EventsOf[TEventName]>(eventName: TEventName, listener?: (...args: TArgs) => void): number { + const listeners = this.events.get(eventName); + if (listeners === undefined || listener == undefined) { + return listeners?.length || 0; + } + + let count = 0; + listeners.forEach((ev) => { + if (ev.listener === listener) { + count++; + } + }); + + return count; + } + + /** + * Gets the event listeners for the event named {@link eventName}. + * @param eventName Name of the event. + * @returns The event listeners. + */ + public listeners, TArgs extends EventsOf[TEventName]>(eventName: TEventName): ((...args: TArgs) => void)[] { + return Array.from(this.events.get(eventName) || []).map(({ listener }) => listener); + } + + /** + * Removes the event {@link listener} for the event named {@link eventName}. + * @param eventName Name of the event. + * @param listener Event handler function. + * @returns This instance with the event {@link listener} removed. + */ + public off, TArgs extends EventsOf[TEventName]>(eventName: TEventName, listener: (...args: TArgs) => void): this { + const listeners = this.events.get(eventName) || []; + for (let i = listeners.length - 1; i >= 0; i--) { + if (listeners[i].listener === listener) { + listeners.splice(i, 1); + } + } + + return this; + } + + /** + * Adds the event {@link listener} for the event named {@link eventName}. + * @param eventName Name of the event. + * @param listener Event handler function. + * @returns This instance with the event {@link listener} added. + */ + public on, TArgs extends EventsOf[TEventName]>(eventName: TEventName, listener: (...args: TArgs) => void): this { + return this.add(eventName, (listeners) => listeners.push({ listener })); + } + + /** + * Adds the **one-time** event {@link listener} for the event named {@link eventName}. + * @param eventName Name of the event. + * @param listener Event handler function. + * @returns This instance with the event {@link listener} added. + */ + public once, TArgs extends EventsOf[TEventName]>(eventName: TEventName, listener: (...args: TArgs) => void): this { + return this.add(eventName, (listeners) => listeners.push({ listener, once: true })); + } + + /** + * Adds the event {@link listener} to the beginning of the listeners for the event named {@link eventName}. + * @param eventName Name of the event. + * @param listener Event handler function. + * @returns This instance with the event {@link listener} prepended. + */ + public prependListener, TArgs extends EventsOf[TEventName]>(eventName: TEventName, listener: (...args: TArgs) => void): this { + return this.add(eventName, (listeners) => listeners.splice(0, 0, { listener })); + } + + /** + * Adds the **one-time** event {@link listener} to the beginning of the listeners for the event named {@link eventName}. + * @param eventName Name of the event. + * @param listener Event handler function. + * @returns This instance with the event {@link listener} prepended. + */ + public prependOnceListener, TArgs extends EventsOf[TEventName]>(eventName: TEventName, listener: (...args: TArgs) => void): this { + return this.add(eventName, (listeners) => listeners.splice(0, 0, { listener, once: true })); + } + + /** + * Removes all event listeners for the event named {@link eventName}. + * @param eventName Name of the event. + * @returns This instance with the event listeners removed + */ + public removeAllListeners>(eventName: TEventName): this { + this.events.delete(eventName); + return this; + } + + /** + * Removes the event {@link listener} for the event named {@link eventName}. + * @param eventName Name of the event. + * @param listener Event handler function. + * @returns This instance with the event {@link listener} removed. + */ + public removeListener, TArgs extends EventsOf[TEventName]>(eventName: TEventName, listener: (...args: TArgs) => void): this { + return this.off(eventName, listener); + } + + /** + * Adds the event {@link listener} for the event named {@link eventName}. + * @param eventName Name of the event. + * @param fn Function responsible for adding the new event handler function. + * @returns This instance with event {@link listener} added. + */ + private add>(eventName: TEventName, fn: (listeners: EventListener[]) => void): this { + let listeners = this.events.get(eventName); + if (listeners === undefined) { + listeners = []; + this.events.set(eventName, listeners); + } + + fn(listeners); + return this; + } +} + +/** + * A map of events and their arguments (represented as an array) that are supplied to the event's listener when the event is emitted. + * @example + * type UserService = { + * created: [id: number, userName: string]; + * deleted: [id: number]; + * } + */ +type EventMap = { + [K in keyof T]: K extends string ? (T[K] extends unknown[] ? T[K] : never) : never; +}; + +/** + * Parsed {@link EventMap} whereby each property is a `string` that denotes an event name, and the associated value type defines the listener arguments. + */ +export type EventsOf = EventMap< + Pick< + T, + Extract< + { + [K in keyof T]: K extends string ? K : never; + }[keyof T], + { + [K in keyof T]: T[K] extends unknown[] ? K : never; + }[keyof T] + > + > +>; + +/** + * An event listener associated with an event. + */ +type EventListener = { + /** + * + * @param args Arguments supplied to the event listener when the event is emitted. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listener: (...args: any) => void; + + /** + * Determines whether the event listener should be invoked once. + */ + once?: true; +}; diff --git a/src/common/typed-event-emitter.ts b/src/common/typed-event-emitter.ts deleted file mode 100644 index 2981502a..00000000 --- a/src/common/typed-event-emitter.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Inspired by work credited to https://github.com/andywer/typed-emitter/blob/master/index.d.ts - */ -import type { EventEmitter } from "node:events"; - -/** - * An {@link EventEmitter} with typed event names and listeners. - */ -export interface TypedEventEmitter { - /** @inheritdoc */ - addListener(event: TEventName, listener: (data: TData) => void): this; - - /** @inheritdoc */ - eventNames(): (string | symbol | keyof TMap)[]; - - /** @inheritdoc */ - on(event: TEventName, listener: (data: TData) => void): this; - - /** @inheritdoc */ - once(event: TEventName, listener: (data: TData) => void): this; - - /** @inheritdoc */ - prependListener(event: TEventName, listener: (data: TData) => void): this; - - /** @inheritdoc */ - prependOnceListener(event: TEventName, listener: (data: TData) => void): this; - - /** @inheritdoc */ - off(event: TEventName, listener: (data: TData) => void): this; - - /** @inheritdoc */ - removeAllListeners(event?: TEventName): this; - - /** @inheritdoc */ - removeListener(event: TEventName, listener: (data: TData) => void): this; - - /** @inheritdoc */ - emit(event: TEventName, data: TMap[TEventName]): boolean; - - /** @inheritdoc */ - // eslint-disable-next-line @typescript-eslint/ban-types - rawListeners(event: TEventName): Function[]; - - /** @inheritdoc */ - // eslint-disable-next-line @typescript-eslint/ban-types - listeners(event: TEventName): Function[]; - - /** @inheritdoc */ - listenerCount(event: TEventName): number; - - /** @inheritdoc */ - getMaxListeners(): number; - - /** @inheritdoc */ - setMaxListeners(maxListeners: number): this; -} diff --git a/src/connectivity/__tests__/connection.test.ts b/src/connectivity/__tests__/connection.test.ts index 0bc9c5a8..cd60c17a 100644 --- a/src/connectivity/__tests__/connection.test.ts +++ b/src/connectivity/__tests__/connection.test.ts @@ -6,7 +6,6 @@ import { getMockedLogger } from "../../../tests/__mocks__/logging"; import { registrationParameters } from "../__mocks__/registration"; import { OpenUrl } from "../commands"; import { StreamDeckConnection, createConnection } from "../connection"; -import * as api from "../events"; import { ApplicationDidLaunch } from "../events"; jest.mock("ws", () => { @@ -250,128 +249,6 @@ describe("StreamDeckConnection", () => { expect(createScopeSpy).toHaveBeenCalledWith("StreamDeckConnection"); }); - describe("addDisposableListener", () => { - /** - * Asserts the {@link StreamDeckConnection.addDisposableListener} adds the event listener. - */ - it("adds the listener", async () => { - // Arrange. - const { connection, webSocket } = await getOpenConnection(); - const listener = jest.fn(); - - // Act. - connection.addDisposableListener("applicationDidLaunch", listener); - webSocket.emit( - "message", - JSON.stringify({ - event: "applicationDidLaunch", - payload: { application: "one" } - } satisfies api.ApplicationDidLaunch) - ); - - // Assert. - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith<[ApplicationDidLaunch]>({ - event: "applicationDidLaunch", - payload: { application: "one" } - }); - }); - - /** - * Asserts listeners added via {@link StreamDeckConnection.addDisposableListener} can be removed by disposing. - */ - it("can remove after emitting", async () => { - // Arrange. - const { connection, webSocket } = await getOpenConnection(); - const listener = jest.fn(); - - // Act. - const handler = connection.addDisposableListener("applicationDidLaunch", listener); - webSocket.emit( - "message", - JSON.stringify({ - event: "applicationDidLaunch", - payload: { application: "one" } - } satisfies api.ApplicationDidLaunch) - ); - - // Assert. - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith<[ApplicationDidLaunch]>({ - event: "applicationDidLaunch", - payload: { application: "one" } - }); - - // Re-act - handler.dispose(); - webSocket.emit( - "message", - JSON.stringify({ - event: "applicationDidLaunch", - payload: { application: "__other__" } - } satisfies api.ApplicationDidLaunch) - ); - - // Re-assert - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenLastCalledWith<[ApplicationDidLaunch]>({ - event: "applicationDidLaunch", - payload: { application: "one" } - }); - }); - - describe("removing the listener", () => { - /** - * Asserts `dispose()` on the result {@link StreamDeckConnection.addDisposableListener} removes the listener. - */ - it("dispose", async () => { - // Arrange. - const { connection, webSocket } = await getOpenConnection(); - const listener = jest.fn(); - const handler = connection.addDisposableListener("applicationDidLaunch", listener); - - // Act. - handler.dispose(); - webSocket.emit( - "message", - JSON.stringify({ - event: "applicationDidLaunch", - payload: { application: "one" } - } satisfies api.ApplicationDidLaunch) - ); - - // Assert. - expect(listener).toHaveBeenCalledTimes(0); - }); - - /** - * Asserts `[Symbol.dispose]()` on the result {@link StreamDeckConnection.addDisposableListener} removes the listener. - */ - it("[Symbol.dispose]", async () => { - // Arrange. - const { connection, webSocket } = await getOpenConnection(); - const listener = jest.fn(); - - // Act. - { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - using handler = connection.addDisposableListener("applicationDidLaunch", listener); - } - - webSocket.emit( - "message", - JSON.stringify({ - event: "applicationDidLaunch", - payload: { application: "one" } - } satisfies api.ApplicationDidLaunch) - ); - - // Assert. - expect(listener).toHaveBeenCalledTimes(0); - }); - }); - }); - /** * Creates {@link StreamDeckConnection} and connects it to a mock {@link WebSocket}. * @returns The {@link StreamDeckConnection} in a connected state, and the mocks used to construct it. diff --git a/src/connectivity/connection.ts b/src/connectivity/connection.ts index 9bbcfc9c..5dcc13f9 100644 --- a/src/connectivity/connection.ts +++ b/src/connectivity/connection.ts @@ -1,14 +1,12 @@ -import { EventEmitter } from "node:events"; import WebSocket from "ws"; +import { EventEmitter } from "../common/event-emitter"; import { PromiseCompletionSource } from "../common/promises"; import { Logger } from "../logging"; import { Command } from "./commands"; -import { IDisposable, deferredDisposable } from "../common/disposable"; -import { TypedEventEmitter } from "../common/typed-event-emitter"; import { Version } from "../common/version"; -import { EventMap } from "./events"; +import { PluginEventMap } from "./events"; import { RegistrationParameters } from "./registration"; /** @@ -19,57 +17,25 @@ import { RegistrationParameters } from "./registration"; * @returns A connection with the Stream Deck, in an idle-unconnected state. */ export function createConnection(registrationParameters: RegistrationParameters, logger: Logger): StreamDeckConnection { - return new StreamDeckWebSocketConnection(registrationParameters, logger) as StreamDeckConnection; + return new PluginConnection(registrationParameters, logger); } /** * Provides a connection between the plugin and the Stream Deck allowing for messages to be sent and received. */ -export type StreamDeckConnection = TypedEventEmitter & { - /** - * Registration parameters used to establish a connection with the Stream Deck; these are automatically supplied as part of the command line arguments when the plugin is ran by - * the Stream Deck. - */ - readonly registrationParameters: RegistrationParameters; - - /** - * Version of Stream Deck this instance is connected to. - */ - readonly version: Version; - - /** - * Adds the {@link listener} to the connection for the {@link eventName} and returns a disposable that, when disposed, removes the listener. - * @param eventName Name of the event the listener is associated to. - * @param listener The event listener. - * @returns A disposable that removes the listener when disposed. - */ - addDisposableListener(eventName: TEventName, listener: (data: TData) => void): IDisposable; - - /** - * Establishes a connection with the Stream Deck, allowing for the plugin to send and receive messages. - * @returns A promise that is resolved when a connection has been established. - */ - connect(): Promise; - - /** - * Sends the commands to the Stream Deck, once the connection has been established and the plugin registered. - * @param command Command being sent. - * @returns `Promise` resolved when the command is sent to Stream Deck. - */ - send(command: Command): Promise; -}; +export type StreamDeckConnection = PluginConnection; /** * Provides a connection between the plugin and the Stream Deck allowing for messages to be sent and received. */ -class StreamDeckWebSocketConnection extends EventEmitter implements StreamDeckConnection { +class PluginConnection extends EventEmitter { /** - * @inheritdoc + * Version of Stream Deck this instance is connected to. */ public readonly version: Version; /** - * Used to ensure {@link StreamDeckWebSocketConnection.connect} is invoked as a singleton; `false` when a connection is occurring or established. + * Used to ensure {@link StreamDeckConnection.connect} is invoked as a singleton; `false` when a connection is occurring or established. */ private canConnect = true; @@ -85,7 +51,7 @@ class StreamDeckWebSocketConnection extends EventEmitter implements StreamDeckCo private readonly logger: Logger; /** - * Initializes a new instance of the {@link StreamDeckWebSocketConnection} class. + * Initializes a new instance of the {@link StreamDeckConnection} class. * @param registrationParameters Registration parameters used to establish a connection with the Stream Deck; these are automatically supplied as part of the command line arguments * when the plugin is ran by the Stream Deck. * @param logger Logger responsible for capturing log entries. @@ -100,15 +66,8 @@ class StreamDeckWebSocketConnection extends EventEmitter implements StreamDeckCo } /** - * @inheritdoc - */ - public addDisposableListener(eventName: TEventName, listener: (data: TData) => void): IDisposable { - this.addListener(eventName, listener); - return deferredDisposable(() => this.removeListener(eventName, listener)); - } - - /** - * @inheritdoc + * Establishes a connection with the Stream Deck, allowing for the plugin to send and receive messages. + * @returns A promise that is resolved when a connection has been established. */ public async connect(): Promise { // Ensure we only establish a single connection. @@ -138,7 +97,9 @@ class StreamDeckWebSocketConnection extends EventEmitter implements StreamDeckCo } /** - * @inheritdoc + * Sends the commands to the Stream Deck, once the connection has been established and the plugin registered. + * @param command Command being sent. + * @returns `Promise` resolved when the command is sent to Stream Deck. */ public async send(command: Command): Promise { const connection = await this.connection.promise; @@ -149,7 +110,7 @@ class StreamDeckWebSocketConnection extends EventEmitter implements StreamDeckCo } /** - * Resets the {@link StreamDeckWebSocketConnection.connection}. + * Resets the {@link StreamDeckConnection.connection}. */ private resetConnection(): void { this.canConnect = true; @@ -159,7 +120,7 @@ class StreamDeckWebSocketConnection extends EventEmitter implements StreamDeckCo } /** - * Attempts to emit the {@link data} that was received from the {@link StreamDeckWebSocketConnection.connection}. + * Attempts to emit the {@link data} that was received from the {@link StreamDeckConnection.connection}. * @param data Event message data received from the Stream Deck. */ private tryEmit(data: WebSocket.RawData): void { diff --git a/src/connectivity/events/index.ts b/src/connectivity/events/index.ts index a02f15cf..0b20fd10 100644 --- a/src/connectivity/events/index.ts +++ b/src/connectivity/events/index.ts @@ -58,6 +58,6 @@ export type EventMessage = object> = /** * Map of events received by the plugin, from the Stream Deck. */ -export type EventMap = { - [K in EventMessage["event"]]: Extract>; +export type PluginEventMap = { + [K in EventMessage["event"]]: [event: Extract>]; }; diff --git a/src/devices.ts b/src/devices.ts index 61d5d1ae..d5180f7a 100644 --- a/src/devices.ts +++ b/src/devices.ts @@ -85,7 +85,7 @@ export class DeviceClient { * @returns A disposable that, when disposed, removes the listener. */ public onDeviceDidConnect(listener: (ev: DeviceDidConnectEvent) => void): IDisposable { - return this.connection.addDisposableListener("deviceDidConnect", (ev) => + return this.connection.disposableOn("deviceDidConnect", (ev) => listener( new DeviceEvent(ev, { ...ev.deviceInfo, @@ -101,7 +101,7 @@ export class DeviceClient { * @returns A disposable that, when disposed, removes the listener. */ public onDeviceDidDisconnect(listener: (ev: DeviceDidDisconnectEvent) => void): IDisposable { - return this.connection.addDisposableListener("deviceDidDisconnect", (ev) => + return this.connection.disposableOn("deviceDidDisconnect", (ev) => listener( new DeviceEvent(ev, { ...this.devices.get(ev.device), diff --git a/src/index.ts b/src/index.ts index 8b13a621..bd2f106d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { StreamDeck } from "./stream-deck"; export { Action } from "./actions/action"; export { action } from "./actions/decorators"; export { SingletonAction } from "./actions/singleton-action"; +export { EventEmitter, EventsOf } from "./common/event-emitter"; export * from "./connectivity/layouts"; export { Target } from "./connectivity/target"; export * from "./events"; diff --git a/src/settings/client.ts b/src/settings/client.ts index 771f9999..2cf7b570 100644 --- a/src/settings/client.ts +++ b/src/settings/client.ts @@ -36,7 +36,7 @@ export class SettingsClient { * @returns A disposable that, when disposed, removes the listener. */ public onDidReceiveGlobalSettings = object>(listener: (ev: DidReceiveGlobalSettingsEvent) => void): IDisposable { - return this.connection.addDisposableListener("didReceiveGlobalSettings", (ev: api.DidReceiveGlobalSettings) => listener(new DidReceiveGlobalSettingsEvent(ev))); + return this.connection.disposableOn("didReceiveGlobalSettings", (ev: api.DidReceiveGlobalSettings) => listener(new DidReceiveGlobalSettingsEvent(ev))); } /** @@ -46,7 +46,7 @@ export class SettingsClient { * @returns A disposable that, when disposed, removes the listener. */ public onDidReceiveSettings = object>(listener: (ev: DidReceiveSettingsEvent) => void): IDisposable { - return this.connection.addDisposableListener("didReceiveSettings", (ev: api.DidReceiveSettings) => + return this.connection.disposableOn("didReceiveSettings", (ev: api.DidReceiveSettings) => listener(new ActionEvent>(new Action(this.connection, ev), ev)) ); } diff --git a/src/system.ts b/src/system.ts index 35f223fb..836c358a 100644 --- a/src/system.ts +++ b/src/system.ts @@ -22,7 +22,7 @@ export class System { * @returns A disposable that, when disposed, removes the listener. */ public onApplicationDidLaunch(listener: (ev: ApplicationDidLaunchEvent) => void): IDisposable { - return this.connection.addDisposableListener("applicationDidLaunch", (ev) => listener(new ApplicationEvent(ev))); + return this.connection.disposableOn("applicationDidLaunch", (ev) => listener(new ApplicationEvent(ev))); } /** @@ -32,7 +32,7 @@ export class System { * @returns A disposable that, when disposed, removes the listener. */ public onApplicationDidTerminate(listener: (ev: ApplicationDidTerminateEvent) => void): IDisposable { - return this.connection.addDisposableListener("applicationDidTerminate", (ev) => listener(new ApplicationEvent(ev))); + return this.connection.disposableOn("applicationDidTerminate", (ev) => listener(new ApplicationEvent(ev))); } /** @@ -43,7 +43,7 @@ export class System { */ public onDidReceiveDeepLink(listener: (ev: DidReceiveDeepLinkEvent) => void): IDisposable { requiresVersion(6.5, this.connection.version, "Receiving deep-link messages"); - return this.connection.addDisposableListener("didReceiveDeepLink", (ev) => listener(new DidReceiveDeepLinkEvent(ev))); + return this.connection.disposableOn("didReceiveDeepLink", (ev) => listener(new DidReceiveDeepLinkEvent(ev))); } /** @@ -52,7 +52,7 @@ export class System { * @returns A disposable that, when disposed, removes the listener. */ public onSystemDidWakeUp(listener: (ev: SystemDidWakeUpEvent) => void): IDisposable { - return this.connection.addDisposableListener("systemDidWakeUp", (ev) => listener(new Event(ev))); + return this.connection.disposableOn("systemDidWakeUp", (ev) => listener(new Event(ev))); } /** diff --git a/src/ui.ts b/src/ui.ts index ff50cf78..5e2162e7 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -21,7 +21,7 @@ export class UIClient { * @returns A disposable that, when disposed, removes the listener. */ public onPropertyInspectorDidAppear = object>(listener: (ev: PropertyInspectorDidAppearEvent) => void): IDisposable { - return this.connection.addDisposableListener("propertyInspectorDidAppear", (ev: api.PropertyInspectorDidAppear) => + return this.connection.disposableOn("propertyInspectorDidAppear", (ev: api.PropertyInspectorDidAppear) => listener(new ActionWithoutPayloadEvent(new Action(this.connection, ev), ev)) ); } @@ -33,7 +33,7 @@ export class UIClient { * @returns A disposable that, when disposed, removes the listener. */ public onPropertyInspectorDidDisappear = object>(listener: (ev: PropertyInspectorDidDisappearEvent) => void): IDisposable { - return this.connection.addDisposableListener("propertyInspectorDidDisappear", (ev: api.PropertyInspectorDidDisappear) => + return this.connection.disposableOn("propertyInspectorDidDisappear", (ev: api.PropertyInspectorDidDisappear) => listener(new ActionWithoutPayloadEvent(new Action(this.connection, ev), ev)) ); } @@ -48,7 +48,7 @@ export class UIClient { public onSendToPlugin = object, TSettings extends api.PayloadObject = object>( listener: (ev: SendToPluginEvent) => void ): IDisposable { - return this.connection.addDisposableListener("sendToPlugin", (ev: api.SendToPlugin) => + return this.connection.disposableOn("sendToPlugin", (ev: api.SendToPlugin) => listener(new SendToPluginEvent(new Action(this.connection, ev), ev)) ); }