refactored EventManager to run asyncs on call only, app defaults to run before response (#129)

* refactored EventManager to run asyncs on call only, app defaults to run before response

* fix tests
This commit is contained in:
dswbx
2025-04-01 11:19:55 +02:00
committed by GitHub
parent 434d56672c
commit 36e4224b33
11 changed files with 244 additions and 64 deletions

View File

@@ -1,6 +1,6 @@
import { describe, expect, mock, test } from "bun:test"; import { describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src"; import type { ModuleBuildContext } from "../../src";
import { type App, createApp } from "../../src/App"; import { App, createApp } from "../../src/App";
import * as proto from "../../src/data/prototype"; import * as proto from "../../src/data/prototype";
describe("App", () => { describe("App", () => {
@@ -51,4 +51,87 @@ describe("App", () => {
expect(todos[0]?.title).toBe("ctx"); expect(todos[0]?.title).toBe("ctx");
expect(todos[1]?.title).toBe("api"); expect(todos[1]?.title).toBe("api");
}); });
test("lifecycle events are triggered", async () => {
const firstBoot = mock(() => null);
const configUpdate = mock(() => null);
const appBuilt = mock(() => null);
const appRequest = mock(() => null);
const beforeResponse = mock(() => null);
const app = createApp();
app.emgr.onEvent(
App.Events.AppFirstBoot,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppFirstBoot);
expect(event.params.app.version()).toBe(app.version());
firstBoot();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppBuiltEvent,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppBuiltEvent);
expect(event.params.app.version()).toBe(app.version());
appBuilt();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
() => {
configUpdate();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppRequest,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppRequest);
expect(event.params.app.version()).toBe(app.version());
expect(event.params.request).toBeInstanceOf(Request);
appRequest();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppBeforeResponse,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppBeforeResponse);
expect(event.params.app.version()).toBe(app.version());
expect(event.params.response).toBeInstanceOf(Response);
beforeResponse();
},
"sync",
);
await app.build();
expect(firstBoot).toHaveBeenCalled();
expect(appBuilt).toHaveBeenCalled();
//expect(configUpdate).toHaveBeenCalled();
expect(appRequest).not.toHaveBeenCalled();
expect(beforeResponse).not.toHaveBeenCalled();
});
test("emgr exec modes", async () => {
const called = mock(() => null);
const app = createApp({
options: {
asyncEventsMode: "sync",
},
});
// register async listener
app.emgr.onEvent(App.Events.AppFirstBoot, async () => {
called();
});
await app.build();
await app.server.request(new Request("http://localhost"));
// expect async listeners to be executed sync after request
expect(called).toHaveBeenCalled();
});
}); });

View File

@@ -70,6 +70,9 @@ describe("EventManager", async () => {
new SpecialEvent({ foo: "bar" }); new SpecialEvent({ foo: "bar" });
new InformationalEvent(); new InformationalEvent();
// execute asyncs
await emgr.executeAsyncs();
expect(call).toHaveBeenCalledTimes(2); expect(call).toHaveBeenCalledTimes(2);
expect(delayed).toHaveBeenCalled(); expect(delayed).toHaveBeenCalled();
}); });
@@ -80,15 +83,11 @@ describe("EventManager", async () => {
call(); call();
return Promise.all(p); return Promise.all(p);
}; };
const emgr = new EventManager( const emgr = new EventManager({ InformationalEvent });
{ InformationalEvent },
{
asyncExecutor,
},
);
emgr.onEvent(InformationalEvent, async () => {}); emgr.onEvent(InformationalEvent, async () => {});
await emgr.emit(new InformationalEvent()); await emgr.emit(new InformationalEvent());
await emgr.executeAsyncs(asyncExecutor);
expect(call).toHaveBeenCalled(); expect(call).toHaveBeenCalled();
}); });
@@ -125,6 +124,9 @@ describe("EventManager", async () => {
const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" })); const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" }));
expect(e2.returned).toBe(true); expect(e2.returned).toBe(true);
expect(e2.params.foo).toBe("bar-1-0"); expect(e2.params.foo).toBe("bar-1-0");
await emgr.executeAsyncs();
expect(onInvalidReturn).toHaveBeenCalled(); expect(onInvalidReturn).toHaveBeenCalled();
expect(asyncEventCallback).toHaveBeenCalled(); expect(asyncEventCallback).toHaveBeenCalled();
}); });

