Files
factorio-clusterio/packages/lib/src/AsyncHook.ts
T
bbassieandClaude Opus 5.5 bd609c3c54 Allow metrics hook handlers to return nothing
A handler that only updates gauges has nothing to yield. collect() already
dropped undefined results at runtime, now its type says so too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:15:00 +00:00

185 lines
4.1 KiB
TypeScript

import { logger } from "./logging.js";
import * as libHelpers from "./helpers.js";
export type HookHandler<Args extends unknown[], Result = void> = (...args: Args) => Result | Promise<Result>;
export class AsyncHook<
Args extends unknown[],
Result = void,
> {
private static readonly _timeoutToken = Symbol("timeout");
private _handlers = new Map<string, HookHandler<Args, Result>>();
constructor(
private _logger = logger,
private _timeoutMs = 15_000,
) { }
get size() {
return this._handlers.size;
}
get attached() {
return this._handlers.keys();
}
/**
* Stable event listener reference bound to this hook instance.
*
* This function is pre-bound and guaranteed to remain referentially stable
* for the lifetime of the hook instance. It can safely be passed directly
* into event emitters (e.g. EventEmitter.on / off) without losing context
* or requiring re-binding.
*
* Example:
* `eventEmitter.on("change", hook.listener);`
*/
readonly listener = this.invoke.bind(this);
/**
* Attach a handler to this hook.
*
* An error is thrown if another handler with the same name is already attached.
*/
attach(
name: string,
handler: HookHandler<Args, Result>,
) {
if (this._handlers.has(name)) {
throw new Error(`Handler with name ${name} is already attached`);
}
this._handlers.set(name, handler);
}
/**
* Remove a previously attached handler.
*
* If no handler exists, then no action is performed.
*/
detach(name: string) {
this._handlers.delete(name);
}
/**
* Remove all attached handlers.
*/
clear() {
this._handlers.clear();
}
private async _invokeHandler(
name: string,
handler: HookHandler<Args, Result>,
args: Args,
): Promise<Result | undefined> {
try {
const result = await libHelpers.timeout<
Result | typeof AsyncHook._timeoutToken
>(
Promise.resolve(handler(...args)),
this._timeoutMs,
AsyncHook._timeoutToken,
);
if (result === AsyncHook._timeoutToken) {
throw new Error(`Hook ${name} timed out after ${this._timeoutMs}ms`);
}
return result;
} catch (err: any) {
this._logger.error(`Ignoring error in hook "${name}":\n${err?.stack ?? err}`);
return undefined;
}
}
/**
* Invoke all handlers attached to this hook.
*
* Any return values are discarded.
*/
async invoke(...args: Args): Promise<void> {
await Promise.all(
[...this._handlers.entries()].map(
([name, handler]) => this._invokeHandler(name, handler, args)
)
);
}
/**
* Invoke all handlers attached to this hook.
*
* Returns all successful results.
*/
async collect(...args: Args): Promise<Exclude<Awaited<Result>, void>[]> {
const results = await Promise.all(
[...this._handlers.entries()].map(
([name, handler]) => this._invokeHandler(name, handler, args)
)
);
return results.filter(
(result): result is Exclude<Awaited<Result>, void> => result !== undefined
);
}
/**
* Invoke all handlers attached to this hook.
*
* Returns all successful results and their source.
*/
async collectEntries(...args: Args): Promise<[string, Result][]> {
const results = await Promise.all(
[...this._handlers.entries()].map(async ([name, handler]) => {
const result = await this._invokeHandler(name, handler, args);
return result !== undefined ? [name, result] as [string, Result] : undefined;
})
);
return results.filter(
(entry): entry is [string, Result] => entry !== undefined
);
}
}
export class AsyncHookCollection {
private hooks: AsyncHook<any[], any>[] = [];
constructor(
private _logger = logger,
) {}
protected newHook<
Args extends unknown[],
Result = void,
> (
_logger = this._logger
) {
const hook = new AsyncHook<Args, Result>(_logger);
this.hooks.push(hook);
return hook;
}
get size() {
const attached = new Set(this.hooks.flatMap(hook => [...hook.attached]));
return attached.size;
}
get attached() {
const attached = new Set(this.hooks.flatMap(hook => [...hook.attached]));
return attached.values();
}
detachAll(name: string) {
for (const hook of this.hooks) {
hook.detach(name);
}
}
clearAll() {
for (const hook of this.hooks) {
hook.clear();
}
}
}