Skip to content

Commit

Permalink
refactor: use DOM compatible EventTarget to replace Node.js's EventEm…
Browse files Browse the repository at this point in the history
…itter (#7437)
  • Loading branch information
AlCalzone authored Nov 25, 2024
1 parent ef8f276 commit 6a0d95c
Show file tree
Hide file tree
Showing 12 changed files with 322 additions and 30 deletions.
1 change: 0 additions & 1 deletion packages/core/src/error/ZWaveError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/values/ValueDB.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -95,7 +95,7 @@ export function valueIdToString(valueID: ValueID): string {
/**
* The value store for a single node
*/
export class ValueDB extends TypedEventEmitter<ValueDBEventCallbacks> {
export class ValueDB extends TypedEventTarget<ValueDBEventCallbacks> {
// This is a wrapper around the driver's on-disk value and metadata key value stores

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/serial/src/mock/SerialPortBindingMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -158,7 +158,7 @@ interface MockPortBindingEvents {
/**
* Mock bindings for pretend serialport access
*/
export class MockPortBinding extends TypedEventEmitter<MockPortBindingEvents>
export class MockPortBinding extends TypedEventTarget<MockPortBindingEvents>
implements BindingPortInterface
{
readonly openOptions: Required<OpenOptions>;
Expand Down
128 changes: 128 additions & 0 deletions packages/shared/src/EventTarget.test.ts
Original file line number Diff line number Diff line change
@@ -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<TestEvents> {}

@Mixin([TypedEventTarget])
class Test extends Base implements TypedEventTarget<TestEvents> {
emit1() {
this.emit("test1", 1);
}
}

test("Type-Safe EventTarget as Mixin works", (t) => {
return new Promise<void>((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<TestEvents> {
emit1() {
this.emit("test1", 1);
}

emit2() {
this.emit("test2");
}
}

test("Type-Safe EventTarget standalone works", (t) => {
return new Promise<void>((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<void>((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<void>(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<TypedEventTarget<TestEvents>>,
) {
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<void>((resolve) => {
testClass.on("test1", (arg1) => {
t.expect(arg1).toBe(1);
resolve();
});
testClass.emit1();
});
});
}
177 changes: 177 additions & 0 deletions packages/shared/src/EventTarget.ts
Original file line number Diff line number Diff line change
@@ -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<T extends any[]> 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<TestEvents> {
* // class implementation
* }
* ```
* 2b.) as a mixin
* ```ts
* interface Test extends TypedEventTarget<TestEvents> {}
* Mixin([EventEmitter]) // This is a decorator - prepend it with an <at> sign
* class Test extends OtherClass implements TypedEventTarget<TestEvents> {
* // class implementation
* }
* ```
*/

export class TypedEventTarget<
TEvents extends Record<keyof TEvents, EventListener>,
> {
// 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<keyof TEvents, Set<Fn>> | undefined;
private get listeners(): Map<keyof TEvents, Set<Fn>> {
this._listeners ??= new Map();
return this._listeners;
}

private _wrappers: WeakMap<Fn, Fn> | undefined;
private get wrappers(): WeakMap<Fn, Fn> {
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<Parameters<TEvents[keyof TEvents]>>)
.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<TEvent extends keyof TEvents>(
event: TEvent,
callback: TEvents[TEvent],
): this {
this.eventTarget.addEventListener(
event as string,
this.getWrapper(event, callback),
);
this.rememberListener(event, callback);
return this;
}

public once<TEvent extends keyof TEvents>(
event: TEvent,
callback: TEvents[TEvent],
): this {
this.eventTarget.addEventListener(
event as string,
this.getWrapper(event, callback, true),
{ once: true },
);
return this;
}

public removeListener<TEvent extends keyof TEvents>(
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<TEvent extends keyof TEvents>(
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<TEvent extends keyof TEvents>(
event: TEvent,
callback: TEvents[TEvent],
): this {
return this.removeListener(event, callback);
}

public emit<TEvent extends keyof TEvents>(
event: TEvent,
...args: Parameters<TEvents[TEvent]>
): boolean {
return this.eventTarget.dispatchEvent(
new CustomEvent(event as string, args),
);
}
}
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index_browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 2 additions & 2 deletions packages/zwave-js/src/lib/controller/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ import {
type ReadonlyObjectKeyMap,
type ReadonlyThrowingMap,
type ThrowingMap,
TypedEventEmitter,
TypedEventTarget,
areUint8ArraysEqual,
cloneDeep,
createThrowingMap,
Expand Down Expand Up @@ -482,7 +482,7 @@ export interface ZWaveController extends ControllerStatisticsHost {}

@Mixin([ControllerStatisticsHost])
export class ZWaveController
extends TypedEventEmitter<ControllerEventCallbacks>
extends TypedEventTarget<ControllerEventCallbacks>
{
/** @internal */
public constructor(
Expand Down
Loading

0 comments on commit 6a0d95c

Please sign in to comment.