import { toDisposable, type IDisposable } from "#/_base/di/lifecycle"; import { BugIndicatingError } from "#/errors"; export type Hooks> = { readonly [K in keyof TEvents]: HookSlot; }; export interface HookSlot { register( id: string, handler: HookHandler, options?: HookRegisterOptions, ): IDisposable; delete(id: string): boolean; run(context: TContext, terminal?: (context: TContext) => Promise): Promise; } export type HookHandler = ( context: TContext, next: (context?: TContext) => Promise, ) => void | Promise; export interface HookRegisterOptions { before?: string; after?: string; } interface HookEntry { readonly id: string; readonly handler: HookHandler; } export class OrderedHookSlot implements HookSlot { private entries: HookEntry[] = []; register( id: string, handler: HookHandler, options: HookRegisterOptions = {}, ): IDisposable { if (options.before !== undefined && options.after !== undefined) { throw new BugIndicatingError('Hook registration cannot specify both before and after'); } this.delete(id); const entry = { id, handler }; const target = options.before ?? options.after; if (target === undefined) { this.entries.push(entry); return this.toEntryDisposable(entry); } const targetIndex = this.entries.findIndex((item) => item.id === target); if (targetIndex < 0) { throw new BugIndicatingError(`Hook target "${target}" is not registered`); } const insertAt = options.before !== undefined ? targetIndex : targetIndex + 1; this.entries.splice(insertAt, 0, entry); return this.toEntryDisposable(entry); } delete(id: string): boolean { const index = this.entries.findIndex((entry) => entry.id === id); if (index < 0) return false; this.entries.splice(index, 1); return true; } asDisposable(id: string): IDisposable { return toDisposable(() => { this.delete(id); }); } private toEntryDisposable(entry: HookEntry): IDisposable { return toDisposable(() => { const index = this.entries.indexOf(entry); if (index < 0) return; this.entries.splice(index, 1); }); } async run( context: TContext, terminal: (context: TContext) => Promise = async () => {}, ): Promise { const entries = [...this.entries]; const dispatch = (index: number, ctx: TContext): ((override?: TContext) => Promise) => { return async (override?: TContext): Promise => { const current = override ?? ctx; const entry = entries[index]; if (entry === undefined) { await terminal(current); return; } await entry.handler(current, dispatch(index + 1, current)); }; }; await dispatch(0, context)(); } } export function createHooks, TKeys extends keyof TEvents>( keys: readonly TKeys[], ): Hooks { return Object.fromEntries( keys.map((key) => [key, new OrderedHookSlot()]), ) as unknown as Hooks; }