-
-
Notifications
You must be signed in to change notification settings - Fork 634
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: use DOM compatible EventTarget to replace Node.js's EventEm…
…itter (#7437)
- Loading branch information
Showing
12 changed files
with
322 additions
and
30 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.