Refactor event system to support returnable events

Added support for validating and managing return values in events. Implemented `validate` and `clone` methods in the event base class for event mutation and return handling. Additionally, enhanced error handling, introduced "once" listeners, and improved async execution management in the `EventManager`.
This commit is contained in:
dswbx
2025-01-15 17:21:28 +01:00
parent 7b0a41b297
commit 6625c9bc48
6 changed files with 227 additions and 185 deletions

View File

@@ -1,8 +1,18 @@
import { describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import { Event, EventManager, NoParamEvent } from "../../src/core/events"; import {
Event,
EventManager,
InvalidEventReturn,
type ListenerHandler,
NoParamEvent
} from "../../src/core/events";
import { disableConsoleLog, enableConsoleLog } from "../helper";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
class SpecialEvent extends Event<{ foo: string }> { class SpecialEvent extends Event<{ foo: string }> {
static slug = "special-event"; static override slug = "special-event";
isBar() { isBar() {
return this.params.foo === "bar"; return this.params.foo === "bar";
@@ -10,37 +20,136 @@ class SpecialEvent extends Event<{ foo: string }> {
} }
class InformationalEvent extends NoParamEvent { class InformationalEvent extends NoParamEvent {
static slug = "informational-event"; static override slug = "informational-event";
}
class ReturnEvent extends Event<{ foo: string }, string> {
static override slug = "return-event";
override validate(value: string) {
if (typeof value !== "string") {
throw new InvalidEventReturn("string", typeof value);
}
return this.clone({
foo: [this.params.foo, value].join("-")
});
}
} }
describe("EventManager", async () => { describe("EventManager", async () => {
test("test", async () => { test("executes", async () => {
const call = mock(() => null);
const delayed = mock(() => null);
const emgr = new EventManager(); const emgr = new EventManager();
emgr.registerEvents([SpecialEvent, InformationalEvent]); emgr.registerEvents([SpecialEvent, InformationalEvent]);
expect(emgr.eventExists("special-event")).toBe(true);
expect(emgr.eventExists("informational-event")).toBe(true);
expect(emgr.eventExists("unknown-event")).toBe(false);
emgr.onEvent( emgr.onEvent(
SpecialEvent, SpecialEvent,
async (event, name) => { async (event, name) => {
console.log("Event: ", name, event.params.foo, event.isBar()); expect(name).toBe("special-event");
console.log("wait..."); expect(event.isBar()).toBe(true);
call();
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 50));
console.log("done waiting"); delayed();
}, },
"sync" "sync"
); );
emgr.onEvent(InformationalEvent, async (event, name) => { emgr.onEvent(InformationalEvent, async (event, name) => {
console.log("Event: ", name, event.params); call();
expect(name).toBe("informational-event");
}); });
await emgr.emit(new SpecialEvent({ foo: "bar" })); await emgr.emit(new SpecialEvent({ foo: "bar" }));
console.log("done"); await emgr.emit(new InformationalEvent());
// expect construct signatures to not cause ts errors // expect construct signatures to not cause ts errors
new SpecialEvent({ foo: "bar" }); new SpecialEvent({ foo: "bar" });
new InformationalEvent(); new InformationalEvent();
expect(true).toBe(true); expect(call).toHaveBeenCalledTimes(2);
expect(delayed).toHaveBeenCalled();
});
test("custom async executor", async () => {
const call = mock(() => null);
const asyncExecutor = (p: Promise<any>[]) => {
call();
return Promise.all(p);
};
const emgr = new EventManager(
{ InformationalEvent },
{
asyncExecutor
}
);
emgr.onEvent(InformationalEvent, async () => {});
await emgr.emit(new InformationalEvent());
expect(call).toHaveBeenCalled();
});
test("piping", async () => {
const onInvalidReturn = mock(() => null);
const asyncEventCallback = mock(() => null);
const emgr = new EventManager(
{ ReturnEvent, InformationalEvent },
{
onInvalidReturn
}
);
// @ts-expect-error InformationalEvent has no return value
emgr.onEvent(InformationalEvent, async () => {
asyncEventCallback();
return 1;
});
emgr.onEvent(ReturnEvent, async () => "1", "sync");
emgr.onEvent(ReturnEvent, async () => "0", "sync");
// @ts-expect-error must be string
emgr.onEvent(ReturnEvent, async () => 0, "sync");
// return is not required
emgr.onEvent(ReturnEvent, async () => {}, "sync");
// was "async", will not return
const e1 = await emgr.emit(new InformationalEvent());
expect(e1.returned).toBe(false);
const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" }));
expect(e2.returned).toBe(true);
expect(e2.params.foo).toBe("bar-1-0");
expect(onInvalidReturn).toHaveBeenCalled();
expect(asyncEventCallback).toHaveBeenCalled();
});
test("once", async () => {
const call = mock(() => null);
const emgr = new EventManager({ InformationalEvent });
emgr.onEventOnce(
InformationalEvent,
async (event, slug) => {
expect(event).toBeInstanceOf(InformationalEvent);
expect(slug).toBe("informational-event");
call();
},
"sync"
);
expect(emgr.getListeners().length).toBe(1);
await emgr.emit(new InformationalEvent());
expect(emgr.getListeners().length).toBe(0);
await emgr.emit(new InformationalEvent());
expect(emgr.getListeners().length).toBe(0);
expect(call).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -1,17 +1,31 @@
export abstract class Event<Params = any> { export abstract class Event<Params = any, Returning = void> {
_returning!: Returning;
/** /**
* Unique event slug * Unique event slug
* Must be static, because registering events is done by class * Must be static, because registering events is done by class
*/ */
static slug: string = "untitled-event"; static slug: string = "untitled-event";
params: Params; params: Params;
returned: boolean = false;
validate(value: Returning): Event<Params, Returning> | void {}
protected clone<This extends Event<Params, Returning> = Event<Params, Returning>>(
this: This,
params: Params
): This {
const cloned = new (this.constructor as any)(params);
cloned.returned = true;
return cloned as This;
}
constructor(params: Params) { constructor(params: Params) {
this.params = params; this.params = params;
} }
} }
// @todo: current workaround: potentially there is none and that's the way // @todo: current workaround: potentially there is "none" and that's the way
export class NoParamEvent extends Event<null> { export class NoParamEvent extends Event<null> {
static override slug: string = "noparam-event"; static override slug: string = "noparam-event";
@@ -19,3 +33,9 @@ export class NoParamEvent extends Event<null> {
super(null); super(null);
} }
} }
export class InvalidEventReturn extends Error {
constructor(expected: string, given: string) {
super(`Expected "${expected}", got "${given}"`);
}
}

View File

@@ -4,15 +4,16 @@ import type { EventClass } from "./EventManager";
export const ListenerModes = ["sync", "async"] as const; export const ListenerModes = ["sync", "async"] as const;
export type ListenerMode = (typeof ListenerModes)[number]; export type ListenerMode = (typeof ListenerModes)[number];
export type ListenerHandler<E extends Event = Event> = ( export type ListenerHandler<E extends Event<any, any>> = (
event: E, event: E,
slug: string, slug: string
) => Promise<void> | void; ) => E extends Event<any, infer R> ? R | Promise<R | void> : never;
export class EventListener<E extends Event = Event> { export class EventListener<E extends Event = Event> {
mode: ListenerMode = "async"; mode: ListenerMode = "async";
event: EventClass; event: EventClass;
handler: ListenerHandler<E>; handler: ListenerHandler<E>;
once: boolean = false;
constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") { constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") {
this.event = event; this.event = event;

View File

@@ -1,4 +1,4 @@
import type { Event } from "./Event"; import { type Event, InvalidEventReturn } from "./Event";
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
export interface EmitsEvents { export interface EmitsEvents {
@@ -6,7 +6,7 @@ export interface EmitsEvents {
} }
export type EventClass = { export type EventClass = {
new (params: any): Event; new (params: any): Event<any, any>;
slug: string; slug: string;
}; };
@@ -17,16 +17,20 @@ export class EventManager<
protected listeners: EventListener[] = []; protected listeners: EventListener[] = [];
enabled: boolean = true; enabled: boolean = true;
constructor(events?: RegisteredEvents, listeners?: EventListener[]) { constructor(
events?: RegisteredEvents,
private options?: {
listeners?: EventListener[];
onError?: (event: Event, e: unknown) => void;
onInvalidReturn?: (event: Event, e: InvalidEventReturn) => void;
asyncExecutor?: typeof Promise.all;
}
) {
if (events) { if (events) {
this.registerEvents(events); this.registerEvents(events);
} }
if (listeners) { options?.listeners?.forEach((l) => this.addListener(l));
for (const listener of listeners) {
this.addListener(listener);
}
}
} }
enable() { enable() {
@@ -128,6 +132,18 @@ export class EventManager<
this.addListener(listener as any); this.addListener(listener as any);
} }
onEventOnce<ActualEvent extends EventClass, Instance extends InstanceType<ActualEvent>>(
event: ActualEvent,
handler: ListenerHandler<Instance>,
mode: ListenerMode = "async"
) {
this.throwIfEventNotRegistered(event);
const listener = new EventListener(event, handler, mode);
listener.once = true;
this.addListener(listener as any);
}
on<Params = any>( on<Params = any>(
slug: string, slug: string,
handler: ListenerHandler<Event<Params>>, handler: ListenerHandler<Event<Params>>,
@@ -145,27 +161,73 @@ export class EventManager<
this.events.forEach((event) => this.onEvent(event, handler, mode)); this.events.forEach((event) => this.onEvent(event, handler, mode));
} }
async emit(event: Event) { protected executeAsyncs(promises: (() => Promise<void>)[]) {
const executor = this.options?.asyncExecutor ?? ((e) => Promise.all(e));
executor(promises.map((p) => p())).then(() => void 0);
}
async emit<Actual extends Event<any, any>>(event: Actual): Promise<Actual> {
// @ts-expect-error slug is static // @ts-expect-error slug is static
const slug = event.constructor.slug; const slug = event.constructor.slug;
if (!this.enabled) { if (!this.enabled) {
console.log("EventManager disabled, not emitting", slug); console.log("EventManager disabled, not emitting", slug);
return; return event;
} }
if (!this.eventExists(event)) { if (!this.eventExists(event)) {
throw new Error(`Event "${slug}" not registered`); throw new Error(`Event "${slug}" not registered`);
} }
const listeners = this.listeners.filter((listener) => listener.event.slug === slug); const syncs: EventListener[] = [];
//console.log("---!-- emitting", slug, listeners.length); const asyncs: (() => Promise<void>)[] = [];
this.listeners = this.listeners.filter((listener) => {
// if no match, keep and ignore
if (listener.event.slug !== slug) return true;
for (const listener of listeners) {
if (listener.mode === "sync") { if (listener.mode === "sync") {
await listener.handler(event, listener.event.slug); syncs.push(listener);
} else { } else {
listener.handler(event, listener.event.slug); asyncs.push(async () => await listener.handler(event, listener.event.slug));
}
// Remove if `once` is true, otherwise keep
return !listener.once;
});
// execute asyncs
this.executeAsyncs(asyncs);
// execute syncs
let _event: Actual = event;
for (const listener of syncs) {
try {
const return_value = (await listener.handler(_event, listener.event.slug)) as any;
if (typeof return_value !== "undefined") {
const newEvent = _event.validate(return_value);
// @ts-expect-error slug is static
if (newEvent && newEvent.constructor.slug === slug) {
if (!newEvent.returned) {
throw new Error(
// @ts-expect-error slug is static
`Returned event ${newEvent.constructor.slug} must be marked as returned.`
);
}
_event = newEvent as Actual;
}
}
} catch (e) {
if (e instanceof InvalidEventReturn) {
this.options?.onInvalidReturn?.(_event, e);
console.warn(`Invalid return of event listener for "${slug}": ${e.message}`);
} else if (this.options?.onError) {
this.options.onError(_event, e);
} else {
throw e;
}
} }
} }
return _event;
} }
} }

View File

@@ -1,8 +1,8 @@
export { Event, NoParamEvent } from "./Event"; export { Event, NoParamEvent, InvalidEventReturn } from "./Event";
export { export {
EventListener, EventListener,
ListenerModes, ListenerModes,
type ListenerMode, type ListenerMode,
type ListenerHandler, type ListenerHandler
} from "./EventListener"; } from "./EventListener";
export { EventManager, type EmitsEvents, type EventClass } from "./EventManager"; export { EventManager, type EmitsEvents, type EventClass } from "./EventManager";

View File

@@ -1,150 +0,0 @@
Subject: [PATCH] event manager returning test
---
Index: app/__test__/core/EventManager.spec.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/app/__test__/core/EventManager.spec.ts b/app/__test__/core/EventManager.spec.ts
--- a/app/__test__/core/EventManager.spec.ts (revision f06777256f332766de4bc76c23183725c8c7d310)
+++ b/app/__test__/core/EventManager.spec.ts (date 1731498680965)
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test";
-import { Event, EventManager, NoParamEvent } from "../../src/core/events";
+import { Event, EventManager, type ListenerHandler, NoParamEvent } from "../../src/core/events";
class SpecialEvent extends Event<{ foo: string }> {
- static slug = "special-event";
+ static override slug = "special-event";
isBar() {
return this.params.foo === "bar";
@@ -10,7 +10,19 @@
}
class InformationalEvent extends NoParamEvent {
- static slug = "informational-event";
+ static override slug = "informational-event";
+}
+
+class ReturnEvent extends Event<{ foo: string }, number> {
+ static override slug = "return-event";
+ static override returning = true;
+
+ override setValidatedReturn(value: number) {
+ if (typeof value !== "number") {
+ throw new Error("Invalid return value");
+ }
+ this.params.foo = value.toString();
+ }
}
describe("EventManager", async () => {
@@ -43,4 +55,22 @@
expect(true).toBe(true);
});
+
+ test.only("piping", async () => {
+ const emgr = new EventManager();
+ emgr.registerEvents([ReturnEvent, InformationalEvent]);
+
+ type T = ListenerHandler<ReturnEvent>;
+
+ // @ts-expect-error InformationalEvent has no return value
+ emgr.onEvent(InformationalEvent, async (event, name) => {
+ console.log("Event: ", name, event.params);
+ return 1;
+ });
+
+ emgr.onEvent(ReturnEvent, async (event, name) => {
+ console.log("Event: ", name, event.params);
+ return 1;
+ });
+ });
});
Index: app/src/core/events/EventManager.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts
--- a/app/src/core/events/EventManager.ts (revision f06777256f332766de4bc76c23183725c8c7d310)
+++ b/app/src/core/events/EventManager.ts (date 1731498680971)
@@ -6,7 +6,7 @@
}
export type EventClass = {
- new (params: any): Event;
+ new (params: any): Event<any, any>;
slug: string;
};
@@ -137,6 +137,9 @@
throw new Error(`Event "${slug}" not registered`);
}
+ // @ts-expect-error returning is static
+ const returning = Boolean(event.constructor.returning);
+
const listeners = this.listeners.filter((listener) => listener.event.slug === slug);
//console.log("---!-- emitting", slug, listeners.length);
Index: app/src/core/events/EventListener.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/app/src/core/events/EventListener.ts b/app/src/core/events/EventListener.ts
--- a/app/src/core/events/EventListener.ts (revision f06777256f332766de4bc76c23183725c8c7d310)
+++ b/app/src/core/events/EventListener.ts (date 1731498680968)
@@ -4,10 +4,10 @@
export const ListenerModes = ["sync", "async"] as const;
export type ListenerMode = (typeof ListenerModes)[number];
-export type ListenerHandler<E extends Event = Event> = (
+export type ListenerHandler<E extends Event<any, any>> = (
event: E,
- slug: string,
-) => Promise<void> | void;
+ slug: string
+) => E extends Event<any, infer R> ? R | Promise<R> : never;
export class EventListener<E extends Event = Event> {
mode: ListenerMode = "async";
Index: app/src/core/events/Event.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/app/src/core/events/Event.ts b/app/src/core/events/Event.ts
--- a/app/src/core/events/Event.ts (revision f06777256f332766de4bc76c23183725c8c7d310)
+++ b/app/src/core/events/Event.ts (date 1731498680973)
@@ -1,17 +1,25 @@
-export abstract class Event<Params = any> {
+export abstract class Event<Params = any, Returning = void> {
/**
* Unique event slug
* Must be static, because registering events is done by class
*/
static slug: string = "untitled-event";
params: Params;
+ _returning!: Returning;
+ static returning: boolean = false;
+
+ setValidatedReturn(value: Returning): void {
+ if (typeof value !== "undefined") {
+ throw new Error("Invalid event return value");
+ }
+ }
constructor(params: Params) {
this.params = params;
}
}
-// @todo: current workaround: potentially there is none and that's the way
+// @todo: current workaround: potentially there is "none" and that's the way
export class NoParamEvent extends Event<null> {
static override slug: string = "noparam-event";