alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
395 lines (349 loc) • 11.5 kB
text/typescript
import type { Hook, Hooks } from "../Alepha.ts";
import { AlephaError } from "../errors/AlephaError.ts";
import type { Async } from "../interfaces/Async.ts";
import type { LoggerInterface } from "../interfaces/LoggerInterface.ts";
import type { Service } from "../interfaces/Service.ts";
/**
* Compiled event executor - optimized for hot paths.
* Returns void for sync-only chains, Promise<void> for chains with async hooks.
*/
export type CompiledEventExecutor<T extends keyof Hooks> = (
payload: Hooks[T],
) => void | Promise<void>;
/**
* Options for compiled event executors.
*/
export interface CompileOptions {
/**
* If true, errors will be caught and logged instead of throwing.
* @default false
*/
catch?: boolean;
}
export class EventManager {
public logFn?: () => LoggerInterface | undefined;
/**
* Three-list storage for hooks, split by priority tier.
*/
protected events: Record<
string,
{ first: Array<Hook>; normal: Array<Hook>; last: Array<Hook> }
> = {};
/**
* Cache of compiled executors, auto-built on first emit per event.
*/
protected cache = new Map<string, CompiledEventExecutor<any>>();
/**
* Cache of resolved (topo-sorted) hook arrays per event.
*/
protected sortedCache = new Map<string, Array<Hook>>();
constructor(logFn?: () => LoggerInterface | undefined) {
this.logFn = logFn;
}
protected get log(): LoggerInterface | undefined {
return this.logFn?.();
}
public clear(): void {
this.events = {};
this.cache.clear();
this.sortedCache.clear();
}
/**
* Registers a hook for the specified event.
*/
public on<T extends keyof Hooks>(
event: T,
hookOrFunc: Hook<T> | ((payload: Hooks[T]) => Async<void>),
): () => void {
if (!this.events[event]) {
this.events[event] = { first: [], normal: [], last: [] };
}
const hook =
typeof hookOrFunc === "function" ? { callback: hookOrFunc } : hookOrFunc;
const lists = this.events[event];
if (hook.priority === "first") {
lists.first.push(hook);
} else if (hook.priority === "last") {
lists.last.push(hook);
} else {
lists.normal.push(hook);
}
this.invalidateCache(event as string);
return () => {
const lists = this.events[event];
if (!lists) return;
for (const key of ["first", "normal", "last"] as const) {
lists[key] = lists[key].filter((it) => it.callback !== hook.callback);
}
this.invalidateCache(event as string);
};
}
protected invalidateCache(event: string): void {
this.cache.delete(event);
this.cache.delete(`${event}:catch`);
this.sortedCache.delete(event);
}
/**
* Resolves the final ordered hook list for an event.
* Applies topological sort (Kahn's algorithm) within each priority tier
* based on before/after constraints.
*/
protected resolve(event: string): Array<Hook> {
const cached = this.sortedCache.get(event);
if (cached) return cached;
const lists = this.events[event];
if (!lists) return [];
const sorted = [
...this.topoSort(lists.first),
...this.topoSort(lists.normal),
...this.topoSort(lists.last),
];
this.sortedCache.set(event, sorted);
return sorted;
}
/**
* Topological sort using Kahn's algorithm.
* Hooks with no constraints maintain registration order (stable).
*/
protected topoSort(hooks: Array<Hook>): Array<Hook> {
if (hooks.length <= 1) return hooks;
// Check if any hook has constraints — skip sort if none
const hasConstraints = hooks.some(
(h) =>
(h.before && h.before.length > 0) || (h.after && h.after.length > 0),
);
if (!hasConstraints) return hooks;
// Build adjacency: edge from A → B means A must run before B
const inDegree = new Map<Hook, number>();
const adjacency = new Map<Hook, Set<Hook>>();
for (const hook of hooks) {
inDegree.set(hook, 0);
adjacency.set(hook, new Set());
}
// Build a map from service class → hooks in this list owned by that service
const serviceToHooks = new Map<Service, Array<Hook>>();
for (const hook of hooks) {
if (hook.caller) {
let arr = serviceToHooks.get(hook.caller);
if (!arr) {
arr = [];
serviceToHooks.set(hook.caller, arr);
}
arr.push(hook);
}
}
// "after: [ServiceA]" means this hook runs after ServiceA's hooks → edge from ServiceA's hooks → this hook
for (const hook of hooks) {
if (hook.after) {
for (const dep of hook.after) {
const depHooks = serviceToHooks.get(dep);
if (depHooks) {
for (const depHook of depHooks) {
if (depHook !== hook && !adjacency.get(depHook)!.has(hook)) {
adjacency.get(depHook)!.add(hook);
inDegree.set(hook, inDegree.get(hook)! + 1);
}
}
}
}
}
// "before: [ServiceB]" means this hook runs before ServiceB's hooks → edge from this hook → ServiceB's hooks
if (hook.before) {
for (const dep of hook.before) {
const depHooks = serviceToHooks.get(dep);
if (depHooks) {
for (const depHook of depHooks) {
if (depHook !== hook && !adjacency.get(hook)!.has(depHook)) {
adjacency.get(hook)!.add(depHook);
inDegree.set(depHook, inDegree.get(depHook)! + 1);
}
}
}
}
}
}
// Kahn's algorithm with stable ordering (registration order as tiebreaker)
const queue: Array<Hook> = [];
for (const hook of hooks) {
if (inDegree.get(hook)! === 0) {
queue.push(hook);
}
}
const result: Array<Hook> = [];
while (queue.length > 0) {
const hook = queue.shift()!;
result.push(hook);
for (const neighbor of adjacency.get(hook)!) {
const deg = inDegree.get(neighbor)! - 1;
inDegree.set(neighbor, deg);
if (deg === 0) {
queue.push(neighbor);
}
}
}
if (result.length !== hooks.length) {
throw new AlephaError(
"Circular dependency detected in hook before/after constraints",
);
}
return result;
}
/**
* Compiles an event into an optimized executor function.
*
* Called automatically by emit() on first use. Can also be called
* manually for direct access to the executor.
*/
public compile<T extends keyof Hooks>(
event: T,
options: CompileOptions = {},
): CompiledEventExecutor<T> {
const hooks = this.resolve(event as string);
if (hooks.length === 0) {
return () => {};
}
const catchErrors = options.catch ?? false;
// Logger is captured at compile time. Since compilation happens after
// start() (on first emit), the logger is already fully configured and
// won't change afterwards — this is safe.
const log = this.log;
// Once the first async hook is encountered, all remaining hooks
// must run in this async continuation to preserve sequential ordering
const runRemainingAsync = async (
startIndex: number,
payload: Hooks[T],
): Promise<void> => {
for (let i = startIndex; i < hooks.length; i++) {
const hook = hooks[i];
try {
const result = hook.callback(payload);
if (result && typeof result === "object" && "then" in result) {
if (catchErrors) {
await (result as Promise<void>).catch((error) => {
log?.error(
`${String(event)}(${hook.caller?.name ?? "unknown"}) ERROR`,
error,
);
});
} else {
await result;
}
}
} catch (error) {
if (catchErrors) {
log?.error(
`${String(event)}(${hook.caller?.name ?? "unknown"}) ERROR`,
error,
);
} else {
throw error;
}
}
}
};
// Run sync hooks synchronously. On first async hook, switch to
// runRemainingAsync and return the Promise. If all hooks are sync,
// returns void (no Promise allocation).
return (payload: Hooks[T]): void | Promise<void> => {
for (let i = 0; i < hooks.length; i++) {
const hook = hooks[i];
try {
const result = hook.callback(payload);
if (result && typeof result === "object" && "then" in result) {
if (catchErrors) {
return (result as Promise<void>)
.catch((error) => {
log?.error(
`${String(event)}(${hook.caller?.name ?? "unknown"}) ERROR`,
error,
);
})
.then(() => runRemainingAsync(i + 1, payload));
}
return (result as Promise<void>).then(() =>
runRemainingAsync(i + 1, payload),
);
}
} catch (error) {
if (catchErrors) {
log?.error(
`${String(event)}(${hook.caller?.name ?? "unknown"}) ERROR`,
error,
);
} else {
throw error;
}
}
}
};
}
/**
* Emits the specified event with the given payload.
*
* Auto-compiles and caches an optimized executor on first call per event.
* Use `{ log: true }` for startup events that need timing information.
*/
public async emit<T extends keyof Hooks>(
event: T,
payload: Hooks[T],
options: {
/**
* If true, the hooks will be logged with their execution time.
*
* @default false
*/
log?: boolean;
/**
* If true, errors will be caught and logged instead of throwing.
*
* @default false
*/
catch?: boolean;
} = {},
): Promise<void> {
// Fast path: auto-compiled executor
if (!options.log) {
const cacheKey = options.catch
? `${event as string}:catch`
: (event as string);
let executor = this.cache.get(cacheKey);
if (!executor) {
executor = this.compile(event, { catch: options.catch });
this.cache.set(cacheKey, executor);
}
await executor(payload);
return;
}
// Slow path with logging (startup only)
const events = this.resolve(event as string);
if (events.length === 0) return;
const now = performance.now();
this.log?.trace(`${String(event)} ...`);
for (const hook of events) {
const name = hook.caller?.name ?? "unknown";
const hookStart = performance.now();
this.log?.trace(`${String(event)}(${name}) ...`);
try {
const result = hook.callback(payload);
if (result && typeof result === "object" && "then" in result) {
await result;
}
} catch (error) {
if (options.catch) {
this.log?.error(`${String(event)}(${name}) ERROR`, error);
continue;
}
throw new AlephaError(
`Failed during '${String(event)}()' hook for service: ${name}`,
{ cause: error },
);
}
this.log?.debug(
`${String(event)}(${name}) OK [${(performance.now() - hookStart).toFixed(1)}ms]`,
);
}
this.log?.debug(
`${String(event)} OK [${(performance.now() - now).toFixed(1)}ms]`,
);
}
}