View File

@@ -288,14 +288,17 @@ describe("[data] Mutator (Events)", async () => {
test("events were fired", async () => { test("events were fired", async () => {
const { data } = await mutator.insertOne({ label: "test" }); const { data } = await mutator.insertOne({ label: "test" });
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue();
await mutator.updateOne(data.id, { label: "test2" }); await mutator.updateOne(data.id, { label: "test2" });
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue();
await mutator.deleteOne(data.id); await mutator.deleteOne(data.id);
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
}); });

View File

@@ -198,22 +198,27 @@ describe("[data] Repository (Events)", async () => {
}); });
test("events were fired", async () => { test("events were fired", async () => {
await em.repository(items).findId(1); const repo = em.repository(items);
await repo.findId(1);
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear(); events.clear();
await em.repository(items).findOne({ id: 1 }); await repo.findOne({ id: 1 });
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear(); events.clear();
await em.repository(items).findMany({ where: { id: 1 } }); await repo.findMany({ where: { id: 1 } });
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear(); events.clear();
await em.repository(items).findManyByReference(1, "categories"); await repo.findManyByReference(1, "categories");
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear(); events.clear();

View File

@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { type FileBody, Storage, type StorageAdapter } from "../../src/media/storage/Storage"; import { type FileBody, Storage } from "../../src/media/storage/Storage";
import * as StorageEvents from "../../src/media/storage/events"; import * as StorageEvents from "../../src/media/storage/events";
import { StorageAdapter } from "media";
class TestAdapter implements StorageAdapter { class TestAdapter extends StorageAdapter {
files: Record<string, FileBody> = {}; files: Record<string, FileBody> = {};
getName() { getName() {
@@ -61,7 +62,7 @@ describe("Storage", async () => {
test("uploads a file", async () => { test("uploads a file", async () => {
const { const {
meta: { type, size }, meta: { type, size },
} = await storage.uploadFile("hello", "world.txt"); } = await storage.uploadFile("hello" as any, "world.txt");
expect({ type, size }).toEqual({ type: "text/plain", size: 0 }); expect({ type, size }).toEqual({ type: "text/plain", size: 0 });
}); });
@@ -71,6 +72,7 @@ describe("Storage", async () => {
}); });
test("events were fired", async () => { test("events were fired", async () => {
await storage.emgr.executeAsyncs();
expect(events.has(StorageEvents.FileUploadedEvent.slug)).toBeTrue(); expect(events.has(StorageEvents.FileUploadedEvent.slug)).toBeTrue();
expect(events.has(StorageEvents.FileDeletedEvent.slug)).toBeTrue(); expect(events.has(StorageEvents.FileDeletedEvent.slug)).toBeTrue();
// @todo: file access must be tested in controllers // @todo: file access must be tested in controllers

View File

@@ -4,9 +4,10 @@ import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { import {
ModuleManager,
type InitialModuleConfigs, type InitialModuleConfigs,
type ModuleBuildContext, type ModuleBuildContext,
ModuleManager, type ModuleConfigs,
type ModuleManagerOptions, type ModuleManagerOptions,
type Modules, type Modules,
} from "modules/ModuleManager"; } from "modules/ModuleManager";
@@ -16,6 +17,7 @@ import { SystemController } from "modules/server/SystemController";
// biome-ignore format: must be there // biome-ignore format: must be there
import { Api, type ApiOptions } from "Api"; import { Api, type ApiOptions } from "Api";
import type { ServerEnv } from "modules/Controller";
export type AppPlugin = (app: App) => Promise<void> | void; export type AppPlugin = (app: App) => Promise<void> | void;
@@ -29,12 +31,25 @@ export class AppBuiltEvent extends AppEvent {
export class AppFirstBoot extends AppEvent { export class AppFirstBoot extends AppEvent {
static override slug = "app-first-boot"; static override slug = "app-first-boot";
} }
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const; export class AppRequest extends AppEvent<{ request: Request }> {
static override slug = "app-request";
}
export class AppBeforeResponse extends AppEvent<{ request: Request; response: Response }> {
static override slug = "app-before-response";
}
export const AppEvents = {
AppConfigUpdatedEvent,
AppBuiltEvent,
AppFirstBoot,
AppRequest,
AppBeforeResponse,
} as const;
export type AppOptions = { export type AppOptions = {
plugins?: AppPlugin[]; plugins?: AppPlugin[];
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>; seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">; manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
asyncEventsMode?: "sync" | "async" | "none";
}; };
export type CreateAppConfig = { export type CreateAppConfig = {
connection?: connection?:
@@ -70,35 +85,9 @@ export class App {
this.modules = new ModuleManager(connection, { this.modules = new ModuleManager(connection, {
...(options?.manager ?? {}), ...(options?.manager ?? {}),
initial: _initialConfig, initial: _initialConfig,
onUpdated: async (key, config) => { onUpdated: this.onUpdated.bind(this),
// if the EventManager was disabled, we assume we shouldn't onFirstBoot: this.onFirstBoot.bind(this),
// respond to events, such as "onUpdated". onServerInit: this.onServerInit.bind(this),
// this is important if multiple changes are done, and then build() is called manually
if (!this.emgr.enabled) {
$console.warn("App config updated, but event manager is disabled, skip.");
return;
}
$console.log("App config updated", key);
// @todo: potentially double syncing
await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
},
onFirstBoot: async () => {
$console.log("App first boot");
this.trigger_first_boot = true;
},
onServerInit: async (server) => {
server.use(async (c, next) => {
c.set("app", this);
await next();
try {
// gracefully add the app id
c.res.headers.set("X-bknd-id", this._id);
} catch (e) {}
});
},
}); });
this.modules.ctx().emgr.registerEvents(AppEvents); this.modules.ctx().emgr.registerEvents(AppEvents);
} }
@@ -213,6 +202,53 @@ export class App {
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher }); return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
} }
async onUpdated<Module extends keyof Modules>(module: Module, config: ModuleConfigs[Module]) {
// if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".
// this is important if multiple changes are done, and then build() is called manually
if (!this.emgr.enabled) {
$console.warn("App config updated, but event manager is disabled, skip.");
return;
}
$console.log("App config updated", module);
// @todo: potentially double syncing
await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
}
async onFirstBoot() {
$console.log("App first boot");
this.trigger_first_boot = true;
}
async onServerInit(server: Hono<ServerEnv>) {
server.use(async (c, next) => {
c.set("app", this);
await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw }));
await next();
try {
// gracefully add the app id
c.res.headers.set("X-bknd-id", this._id);
} catch (e) {}
await this.emgr.emit(
new AppBeforeResponse({ app: this, request: c.req.raw, response: c.res }),
);
// execute collected async events (async by default)
switch (this.options?.asyncEventsMode ?? "async") {
case "sync":
await this.emgr.executeAsyncs();
break;
case "async":
this.emgr.executeAsyncs();
break;
}
});
}
} }
export function createApp(config: CreateAppConfig = {}) { export function createApp(config: CreateAppConfig = {}) {

View File

@@ -9,6 +9,7 @@ import { getBinding } from "./bindings";
import { getCached } from "./modes/cached"; import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable"; import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh"; import { getFresh, getWarm } from "./modes/fresh";
import type { CreateAppConfig } from "App";
export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & { export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
mode?: "warm" | "fresh" | "cache" | "durable"; mode?: "warm" | "fresh" | "cache" | "durable";
@@ -32,8 +33,14 @@ export type Context<Env = any> = {
ctx: ExecutionContext; ctx: ExecutionContext;
}; };
export const constants = {
exec_async_event_id: "cf_register_waituntil",
cache_endpoint: "/__bknd/cache",
do_endpoint: "/__bknd/do",
};
let media_registered: boolean = false; let media_registered: boolean = false;
export function makeCfConfig(config: CloudflareBkndConfig, context: Context) { export function makeCfConfig(config: CloudflareBkndConfig, context: Context): CreateAppConfig {
if (!media_registered) { if (!media_registered) {
registerMedia(context.env as any); registerMedia(context.env as any);
media_registered = true; media_registered = true;
@@ -61,7 +68,14 @@ export function makeCfConfig(config: CloudflareBkndConfig, context: Context) {
} }
} }
return appConfig; return {
...appConfig,
options: {
...appConfig.options,
// if not specified explicitly, disable it to use ExecutionContext's waitUntil
asyncEventsMode: config.options?.asyncEventsMode ?? "none",
},
};
} }
export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) { export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {

View File

@@ -1,6 +1,6 @@
import { App } from "bknd"; import { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter"; import { createRuntimeApp } from "bknd/adapter";
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index"; import { type CloudflareBkndConfig, constants, type Context, makeCfConfig } from "../index";
export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) { export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
const { kv } = config.bindings?.(env)!; const { kv } = config.bindings?.(env)!;
@@ -19,13 +19,23 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg
...makeCfConfig(config, { env, ctx, ...args }), ...makeCfConfig(config, { env, ctx, ...args }),
initialConfig, initialConfig,
onBuilt: async (app) => { onBuilt: async (app) => {
app.module.server.client.get("/__bknd/cache", async (c) => { app.module.server.client.get(constants.cache_endpoint, async (c) => {
await kv.delete(key); await kv.delete(key);
return c.json({ message: "Cache cleared" }); return c.json({ message: "Cache cleared" });
}); });
await config.onBuilt?.(app); await config.onBuilt?.(app);
}, },
beforeBuild: async (app) => { beforeBuild: async (app) => {
app.emgr.onEvent(
App.Events.AppBeforeResponse,
async (event) => {
ctx.waitUntil(event.params.app.emgr.executeAsyncs());
},
{
mode: "sync",
id: constants.exec_async_event_id,
},
);
app.emgr.onEvent( app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent, App.Events.AppConfigUpdatedEvent,
async ({ params: { app } }) => { async ({ params: { app } }) => {

View File

@@ -1,7 +1,7 @@
import { DurableObject } from "cloudflare:workers"; import { DurableObject } from "cloudflare:workers";
import type { App, CreateAppConfig } from "bknd"; import { App, type CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter"; import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index"; import { type CloudflareBkndConfig, type Context, constants } from "../index";
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const { dobj } = config.bindings?.(ctx.env)!; const { dobj } = config.bindings?.(ctx.env)!;
@@ -67,7 +67,17 @@ export class DurableBkndApp extends DurableObject {
this.app = await createRuntimeApp({ this.app = await createRuntimeApp({
...config, ...config,
onBuilt: async (app) => { onBuilt: async (app) => {
app.modules.server.get("/__do", async (c) => { app.emgr.onEvent(
App.Events.AppBeforeResponse,
async (event) => {
this.ctx.waitUntil(event.params.app.emgr.executeAsyncs());
},
{
mode: "sync",
id: constants.exec_async_event_id,
},
);
app.modules.server.get(constants.do_endpoint, async (c) => {
// @ts-ignore // @ts-ignore
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
return c.json({ return c.json({
@@ -92,7 +102,6 @@ export class DurableBkndApp extends DurableObject {
this.keepAlive(options.keepAliveSeconds); this.keepAlive(options.keepAliveSeconds);
} }
console.log("id", this.id);
const res = await this.app!.fetch(request); const res = await this.app!.fetch(request);
const headers = new Headers(res.headers); const headers = new Headers(res.headers);
headers.set("X-BuildTime", buildtime.toString()); headers.set("X-BuildTime", buildtime.toString());
@@ -109,16 +118,13 @@ export class DurableBkndApp extends DurableObject {
async beforeBuild(app: App) {} async beforeBuild(app: App) {}
protected keepAlive(seconds: number) { protected keepAlive(seconds: number) {
console.log("keep alive for", seconds);
if (this.interval) { if (this.interval) {
console.log("clearing, there is a new");
clearInterval(this.interval); clearInterval(this.interval);
} }
let i = 0; let i = 0;
this.interval = setInterval(() => { this.interval = setInterval(() => {
i += 1; i += 1;
//console.log("keep-alive", i);
if (i === seconds) { if (i === seconds) {
console.log("cleared"); console.log("cleared");
clearInterval(this.interval); clearInterval(this.interval);

View File

@@ -1,12 +1,25 @@
import type { App } from "bknd"; import { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter"; import { createRuntimeApp } from "bknd/adapter";
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index"; import { type CloudflareBkndConfig, type Context, makeCfConfig, constants } from "../index";
export async function makeApp(config: CloudflareBkndConfig, ctx: Context) { export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
return await createRuntimeApp( return await createRuntimeApp(
{ {
...makeCfConfig(config, ctx), ...makeCfConfig(config, ctx),
adminOptions: config.html ? { html: config.html } : undefined, adminOptions: config.html ? { html: config.html } : undefined,
onBuilt: async (app) => {
app.emgr.onEvent(
App.Events.AppBeforeResponse,
async (event) => {
ctx.ctx.waitUntil(event.params.app.emgr.executeAsyncs());
},
{
mode: "sync",
id: constants.exec_async_event_id,
},
);
await config.onBuilt?.(app);
},
}, },
ctx, ctx,
); );

View File

@@ -22,6 +22,7 @@ export class EventManager<
protected events: EventClass[] = []; protected events: EventClass[] = [];
protected listeners: EventListener[] = []; protected listeners: EventListener[] = [];
enabled: boolean = true; enabled: boolean = true;
protected asyncs: (() => Promise<void>)[] = [];
constructor( constructor(
events?: RegisteredEvents, events?: RegisteredEvents,
@@ -29,7 +30,6 @@ export class EventManager<
listeners?: EventListener[]; listeners?: EventListener[];
onError?: (event: Event, e: unknown) => void; onError?: (event: Event, e: unknown) => void;
onInvalidReturn?: (event: Event, e: InvalidEventReturn) => void; onInvalidReturn?: (event: Event, e: InvalidEventReturn) => void;
asyncExecutor?: typeof Promise.all;
}, },
) { ) {
if (events) { if (events) {
@@ -176,9 +176,15 @@ export class EventManager<
this.events.forEach((event) => this.onEvent(event, handler, config)); this.events.forEach((event) => this.onEvent(event, handler, config));
} }
protected executeAsyncs(promises: (() => Promise<void>)[]) { protected collectAsyncs(promises: (() => Promise<void>)[]) {
const executor = this.options?.asyncExecutor ?? ((e) => Promise.all(e)); this.asyncs.push(...promises);
executor(promises.map((p) => p())).then(() => void 0); }
async executeAsyncs(executor: typeof Promise.all = (e) => Promise.all(e)): Promise<void> {
if (this.asyncs.length === 0) return;
const asyncs = [...this.asyncs];
this.asyncs = [];
await executor(asyncs.map((p) => p()));
} }
async emit<Actual extends Event<any, any>>(event: Actual): Promise<Actual> { async emit<Actual extends Event<any, any>>(event: Actual): Promise<Actual> {
@@ -209,8 +215,8 @@ export class EventManager<
return !listener.once; return !listener.once;
}); });
// execute asyncs // collect asyncs
this.executeAsyncs(asyncs); this.collectAsyncs(asyncs);
// execute syncs // execute syncs
let _event: Actual = event; let _event: Actual = event;