Initial Commit
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# hook-executor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `hook-executor` module ships a small, priority-ordered dispatcher
|
||||
for **non-lifecycle** plugin hooks — custom events plugins contribute to
|
||||
each other, plus the request/tenant/database hook surfaces that ship in
|
||||
sibling packages (`@suaveplan/api`, `@suaveplan/api-tenant`,
|
||||
`@suaveplan/api-migrations`). The four lifecycle hooks
|
||||
(`onRegister/onInit/onReady/onClose`) are explicitly **rejected** by
|
||||
`registerHook` so the upstream dual-runtime hazard (registry calls a
|
||||
lifecycle hook directly AND the executor dispatches it) cannot recur.
|
||||
|
||||
`execute` runs handlers sequentially in priority order (higher first)
|
||||
and aborts on the first throw, wrapping the cause in
|
||||
`PluginError(PLUGIN.HOOK_FAILED)` carrying the offending plugin name
|
||||
and hook name. `executeAll` runs handlers in parallel via
|
||||
`Promise.allSettled`, returning the result vector so callers can
|
||||
inspect each rejection.
|
||||
|
||||
## Features
|
||||
|
||||
- Priority sort — `registerHook` keeps the list ordered after every
|
||||
insertion, so dispatch is O(n) and stable for equal priorities.
|
||||
- Lifecycle-hook rejection — registering any of the four registry-owned
|
||||
names throws `PLUGIN.HOOK_FAILED` synchronously at registration time.
|
||||
- `unregisterPlugin(name)` removes every contribution from a plugin in
|
||||
one call (used by `PluginRegistry.disable`).
|
||||
- Sequential or parallel dispatch — `execute` aborts on first throw,
|
||||
`executeAll` settles all and returns the result vector.
|
||||
- Inspection — `hasHook`, `getHookCount`, `getRegisteredHooks`,
|
||||
`clear` for tests and diagnostics.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createHookExecutor } from "@suaveplan/api-plugin/hook-executor";
|
||||
|
||||
const hooks = createHookExecutor();
|
||||
hooks.registerHook("on-cache-evict", (key) => console.log("evict", key), "cache-plugin", 10);
|
||||
hooks.registerHook("on-cache-evict", (key) => console.log("audit", key), "audit-plugin", 5);
|
||||
|
||||
await hooks.execute("on-cache-evict", "session:42");
|
||||
// prints "evict session:42" then "audit session:42"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Parallel dispatch with partial failures:
|
||||
|
||||
```ts
|
||||
const results = await hooks.executeAll("warm-cache");
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
console.error("warm-cache hook failed:", result.reason);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Attempting to register a lifecycle hook is rejected:
|
||||
|
||||
```ts
|
||||
import { PluginError } from "@suaveplan/api-plugin/error-codes";
|
||||
try {
|
||||
hooks.registerHook("onInit", () => {}, "bad-plugin");
|
||||
} catch (e) {
|
||||
if (e instanceof PluginError) console.log(e.code); // "PLUGIN.HOOK_FAILED"
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Signature |
|
||||
|---|---|
|
||||
| `HookExecutor` | `class` — see methods below |
|
||||
| `createHookExecutor()` | `() => HookExecutor` |
|
||||
| `LIFECYCLE_HOOK_NAMES` | `readonly ["onRegister", "onInit", "onReady", "onClose"]` |
|
||||
| `.registerHook(name, handler, pluginName, priority?)` | rejects lifecycle names |
|
||||
| `.unregisterPlugin(pluginName)` | bulk-remove |
|
||||
| `.execute(name, ...args)` | sequential, abort-on-throw |
|
||||
| `.executeAll(name, ...args)` | parallel, settle-all |
|
||||
| `.hasHook(name)` / `.getHookCount(name)` / `.getRegisteredHooks()` | inspection |
|
||||
| `.clear()` | reset |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lifecycle-name guard is a single `Set.has` lookup against a frozen
|
||||
`LIFECYCLE_HOOK_NAMES` constant. Both the constant and the type derived
|
||||
from it (`LifecycleHookName`) are exported so sibling packages can
|
||||
re-use the same authoritative list.
|
||||
|
||||
`execute` does not parallelise — handlers may have ordering
|
||||
dependencies (a higher-priority handler may publish data a lower one
|
||||
reads). `executeAll` is the explicit opt-in for fan-out scenarios
|
||||
(metrics emitters, listeners with no producer ordering).
|
||||
|
||||
The wrapped `PluginError` carries the original throw as `cause` rather
|
||||
than concatenating into the message — consumers can walk the cause
|
||||
chain via `walkCauses` from `@suaveplan/error` to surface the inner
|
||||
diagnostic without parsing strings.
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { PluginError } from "../error-codes/error-codes.js";
|
||||
import { createHookExecutor, HookExecutor } from "./hook-executor.js";
|
||||
|
||||
describe("HookExecutor", () => {
|
||||
it("dispatch order respects priority (higher first)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ex = createHookExecutor();
|
||||
const order: number[] = [];
|
||||
ex.registerHook("h", () => order.push(5), "p5", 5);
|
||||
ex.registerHook("h", () => order.push(10), "p10", 10);
|
||||
ex.registerHook("h", () => order.push(1), "p1", 1);
|
||||
await ex.execute("h");
|
||||
expect(order).toEqual([10, 5, 1]);
|
||||
});
|
||||
|
||||
it("preserves registration order for equal priority", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ex = createHookExecutor();
|
||||
const order: string[] = [];
|
||||
ex.registerHook("h", () => order.push("a"), "a", 0);
|
||||
ex.registerHook("h", () => order.push("b"), "b", 0);
|
||||
ex.registerHook("h", () => order.push("c"), "c", 0);
|
||||
await ex.execute("h");
|
||||
expect(order).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("execute aborts on first throw with PLUGIN.HOOK_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ex = createHookExecutor();
|
||||
let ranSecond = false;
|
||||
ex.registerHook(
|
||||
"h",
|
||||
() => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
"first",
|
||||
10
|
||||
);
|
||||
ex.registerHook(
|
||||
"h",
|
||||
() => {
|
||||
ranSecond = true;
|
||||
},
|
||||
"second",
|
||||
5
|
||||
);
|
||||
let caught: PluginError | undefined;
|
||||
try {
|
||||
await ex.execute("h");
|
||||
} catch (e) {
|
||||
caught = e as PluginError;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PluginError);
|
||||
expect(caught?.code).toBe("PLUGIN.HOOK_FAILED");
|
||||
expect((caught?.context as { plugin: string }).plugin).toBe("first");
|
||||
expect(ranSecond).toBe(false);
|
||||
});
|
||||
|
||||
it("wraps non-Error throws", { timeout: 5000 }, async () => {
|
||||
const ex = createHookExecutor();
|
||||
ex.registerHook(
|
||||
"h",
|
||||
() => {
|
||||
throw "string-throw";
|
||||
},
|
||||
"p"
|
||||
);
|
||||
await expect(ex.execute("h")).rejects.toBeInstanceOf(PluginError);
|
||||
});
|
||||
|
||||
it("executeAll never throws and returns results", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ex = createHookExecutor();
|
||||
ex.registerHook("h", () => 1, "a");
|
||||
ex.registerHook(
|
||||
"h",
|
||||
() => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
"b"
|
||||
);
|
||||
const results = await ex.executeAll("h");
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]?.status).toBe("fulfilled");
|
||||
expect(results[1]?.status).toBe("rejected");
|
||||
});
|
||||
|
||||
it("lifecycle hooks are not registered with the executor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ex = createHookExecutor();
|
||||
for (const name of ["onRegister", "onInit", "onReady", "onClose"]) {
|
||||
let caught: PluginError | undefined;
|
||||
try {
|
||||
ex.registerHook(name, () => {}, "p");
|
||||
} catch (e) {
|
||||
caught = e as PluginError;
|
||||
}
|
||||
expect(caught?.code).toBe("PLUGIN.HOOK_FAILED");
|
||||
expect(ex.getHookCount(name)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("unregisterPlugin removes all contributions", { timeout: 5000 }, () => {
|
||||
const ex = createHookExecutor();
|
||||
ex.registerHook("h1", () => {}, "p1");
|
||||
ex.registerHook("h2", () => {}, "p1");
|
||||
ex.registerHook("h1", () => {}, "p2");
|
||||
ex.unregisterPlugin("p1");
|
||||
expect(ex.getHookCount("h1")).toBe(1);
|
||||
expect(ex.getHookCount("h2")).toBe(0);
|
||||
expect(ex.hasHook("h2")).toBe(false);
|
||||
});
|
||||
|
||||
it("inspection methods report state", { timeout: 5000 }, () => {
|
||||
const ex = new HookExecutor();
|
||||
expect(ex.hasHook("x")).toBe(false);
|
||||
ex.registerHook("x", () => {}, "p");
|
||||
expect(ex.hasHook("x")).toBe(true);
|
||||
expect(ex.getRegisteredHooks()).toEqual(["x"]);
|
||||
ex.clear();
|
||||
expect(ex.getRegisteredHooks()).toEqual([]);
|
||||
});
|
||||
|
||||
it("execute on unknown hook is a no-op", { timeout: 5000 }, async () => {
|
||||
const ex = createHookExecutor();
|
||||
await expect(ex.execute("unknown")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("executeAll on unknown hook returns empty array", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ex = createHookExecutor();
|
||||
const results = await ex.executeAll("unknown");
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { PLUGIN_CODES, PluginError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* The four registry-owned lifecycle hooks. The `HookExecutor` rejects
|
||||
* registration for these names so the upstream "dual-runtime" hazard
|
||||
* (registry-calls + executor-dispatches the same lifecycle hook) cannot
|
||||
* recur in genesis.
|
||||
*/
|
||||
export const LIFECYCLE_HOOK_NAMES = [
|
||||
"onRegister",
|
||||
"onInit",
|
||||
"onReady",
|
||||
"onClose",
|
||||
] as const;
|
||||
|
||||
type LifecycleHookName = (typeof LIFECYCLE_HOOK_NAMES)[number];
|
||||
|
||||
const LIFECYCLE_SET = new Set<string>(LIFECYCLE_HOOK_NAMES);
|
||||
|
||||
/** A generic, non-lifecycle hook handler. */
|
||||
export type HookHandler = (...args: unknown[]) => unknown;
|
||||
|
||||
interface RegisteredHook {
|
||||
readonly handler: HookHandler;
|
||||
readonly pluginName: string;
|
||||
readonly priority: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatcher for plugin-contributed, non-lifecycle hooks
|
||||
* (custom hook names + the request/tenant/db hook surfaces that ship in
|
||||
* sibling packages). Lifecycle hooks (`onRegister/onInit/onReady/onClose`)
|
||||
* are explicitly rejected — those are owned by `PluginRegistry`.
|
||||
*
|
||||
* Sequential dispatch (`execute`) aborts on the first throw and wraps
|
||||
* the cause in `PluginError(PLUGIN.HOOK_FAILED)`. Parallel dispatch
|
||||
* (`executeAll`) uses `Promise.allSettled` and never throws.
|
||||
*/
|
||||
export class HookExecutor {
|
||||
private readonly hooks = new Map<string, RegisteredHook[]>();
|
||||
|
||||
/**
|
||||
* Register a single hook. Higher priority runs first; equal-priority
|
||||
* preserves registration order.
|
||||
*
|
||||
* @throws PluginError(PLUGIN.HOOK_FAILED) if `name` is one of the
|
||||
* reserved lifecycle hook names.
|
||||
*/
|
||||
public registerHook(
|
||||
name: string,
|
||||
handler: HookHandler,
|
||||
pluginName: string,
|
||||
priority = 0
|
||||
): void {
|
||||
if (LIFECYCLE_SET.has(name)) {
|
||||
throw new PluginError(
|
||||
PLUGIN_CODES.HOOK_FAILED,
|
||||
`Lifecycle hook '${name}' is registry-owned and cannot be registered with the HookExecutor`,
|
||||
{ context: { hook: name, plugin: pluginName } }
|
||||
);
|
||||
}
|
||||
const list = this.hooks.get(name) ?? [];
|
||||
list.push({ handler, pluginName, priority });
|
||||
list.sort((a, b) => b.priority - a.priority);
|
||||
this.hooks.set(name, list);
|
||||
}
|
||||
|
||||
/** Remove every hook contributed by `pluginName`. */
|
||||
public unregisterPlugin(pluginName: string): void {
|
||||
for (const [name, list] of this.hooks) {
|
||||
const remaining = list.filter((h) => h.pluginName !== pluginName);
|
||||
if (remaining.length === 0) {
|
||||
this.hooks.delete(name);
|
||||
} else {
|
||||
this.hooks.set(name, remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequential dispatch — abort on first throw, wrap in
|
||||
* `PluginError(PLUGIN.HOOK_FAILED)` carrying `context.plugin` and
|
||||
* `context.hook`.
|
||||
*/
|
||||
public async execute(name: string, ...args: unknown[]): Promise<void> {
|
||||
const list = this.hooks.get(name) ?? [];
|
||||
for (const hook of list) {
|
||||
try {
|
||||
await hook.handler(...args);
|
||||
} catch (cause) {
|
||||
throw new PluginError(
|
||||
PLUGIN_CODES.HOOK_FAILED,
|
||||
`Hook '${name}' from plugin '${hook.pluginName}' threw`,
|
||||
{
|
||||
cause:
|
||||
cause instanceof Error
|
||||
? cause
|
||||
: new Error(String(cause)),
|
||||
context: { hook: name, plugin: hook.pluginName },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parallel dispatch — `Promise.allSettled`. Never throws; callers
|
||||
* inspect the result for individual rejections.
|
||||
*/
|
||||
public async executeAll(
|
||||
name: string,
|
||||
...args: unknown[]
|
||||
): Promise<PromiseSettledResult<unknown>[]> {
|
||||
const list = this.hooks.get(name) ?? [];
|
||||
return Promise.allSettled(
|
||||
list.map((h) => Promise.resolve().then(() => h.handler(...args)))
|
||||
);
|
||||
}
|
||||
|
||||
public hasHook(name: string): boolean {
|
||||
return (this.hooks.get(name)?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
public getHookCount(name: string): number {
|
||||
return this.hooks.get(name)?.length ?? 0;
|
||||
}
|
||||
|
||||
public getRegisteredHooks(): readonly string[] {
|
||||
return Array.from(this.hooks.keys()).filter((n) => this.hasHook(n));
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.hooks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience factory for {@link HookExecutor}. */
|
||||
export function createHookExecutor(): HookExecutor {
|
||||
return new HookExecutor();
|
||||
}
|
||||
|
||||
export type { LifecycleHookName };
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
HookHandler,
|
||||
LifecycleHookName,
|
||||
} from "./hook-executor.js";
|
||||
export {
|
||||
createHookExecutor,
|
||||
HookExecutor,
|
||||
LIFECYCLE_HOOK_NAMES,
|
||||
} from "./hook-executor.js";
|
||||
Reference in New Issue
Block a user