import type { ActionHooks, FilterHooks, HookHandler, HookName, HookOrder, Logger, } from '@scn-chat/plugin-api' type Entry = { pluginId: string position: number order: HookOrder handler: (...args: never[]) => unknown } const orderRank: Record = { pre: 0, normal: 1, post: 2 } export class PluginHookError extends Error { readonly pluginId: string readonly hook: HookName constructor(pluginId: string, hook: HookName, cause: unknown) { super( `Plugin "${pluginId}" failed in ${hook}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause, }, ) this.name = 'PluginHookError' this.pluginId = pluginId this.hook = hook } } /** Runs hook handlers in order: pre, normal, post, then by the plugin's place in the config. */ export class HookRunner { private readonly entries = new Map() add( name: N, pluginId: string, position: number, handler: HookHandler, order: HookOrder = 'normal', ) { const list = this.entries.get(name) ?? [] list.push({ pluginId, position, order, handler: handler as Entry['handler'] }) list.sort((a, b) => orderRank[a.order] - orderRank[b.order] || a.position - b.position) this.entries.set(name, list) } /** Pass a value through every filter, failing on the first handler that throws. */ async filter( name: N, value: FilterHooks[N]['value'], context: FilterHooks[N]['context'], ): Promise { let current = value for (const entry of this.entries.get(name) ?? []) { try { current = await (entry.handler as (v: typeof current, c: typeof context) => typeof current)( current, context, ) } catch (err) { throw new PluginHookError(entry.pluginId, name, err) } } return current } /** * Run every action, logging failures without stopping the others, until the signal aborts. * Returns the plugins whose handler threw or didn't get to finish. */ async action( name: N, payload: ActionHooks[N], logger: Logger, signal?: AbortSignal, ): Promise { const failed: string[] = [] const aborted = new Promise((_, reject) => { if (signal?.aborted) reject(signal.reason) signal?.addEventListener('abort', () => reject(signal.reason), { once: true }) }) aborted.catch(() => {}) for (const entry of this.entries.get(name) ?? []) { if (signal?.aborted) { logger.error({ pluginId: entry.pluginId, hook: name }, 'plugin action skipped, out of time') failed.push(entry.pluginId) continue } try { await Promise.race([(entry.handler as (p: typeof payload) => unknown)(payload), aborted]) } catch (err) { logger.error({ err, pluginId: entry.pluginId, hook: name }, 'plugin action failed') failed.push(entry.pluginId) } } return failed } /** The resolved handler order for each hook, by plugin ID. */ describe(): Record { return Object.fromEntries( [...this.entries].map(([name, list]) => [name, list.map((entry) => entry.pluginId)]), ) } }