diff --git a/packages/core/src/error/ZWaveError.ts b/packages/core/src/error/ZWaveError.ts index fff3a39958a6..db1a3bb6da1f 100644 --- a/packages/core/src/error/ZWaveError.ts +++ b/packages/core/src/error/ZWaveError.ts @@ -22,7 +22,6 @@ export enum ZWaveErrorCodes { Driver_InvalidOptions, /** The driver tried to do something that requires security */ Driver_NoSecurity, - Driver_NoErrorHandler, Driver_FeatureDisabled, /** The task was removed from the task queue */ diff --git a/packages/core/src/values/ValueDB.ts b/packages/core/src/values/ValueDB.ts index 8673b033f4d8..ddfe67842dd8 100644 --- a/packages/core/src/values/ValueDB.ts +++ b/packages/core/src/values/ValueDB.ts @@ -1,5 +1,5 @@ import type { JsonlDB } from "@alcalzone/jsonl-db"; -import { TypedEventEmitter } from "@zwave-js/shared"; +import { TypedEventTarget } from "@zwave-js/shared"; import type { CommandClasses } from "../definitions/CommandClasses.js"; import { ZWaveError, @@ -95,7 +95,7 @@ export function valueIdToString(valueID: ValueID): string { /** * The value store for a single node */ -export class ValueDB extends TypedEventEmitter { +export class ValueDB extends TypedEventTarget { // This is a wrapper around the driver's on-disk value and metadata key value stores /** diff --git a/packages/serial/src/mock/SerialPortBindingMock.ts b/packages/serial/src/mock/SerialPortBindingMock.ts index b1ea3ead679f..12a2a742b216 100644 --- a/packages/serial/src/mock/SerialPortBindingMock.ts +++ b/packages/serial/src/mock/SerialPortBindingMock.ts @@ -11,7 +11,7 @@ import type { SetOptions, UpdateOptions, } from "@serialport/bindings-interface"; -import { Bytes, TypedEventEmitter, isUint8Array } from "@zwave-js/shared"; +import { Bytes, TypedEventTarget, isUint8Array } from "@zwave-js/shared"; export interface MockPortInternal { data: Uint8Array; @@ -158,7 +158,7 @@ interface MockPortBindingEvents { /** * Mock bindings for pretend serialport access */ -export class MockPortBinding extends TypedEventEmitter +export class MockPortBinding extends TypedEventTarget implements BindingPortInterface { readonly openOptions: Required; diff --git a/packages/shared/src/EventTarget.test.ts b/packages/shared/src/EventTarget.test.ts new file mode 100644 index 000000000000..42f754f41949 --- /dev/null +++ b/packages/shared/src/EventTarget.test.ts @@ -0,0 +1,128 @@ +import { wait } from "alcalzone-shared/async"; +import { test } from "vitest"; +import { TypedEventTarget } from "./EventTarget.js"; +import { AllOf, Mixin } from "./inheritance.js"; +import type { Constructor } from "./types.js"; + +interface TestEvents { + test1: (arg1: number) => void; + test2: () => void; +} + +{ + class Base { + get baseProp() { + return "base"; + } + baseProp2 = "base"; + } + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface Test extends TypedEventTarget {} + + @Mixin([TypedEventTarget]) + class Test extends Base implements TypedEventTarget { + emit1() { + this.emit("test1", 1); + } + } + + test("Type-Safe EventTarget as Mixin works", (t) => { + return new Promise((resolve) => { + const testClass = new Test(); + t.expect(testClass.baseProp).toBe("base"); + t.expect(testClass.baseProp2).toBe("base"); + testClass.on("test1", (arg1) => { + t.expect(arg1).toBe(1); + resolve(); + }); + testClass.emit1(); + }); + }); +} + +{ + class Test extends TypedEventTarget { + emit1() { + this.emit("test1", 1); + } + + emit2() { + this.emit("test2"); + } + } + + test("Type-Safe EventTarget standalone works", (t) => { + return new Promise((resolve) => { + const testClass = new Test(); + testClass.on("test1", (arg1) => { + t.expect(arg1).toBe(1); + resolve(); + }); + testClass.emit1(); + }); + }); + + test("removeAllListeners(event) works", (t) => { + return new Promise((resolve, reject) => { + const testClass = new Test(); + testClass.on("test1", (arg1) => { + reject(new Error("Listener was not removed")); + }); + testClass.on("test2", () => { + resolve(); + }); + testClass.removeAllListeners("test1"); + testClass.emit1(); + testClass.emit2(); + }); + }); + + test("removeAllListeners() works", (t) => { + return new Promise(async (resolve, reject) => { + const testClass = new Test(); + testClass.on("test1", (arg1) => { + reject(new Error("Listener was not removed")); + }); + testClass.on("test2", () => { + reject(new Error("Listener was not removed")); + }); + testClass.removeAllListeners(); + testClass.emit1(); + testClass.emit2(); + await wait(50); + resolve(); + }); + }); +} + +{ + class Base { + get baseProp() { + return "base"; + } + baseProp2 = "base"; + } + + class Test extends AllOf( + Base, + TypedEventTarget as Constructor>, + ) { + emit1() { + this.emit("test1", 1); + } + } + + test("Type-Safe EventTarget (with multi-inheritance) works", async (t) => { + const testClass = new Test(); + t.expect(testClass.baseProp).toBe("base"); + t.expect(testClass.baseProp2).toBe("base"); + return new Promise((resolve) => { + testClass.on("test1", (arg1) => { + t.expect(arg1).toBe(1); + resolve(); + }); + testClass.emit1(); + }); + }); +} diff --git a/packages/shared/src/EventTarget.ts b/packages/shared/src/EventTarget.ts new file mode 100644 index 000000000000..bf1216dd4b16 --- /dev/null +++ b/packages/shared/src/EventTarget.ts @@ -0,0 +1,177 @@ +export type EventListener = + // Add more overloads as necessary + | ((arg1: any, arg2: any, arg3: any, arg4: any) => void) + | ((arg1: any, arg2: any, arg3: any) => void) + | ((arg1: any, arg2: any) => void) + | ((arg1: any) => void) + | ((...args: any[]) => void); + +// FIXME: Once we upgrade to Node.js 20, use the global CustomEvent class +class CustomEvent extends Event { + constructor(type: string, detail: T) { + super(type); + this._detail = detail; + } + + private _detail: T; + public get detail(): T { + return this._detail; + } +} + +type Fn = (...args: any[]) => void; + +/** + * A type-safe EventEmitter replacement that internally uses the portable _eventTarget API. + * + * **Usage:** + * + * 1.) Define event signatures + * ```ts + * interface TestEvents { + * test1: (arg1: number) => void; + * test2: () => void; + * } + * ``` + * + * 2a.) direct inheritance: + * ```ts + * class Test extends TypedEventTarget { + * // class implementation + * } + * ``` + * 2b.) as a mixin + * ```ts + * interface Test extends TypedEventTarget {} + * Mixin([EventEmitter]) // This is a decorator - prepend it with an sign + * class Test extends OtherClass implements TypedEventTarget { + * // class implementation + * } + * ``` + */ + +export class TypedEventTarget< + TEvents extends Record, +> { + // We lazily initialize the instance properties, so they can be used in mixins + + private _eventTarget: EventTarget | undefined; + private get eventTarget(): EventTarget { + this._eventTarget ??= new EventTarget(); + return this._eventTarget; + } + + private _listeners: Map> | undefined; + private get listeners(): Map> { + this._listeners ??= new Map(); + return this._listeners; + } + + private _wrappers: WeakMap | undefined; + private get wrappers(): WeakMap { + this._wrappers ??= new WeakMap(); + return this._wrappers; + } + + private getWrapper( + event: keyof TEvents, + callback: TEvents[keyof TEvents], + once: boolean = false, + ): Fn { + if (this.wrappers.has(callback)) { + return this.wrappers.get(callback)!; + } else { + const wrapper = (e: Event) => { + const detail = + (e as CustomEvent>) + .detail; + // @ts-expect-error + callback(...detail); + if (once) this.listeners.get(event)?.delete(callback); + }; + this.wrappers.set(callback, wrapper); + return wrapper; + } + } + + private rememberListener(event: keyof TEvents, callback: Fn): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + this.listeners.get(event)!.add(callback); + } + + public on( + event: TEvent, + callback: TEvents[TEvent], + ): this { + this.eventTarget.addEventListener( + event as string, + this.getWrapper(event, callback), + ); + this.rememberListener(event, callback); + return this; + } + + public once( + event: TEvent, + callback: TEvents[TEvent], + ): this { + this.eventTarget.addEventListener( + event as string, + this.getWrapper(event, callback, true), + { once: true }, + ); + return this; + } + + public removeListener( + event: TEvent, + callback: TEvents[TEvent], + ): this { + if (this.wrappers.has(callback)) { + this.eventTarget.removeEventListener( + event as string, + this.wrappers.get(callback)!, + ); + this.wrappers.delete(callback); + } + if (this.listeners.has(event)) { + this.listeners.get(event)!.delete(callback); + } + return this; + } + + public removeAllListeners( + event?: TEvent, + ): this { + if (event) { + if (this.listeners.has(event)) { + for (const callback of this.listeners.get(event)!) { + this.removeListener(event, callback as any); + } + } + } else { + for (const event of this.listeners.keys()) { + this.removeAllListeners(event); + } + } + return this; + } + + public off( + event: TEvent, + callback: TEvents[TEvent], + ): this { + return this.removeListener(event, callback); + } + + public emit( + event: TEvent, + ...args: Parameters + ): boolean { + return this.eventTarget.dispatchEvent( + new CustomEvent(event as string, args), + ); + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 39939137dccf..a69129e55dbd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,6 +2,7 @@ export * from "./AsyncQueue.js"; export { Bytes } from "./Bytes.js"; export * from "./EventEmitter.js"; +export * from "./EventTarget.js"; export { ObjectKeyMap } from "./ObjectKeyMap.js"; export type { ReadonlyObjectKeyMap } from "./ObjectKeyMap.js"; export * from "./ThrowingMap.js"; diff --git a/packages/shared/src/index_browser.ts b/packages/shared/src/index_browser.ts index 2ca7b4401be9..8271e13e3f1a 100644 --- a/packages/shared/src/index_browser.ts +++ b/packages/shared/src/index_browser.ts @@ -2,6 +2,7 @@ export * from "./AsyncQueue.js"; export { Bytes } from "./Bytes.js"; +export * from "./EventTarget.js"; export { ObjectKeyMap } from "./ObjectKeyMap.js"; export type { ReadonlyObjectKeyMap } from "./ObjectKeyMap.js"; export * from "./ThrowingMap.js"; diff --git a/packages/zwave-js/src/lib/controller/Controller.ts b/packages/zwave-js/src/lib/controller/Controller.ts index 0a5d14562121..3be6721b70bd 100644 --- a/packages/zwave-js/src/lib/controller/Controller.ts +++ b/packages/zwave-js/src/lib/controller/Controller.ts @@ -360,7 +360,7 @@ import { type ReadonlyObjectKeyMap, type ReadonlyThrowingMap, type ThrowingMap, - TypedEventEmitter, + TypedEventTarget, areUint8ArraysEqual, cloneDeep, createThrowingMap, @@ -482,7 +482,7 @@ export interface ZWaveController extends ControllerStatisticsHost {} @Mixin([ControllerStatisticsHost]) export class ZWaveController - extends TypedEventEmitter + extends TypedEventTarget { /** @internal */ public constructor( diff --git a/packages/zwave-js/src/lib/driver/Driver.ts b/packages/zwave-js/src/lib/driver/Driver.ts index 63da7bf48654..db86a9b6dc48 100644 --- a/packages/zwave-js/src/lib/driver/Driver.ts +++ b/packages/zwave-js/src/lib/driver/Driver.ts @@ -165,7 +165,7 @@ import { AsyncQueue, Bytes, type ThrowingMap, - TypedEventEmitter, + TypedEventTarget, areUint8ArraysEqual, buffer2hex, cloneDeep, @@ -185,7 +185,6 @@ import { createDeferredPromise, } from "alcalzone-shared/deferred-promise"; import { isArray, isObject } from "alcalzone-shared/typeguards"; -import type { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -632,7 +631,7 @@ export type DriverEvents = Extract; * Any action you want to perform on the Z-Wave network must go through a driver * instance or its associated nodes. */ -export class Driver extends TypedEventEmitter +export class Driver extends TypedEventTarget implements CCAPIHost, InterviewContext, @@ -1293,17 +1292,6 @@ export class Driver extends TypedEventEmitter if (this._wasStarted) return Promise.resolve(); this._wasStarted = true; - // Enforce that an error handler is attached, except for testing with a mocked serialport - if ( - !this._options.testingHooks - && (this as unknown as EventEmitter).listenerCount("error") === 0 - ) { - throw new ZWaveError( - `Before starting the driver, a handler for the "error" event must be attached.`, - ZWaveErrorCodes.Driver_NoErrorHandler, - ); - } - const spOpenPromise = createDeferredPromise(); // Log which version is running diff --git a/packages/zwave-js/src/lib/driver/Statistics.ts b/packages/zwave-js/src/lib/driver/Statistics.ts index 7ead89cec044..3313a2d3cb41 100644 --- a/packages/zwave-js/src/lib/driver/Statistics.ts +++ b/packages/zwave-js/src/lib/driver/Statistics.ts @@ -1,5 +1,4 @@ -import { throttle } from "@zwave-js/shared"; -import type EventEmitter from "node:events"; +import { type TypedEventTarget, throttle } from "@zwave-js/shared"; /** Mixin to provide statistics functionality. Requires the base class to extend EventEmitter. */ export abstract class StatisticsHost { @@ -25,7 +24,7 @@ export abstract class StatisticsHost { this._statistics = updater(this._statistics ?? this.createEmpty()); if (!this._emitUpdate) { this._emitUpdate = throttle( - (this as unknown as EventEmitter).emit.bind( + (this as unknown as TypedEventTarget).emit.bind( this, "statistics updated", ...this.getAdditionalEventArgs(), diff --git a/packages/zwave-js/src/lib/node/Node.ts b/packages/zwave-js/src/lib/node/Node.ts index c02813a4098a..63a458c4dacf 100644 --- a/packages/zwave-js/src/lib/node/Node.ts +++ b/packages/zwave-js/src/lib/node/Node.ts @@ -215,7 +215,7 @@ import { containsCC } from "@zwave-js/serial/serialapi"; import { Bytes, Mixin, - type TypedEventEmitter, + TypedEventTarget, cloneDeep, discreteLinearSearch, formatId, @@ -233,7 +233,6 @@ import { } from "alcalzone-shared/deferred-promise"; import { roundTo } from "alcalzone-shared/math"; import { isArray, isObject } from "alcalzone-shared/typeguards"; -import { EventEmitter } from "node:events"; import path from "node:path"; import semverParse from "semver/functions/parse.js"; import { RemoveNodeReason } from "../controller/Inclusion.js"; @@ -279,14 +278,14 @@ type AllNodeEvents = & StatisticsEventCallbacksWithSelf; export interface ZWaveNode - extends TypedEventEmitter, NodeStatisticsHost + extends TypedEventTarget, NodeStatisticsHost {} /** * A ZWaveNode represents a node in a Z-Wave network. It is also an instance * of its root endpoint (index 0) */ -@Mixin([EventEmitter, NodeStatisticsHost]) +@Mixin([TypedEventTarget, NodeStatisticsHost]) export class ZWaveNode extends ZWaveNodeMixins implements QuerySecurityClasses { public constructor( id: number, diff --git a/packages/zwave-js/src/lib/zniffer/Zniffer.ts b/packages/zwave-js/src/lib/zniffer/Zniffer.ts index 4044b013aaa7..ab73a037ccdf 100644 --- a/packages/zwave-js/src/lib/zniffer/Zniffer.ts +++ b/packages/zwave-js/src/lib/zniffer/Zniffer.ts @@ -59,7 +59,7 @@ import { } from "@zwave-js/serial"; import { Bytes, - TypedEventEmitter, + TypedEventTarget, getEnumMemberName, isEnumMember, noop, @@ -184,7 +184,7 @@ export interface CapturedFrame { parsedFrame: Frame | CorruptedFrame; } -export class Zniffer extends TypedEventEmitter { +export class Zniffer extends TypedEventTarget { public constructor( private port: string | ZWaveSerialPortImplementation, options: ZnifferOptions = {},