/** * Extends a function arguments with extra ones. */ type FnWithExtraArgs< // eslint-disable-next-line @typescript-eslint/no-explicit-any F extends (...args: any[]) => any, // eslint-disable-next-line @typescript-eslint/no-explicit-any TExtraArgs extends any[] = any[], > = ( ...args: [...args: Parameters, ...extraArgs: TExtraArgs] ) => ReturnType; /** * Defines a hook handler. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type HookFn = ( ...args: TArgs ) => Promise | TReturn; /** * Generic hook metadata. */ type HookMeta = Record; /** * Defines a hook, including its function handler and optional metadata. */ export type Hook< THookFn extends HookFn = HookFn, THookMeta extends HookMeta = HookMeta, > = { fn: THookFn; meta?: THookMeta; }; /** * Represents a map of hook types to hook functions and metas. */ type Hooks = Record; /** * Builds hook meta arguments after hook meta requirements. */ type HookMetaArg | undefined> = THookMeta extends Record ? [meta: THookMeta] : [meta?: never]; /** * Defines the return type of the {@link HookSystem.callHook} functions. * * @internal */ export type CallHookReturnType = Promise<{ data: Awaited>[]; errors: HookError[]; }>; /** * Defines the return type of the {@link HookSystem.createScope} functions. * * @internal */ export type CreateScopeReturnType< THooks extends Hooks = Record, TExtraArgs extends unknown[] = never[], > = { hook: ( type: TType, hookFn: FnWithExtraArgs, ...[meta]: HookMetaArg ) => void; unhook: HookSystem<{ [P in keyof THooks]: Omit & { fn: FnWithExtraArgs; }; }>["unhook"]; }; type RegisteredHookMeta = { id: string; type: string; owner: string; external?: HookFn; }; /** * Represents a registered hook. */ type RegisteredHook = { fn: THook["fn"]; meta: THook["meta"] extends Record ? RegisteredHookMeta & THook["meta"] : RegisteredHookMeta; }; export class HookError extends Error { type: string; owner: string; rawMeta: RegisteredHookMeta; rawCause: TError; constructor(meta: RegisteredHookMeta, cause: TError) { super( `Error in \`${meta.owner}\` during \`${meta.type}\` hook: ${ cause instanceof Error ? cause.message : String(cause) }`, ); this.type = meta.type; this.owner = meta.owner; this.rawMeta = meta; this.rawCause = cause; this.cause = cause instanceof Error ? cause : undefined; } } const uuid = (): string => { return (++uuid.i).toString(); }; uuid.i = 0; /** * @internal */ export class HookSystem { private _registeredHooks: { [K in keyof THooks]?: RegisteredHook[]; } = {}; hook( owner: string, type: TType, hookFn: THooks[TType]["fn"], ...[meta]: HookMetaArg ): void { const registeredHook = { fn: hookFn, meta: { ...meta, owner, type, id: uuid(), }, } as RegisteredHook; const registeredHooksForType = this._registeredHooks[type]; if (registeredHooksForType) { registeredHooksForType.push(registeredHook); } else { this._registeredHooks[type] = [registeredHook]; } } unhook( type: TType, hookFn: THooks[TType]["fn"], ): void { this._registeredHooks[type] = this._registeredHooks[type]?.filter( (registeredHook) => registeredHook.fn !== hookFn, ); } async callHook>( typeOrTypeAndHookID: TType | { type: TType; hookID: string }, ...args: Parameters ): CallHookReturnType { let hooks: RegisteredHook[]; if (typeof typeOrTypeAndHookID === "string") { hooks = this._registeredHooks[typeOrTypeAndHookID] ?? []; } else { const hookForID = this._registeredHooks[typeOrTypeAndHookID.type]?.find( (hook) => hook.meta.id === typeOrTypeAndHookID.hookID, ); if (hookForID) { hooks = [hookForID]; } else { throw new Error( `Hook of type \`${typeOrTypeAndHookID.type}\` with ID \`${typeOrTypeAndHookID.hookID}\` not found.`, ); } } const promises = hooks.map(async (hook) => { try { return await hook.fn(...args); } catch (error) { throw new HookError(hook.meta, error); } }); const settledPromises = await Promise.allSettled(promises); return settledPromises.reduce<{ data: Awaited>[]; errors: HookError[]; }>( (acc, settledPromise) => { if (settledPromise.status === "fulfilled") { acc.data.push(settledPromise.value); } else { acc.errors.push(settledPromise.reason); } return acc; }, { data: [], errors: [] }, ); } /** * Returns list of hooks for a given owner */ hooksForOwner(owner: string): RegisteredHook[] { const hooks: RegisteredHook[] = []; for (const hookType in this._registeredHooks) { const registeredHooks = this._registeredHooks[hookType]; if (Array.isArray(registeredHooks)) { for (const registeredHook of registeredHooks) { if (registeredHook.meta.owner === owner) { hooks.push(registeredHook); } } } } return hooks; } /** * Returns list of hooks for a given type */ hooksForType( type: TType, ): RegisteredHook[] { return this._registeredHooks[type] ?? []; } createScope( owner: string, extraArgs: [...TExtraArgs], ): CreateScopeReturnType { return { hook: (type, hookFn, ...[meta]) => { const internalHook = (( ...args: Parameters ) => { return hookFn(...args, ...extraArgs); }) as THooks[typeof type]["fn"]; const resolvedMeta = { ...meta, external: hookFn, } as HookMetaArg[0]; return this.hook( owner, type, internalHook, // @ts-expect-error - TypeScript fails to assert rest argument. resolvedMeta, ); }, unhook: (type, hookFn) => { this._registeredHooks[type] = this._registeredHooks[type]?.filter( (registeredHook) => registeredHook.meta.external !== hookFn, ); }, }; } }