@esauflores/plugin-sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import type { HookSchemas, Hooks } from "./hooks.ts";
3
+ /** Any schema library implementing https://standardschema.dev: zod, valibot, ArkType... */
4
+ export type { StandardSchemaV1 };
5
+ export interface AppInfo {
6
+ /** Also the default `package.json` key plugins use for their manifest. */
7
+ name: string;
8
+ version: string;
9
+ }
10
+ export interface Logger {
11
+ debug(...args: unknown[]): void;
12
+ info(...args: unknown[]): void;
13
+ warn(...args: unknown[]): void;
14
+ error(...args: unknown[]): void;
15
+ }
16
+ /**
17
+ * `H` is the app's hook map, `S` its service map, e.g.
18
+ * interface MyHooks { "request": (req: Request) => void }
19
+ * interface MyServices { db: Database }
20
+ * `Options` is what the plugin receives in `ctx.options`; `Input` is what callers pass to
21
+ * `register()` / `load()`. They differ when `optionsSchema` applies defaults or transforms.
22
+ */
23
+ export interface Plugin<Options = any, H extends object = any, S extends object = any, Input = Options> {
24
+ /** Unique within a host. */
25
+ name: string;
26
+ version?: string;
27
+ /** Plugins that must set up and start before this one. If one fails, this plugin is disabled. */
28
+ dependsOn?: readonly string[];
29
+ /** Ordered before this plugin when present; ignored when missing or failed. */
30
+ optionalDependsOn?: readonly string[];
31
+ /**
32
+ * Validates the options before `setup`; `ctx.options` is the parsed result (defaults applied)
33
+ * and its type is inferred from the schema. Invalid options mark the plugin failed.
34
+ */
35
+ optionsSchema?: StandardSchemaV1<Input, Options>;
36
+ /** Register hooks and provide services. Avoid side effects (sockets, timers) here: that is what `start` is for. */
37
+ setup(ctx: PluginContext<Options, H, S>): void | Promise<void>;
38
+ /** Called once every plugin has set up, in dependency order. */
39
+ start?(ctx: PluginContext<Options, H, S>): void | Promise<void>;
40
+ /** Called in reverse dependency order. `ctx.signal` is already aborted; disposers run right after. */
41
+ stop?(ctx: PluginContext<Options, H, S>): void | Promise<void>;
42
+ }
43
+ export interface PluginContext<Options = any, H extends object = any, S extends object = any> {
44
+ readonly name: string;
45
+ readonly options: Options;
46
+ readonly app: AppInfo;
47
+ /** Aborted when the plugin starts stopping, fails, or times out. Pass it to fetch, timers, servers... */
48
+ readonly signal: AbortSignal;
49
+ readonly log: Logger;
50
+ /** Handlers registered here are removed automatically when the plugin is torn down. */
51
+ readonly hooks: Hooks<H>;
52
+ /** Removed automatically when the plugin is torn down. */
53
+ provide<K extends keyof S & string>(key: K, value: S[K]): void;
54
+ /** Throws if missing, or if the provider is not listed in `dependsOn` / `optionalDependsOn`. */
55
+ use<K extends keyof S & string>(key: K): S[K];
56
+ /** Like `use`, but returns undefined when the service is missing (for optional dependencies). */
57
+ tryUse<K extends keyof S & string>(key: K): S[K] | undefined;
58
+ /** Runs on teardown in reverse registration order, after `stop`. */
59
+ onDispose(fn: () => unknown): void;
60
+ }
61
+ export type PluginState = "registered" | "setup" | "started" | "stopped" | "failed" | "disabled";
62
+ export interface PluginInfo {
63
+ name: string;
64
+ version?: string;
65
+ /** "inline" for `host.register()`, otherwise the package specifier passed to `host.load()`. */
66
+ source: string;
67
+ state: PluginState;
68
+ /** Why the plugin failed or was disabled. */
69
+ reason?: string;
70
+ error?: unknown;
71
+ }
72
+ export type HookErrorPolicy =
73
+ /** Log the error, skip that handler and continue. */
74
+ "log"
75
+ /** Log the error, then stop the plugin (state "failed") and the started plugins that depend on it. */
76
+ | "disable"
77
+ /** Make the hook call reject with the PluginError. */
78
+ | "throw";
79
+ export type FailurePolicy =
80
+ /** A failing plugin is torn down, its dependents disabled, and the rest keep running. */
81
+ "isolate"
82
+ /** The first failure stops everything and rejects `start()` / `load()`. */
83
+ | "strict";
84
+ export interface HostOptions<H extends object = any> {
85
+ app: AppInfo;
86
+ /**
87
+ * Schemas for values plugins return from `waterfall` and `bail` hooks. A handler returning
88
+ * an invalid value makes the hook call reject with a PluginError naming that plugin.
89
+ */
90
+ hookSchemas?: NoInfer<HookSchemas<H>>;
91
+ /** Plugins declare the API version they target in their manifest; it must match. Bump it on breaking changes. Default 1. */
92
+ apiVersion?: number;
93
+ /** `package.json` field holding the plugin manifest. Defaults to `app.name`. */
94
+ manifestKey?: string;
95
+ logger?: Logger;
96
+ /** Per-plugin limit for each of setup/start/stop. 0 disables. Default 10s. */
97
+ timeoutMs?: number;
98
+ /** Default "isolate". */
99
+ failure?: FailurePolicy;
100
+ /**
101
+ * What happens when a plugin's hook handler throws, or returns a value its hook schema rejects,
102
+ * while the app runs. Errors from your app's own handlers always propagate. Default "log".
103
+ */
104
+ hookErrors?: HookErrorPolicy;
105
+ }
106
+ /** A package name (`"my-plugin"`, `"@scope/my-plugin"`), a path (`"./plugins/foo"`), or either with options. */
107
+ export type PluginSpec = string | readonly [specifier: string, options: unknown];
108
+ /** What a plugin package puts under its manifest key in package.json. */
109
+ export interface PluginManifest {
110
+ apiVersion: number;
111
+ /** Relative to the package root, e.g. "./dist/index.js". */
112
+ entry: string;
113
+ }
114
+ export type HostPhase = "idle" | "starting" | "started" | "stopping" | "stopped";
115
+ export type StateListener = (plugin: PluginInfo, previous: PluginState) => void;
116
+ export type ErrorPhase = "register" | "load" | "setup" | "start" | "stop" | "dispose" | "hook" | "service";
117
+ /** Every error the SDK raises about a specific plugin. `cause` holds the original error, if any. */
118
+ export declare class PluginError extends Error {
119
+ readonly plugin: string;
120
+ readonly phase: ErrorPhase;
121
+ /** The message without the `[plugin]` prefix. */
122
+ readonly reason: string;
123
+ constructor(plugin: string, phase: ErrorPhase, message: string, options?: ErrorOptions);
124
+ }
125
+ export declare class TimeoutError extends Error {
126
+ constructor(ms: number);
127
+ }
128
+ export declare class SchemaError extends Error {
129
+ readonly issues: readonly StandardSchemaV1.Issue[];
130
+ constructor(issues: readonly StandardSchemaV1.Issue[]);
131
+ }
132
+ /** Runs a Standard Schema and returns its output, or throws a SchemaError listing the issues. */
133
+ export declare function parseWith<T>(schema: StandardSchemaV1<unknown, T>, value: unknown): Promise<T>;
134
+ export declare function isPlugin(value: unknown): value is Plugin;
135
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,49 @@
1
+ /** Every error the SDK raises about a specific plugin. `cause` holds the original error, if any. */
2
+ export class PluginError extends Error {
3
+ plugin;
4
+ phase;
5
+ /** The message without the `[plugin]` prefix. */
6
+ reason;
7
+ constructor(plugin, phase, message, options) {
8
+ super(`[${plugin}] ${message}`, options);
9
+ this.name = "PluginError";
10
+ this.plugin = plugin;
11
+ this.phase = phase;
12
+ this.reason = message;
13
+ }
14
+ }
15
+ export class TimeoutError extends Error {
16
+ constructor(ms) {
17
+ super(`timed out after ${ms}ms`);
18
+ this.name = "TimeoutError";
19
+ }
20
+ }
21
+ export class SchemaError extends Error {
22
+ issues;
23
+ constructor(issues) {
24
+ super(issues.map(formatIssue).join("; "));
25
+ this.name = "SchemaError";
26
+ this.issues = issues;
27
+ }
28
+ }
29
+ /** Runs a Standard Schema and returns its output, or throws a SchemaError listing the issues. */
30
+ export async function parseWith(schema, value) {
31
+ const result = await schema["~standard"].validate(value);
32
+ if (result.issues)
33
+ throw new SchemaError(result.issues);
34
+ return result.value;
35
+ }
36
+ function formatIssue(issue) {
37
+ const path = (issue.path ?? []).map((p) => String(typeof p === "object" ? p.key : p)).join(".");
38
+ return path ? `${path}: ${issue.message}` : issue.message;
39
+ }
40
+ // Duck-typed rather than branded: a plugin may have been built against a
41
+ // different copy of this SDK, so instanceof/symbol checks would reject it.
42
+ export function isPlugin(value) {
43
+ return (typeof value === "object" &&
44
+ value !== null &&
45
+ typeof value.name === "string" &&
46
+ value.name.length > 0 &&
47
+ typeof value.setup === "function");
48
+ }
49
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AA0IA,oGAAoG;AACpG,MAAM,OAAO,WAAY,SAAQ,KAAK;IAC3B,MAAM,CAAS;IACf,KAAK,CAAa;IAC3B,iDAAiD;IACxC,MAAM,CAAS;IAExB,YAAY,MAAc,EAAE,KAAiB,EAAE,OAAe,EAAE,OAAsB;QACpF,KAAK,CAAC,IAAI,MAAM,KAAK,OAAO,EAAE,EAAE,OAAO,CAAC,CAAC;QACzC,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;QAC1B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC;IACxB,CAAC;CACF;AAED,MAAM,OAAO,YAAa,SAAQ,KAAK;IACrC,YAAY,EAAU;QACpB,KAAK,CAAC,mBAAmB,EAAE,IAAI,CAAC,CAAC;QACjC,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;IAC7B,CAAC;CACF;AAED,MAAM,OAAO,WAAY,SAAQ,KAAK;IAC3B,MAAM,CAAoC;IAEnD,YAAY,MAAyC;QACnD,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;QAC1C,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;QAC1B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;CACF;AAED,iGAAiG;AACjG,MAAM,CAAC,KAAK,UAAU,SAAS,CAAI,MAAoC,EAAE,KAAc;IACrF,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;IACzD,IAAI,MAAM,CAAC,MAAM;QAAE,MAAM,IAAI,WAAW,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAExD,OAAO,MAAM,CAAC,KAAK,CAAC;AACtB,CAAC;AAED,SAAS,WAAW,CAAC,KAA6B;IAChD,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAEhG,OAAO,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC;AAC5D,CAAC;AAED,yEAAyE;AACzE,2EAA2E;AAC3E,MAAM,UAAU,QAAQ,CAAC,KAAc;IACrC,OAAO,CACL,OAAO,KAAK,KAAK,QAAQ;QACzB,KAAK,KAAK,IAAI;QACd,OAAQ,KAAgB,CAAC,IAAI,KAAK,QAAQ;QACzC,KAAgB,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC;QACjC,OAAQ,KAAgB,CAAC,KAAK,KAAK,UAAU,CAC9C,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@esauflores/plugin-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A small plugin system for Bun and Node: typed hooks, ordered lifecycle, services, npm loading and Standard Schema validation.",
5
+ "keywords": [
6
+ "bun",
7
+ "hooks",
8
+ "lifecycle",
9
+ "microkernel",
10
+ "plugin",
11
+ "plugin-system",
12
+ "plugins",
13
+ "standard-schema"
14
+ ],
15
+ "homepage": "https://github.com/esauflores/plugin-sdk#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/esauflores/plugin-sdk/issues"
18
+ },
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/esauflores/plugin-sdk.git"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "scripts": {
42
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
43
+ "check": "vp fmt --check && vp lint && tsc --noEmit",
44
+ "check:fix": "vp fmt && vp lint --fix && tsc --noEmit",
45
+ "test": "vp test",
46
+ "test:watch": "vp test --watch",
47
+ "test:bun": "bun test",
48
+ "example": "cd examples/basic && bun install && bun app.ts && node app.ts",
49
+ "prepublishOnly": "bun run check && bun run test && bun run test:bun && bun run build"
50
+ },
51
+ "dependencies": {
52
+ "@standard-schema/spec": "^1.1.0"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^26.6.4",
56
+ "typescript": "^7.0.2",
57
+ "vite-plus": "^1.1.0",
58
+ "zod": "^4.6.7"
59
+ },
60
+ "overrides": {
61
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
62
+ "vitest": "5.0.3"
63
+ },
64
+ "engines": {
65
+ "node": ">=24.0.0"
66
+ }
67
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,253 @@
1
+ import { parseWith, PluginError, type StandardSchemaV1 } from "./types.ts";
2
+
3
+ export type HookFn = (...args: any[]) => unknown;
4
+ export type HookName<H> = keyof H & string;
5
+ type Fn<H, K extends keyof H> = H[K] extends HookFn ? H[K] : never;
6
+ type Args<H, K extends keyof H> = H[K] extends (...args: infer A) => any ? A : never[];
7
+ type Result<H, K extends keyof H> = H[K] extends (...args: any[]) => infer R ? Awaited<R> : never;
8
+
9
+ /** Per hook, a schema for the values handlers return (checked by `waterfall` and `bail`). */
10
+ export type HookSchemas<H> = {
11
+ [K in HookName<H>]?: StandardSchemaV1<unknown, Exclude<Result<H, K>, undefined | void>>;
12
+ };
13
+
14
+ /** "pre" handlers run first, then "normal" (the default), then "post"; registration order within each. */
15
+ export type HookOrder = "pre" | "normal" | "post";
16
+
17
+ export interface HookOptions {
18
+ order?: HookOrder;
19
+ }
20
+
21
+ export type HookStyle = "call" | "parallel" | "waterfall" | "bail";
22
+
23
+ /** Observes every hook call on the host's bus: for tracing, metrics, finding slow plugins. */
24
+ export interface HookInterceptor {
25
+ /** A hook was called, before any handler runs. */
26
+ call?(event: { hook: string; style: HookStyle; args: readonly unknown[] }): void;
27
+ /**
28
+ * One handler finished. `plugin` is undefined for the app's own handlers; `ms` excludes
29
+ * error handling; `error` is the PluginError if it threw or returned an invalid value.
30
+ */
31
+ handler?(event: { hook: string; plugin: string | undefined; ms: number; error?: unknown }): void;
32
+ }
33
+
34
+ export interface Hooks<H extends object> {
35
+ /** Returns a function that removes the handler. */
36
+ on<K extends HookName<H>>(name: K, fn: Fn<H, K>, options?: HookOptions): () => void;
37
+ /** Runs handlers one after another, in order. */
38
+ call<K extends HookName<H>>(name: K, ...args: Args<H, K>): Promise<void>;
39
+ /** Runs all handlers concurrently. */
40
+ parallel<K extends HookName<H>>(name: K, ...args: Args<H, K>): Promise<void>;
41
+ /**
42
+ * Each handler receives the previous one's result as its first argument; returning undefined keeps the value.
43
+ * Results are checked against the hook's schema, if it has one.
44
+ */
45
+ waterfall<K extends HookName<H>>(name: K, ...args: Args<H, K>): Promise<Args<H, K>[0]>;
46
+ /** Returns the first result that is not undefined, skipping the remaining handlers. Checked like `waterfall`. */
47
+ bail<K extends HookName<H>>(
48
+ name: K,
49
+ ...args: Args<H, K>
50
+ ): Promise<Exclude<Result<H, K>, undefined | void> | undefined>;
51
+ count(name: HookName<H>): number;
52
+ }
53
+
54
+ interface Handler {
55
+ fn: HookFn;
56
+ owner?: string;
57
+ rank: number;
58
+ }
59
+
60
+ const RANKS: Record<HookOrder, number> = { pre: 0, normal: 1, post: 2 };
61
+
62
+ /**
63
+ * Called when a plugin's handler throws or returns an invalid value. Rethrow to make the hook
64
+ * call reject; return to skip that handler and continue. App handlers (no owner) always rethrow.
65
+ */
66
+ export type HookErrorHandler = (error: PluginError) => void | Promise<void>;
67
+
68
+ export class HookBus<H extends object = any> implements Hooks<H> {
69
+ #handlers = new Map<string, Handler[]>();
70
+ readonly #schemas: HookSchemas<H>;
71
+ readonly #onError?: HookErrorHandler;
72
+ #interceptors: readonly HookInterceptor[] = [];
73
+
74
+ constructor(schemas: HookSchemas<H> = {}, onError?: HookErrorHandler) {
75
+ this.#schemas = schemas;
76
+ this.#onError = onError;
77
+ }
78
+
79
+ on<K extends HookName<H>>(name: K, fn: Fn<H, K>, options?: HookOptions): () => void {
80
+ return this.#on(name, fn, undefined, options);
81
+ }
82
+
83
+ /**
84
+ * Adds an observer of every hook call; returns a function that removes it. Host-only: the
85
+ * scoped view plugins get has no `intercept`. Errors thrown by an interceptor propagate.
86
+ */
87
+ intercept(interceptor: HookInterceptor): () => void {
88
+ this.#interceptors = [...this.#interceptors, interceptor];
89
+
90
+ return () => {
91
+ this.#interceptors = this.#interceptors.filter((i) => i !== interceptor);
92
+ };
93
+ }
94
+
95
+ /** A view whose `on` tags handlers with `owner`, so `removeOwner` can drop them all. */
96
+ scope(owner: string): Hooks<H> {
97
+ return {
98
+ on: (name, fn, options) => this.#on(name, fn, owner, options),
99
+ call: (name, ...args) => this.call(name, ...args),
100
+ parallel: (name, ...args) => this.parallel(name, ...args),
101
+ waterfall: (name, ...args) => this.waterfall(name, ...args),
102
+ bail: (name, ...args) => this.bail(name, ...args),
103
+ count: (name) => this.count(name),
104
+ };
105
+ }
106
+
107
+ removeOwner(owner: string): void {
108
+ for (const [name, list] of this.#handlers) {
109
+ this.#handlers.set(
110
+ name,
111
+ list.filter((h) => h.owner !== owner),
112
+ );
113
+ }
114
+ }
115
+
116
+ count(name: HookName<H>): number {
117
+ return this.#handlers.get(name)?.length ?? 0;
118
+ }
119
+
120
+ async call<K extends HookName<H>>(name: K, ...args: Args<H, K>): Promise<void> {
121
+ this.#notifyCall(name, "call", args);
122
+
123
+ for (const h of this.#snapshot(name)) await this.#handle(h, name, args, false);
124
+ }
125
+
126
+ async parallel<K extends HookName<H>>(name: K, ...args: Args<H, K>): Promise<void> {
127
+ this.#notifyCall(name, "parallel", args);
128
+
129
+ await Promise.all(this.#snapshot(name).map((h) => this.#handle(h, name, args, false)));
130
+ }
131
+
132
+ async waterfall<K extends HookName<H>>(name: K, ...args: Args<H, K>): Promise<Args<H, K>[0]> {
133
+ this.#notifyCall(name, "waterfall", args);
134
+
135
+ let [value, ...rest] = args;
136
+
137
+ for (const h of this.#snapshot(name)) {
138
+ const next = await this.#handle(h, name, [value, ...rest], true);
139
+ if (next?.value !== undefined) value = next.value;
140
+ }
141
+
142
+ return value;
143
+ }
144
+
145
+ async bail<K extends HookName<H>>(
146
+ name: K,
147
+ ...args: Args<H, K>
148
+ ): Promise<Exclude<Result<H, K>, undefined | void> | undefined> {
149
+ this.#notifyCall(name, "bail", args);
150
+
151
+ for (const h of this.#snapshot(name)) {
152
+ const result = await this.#handle(h, name, args, true);
153
+ if (result?.value !== undefined) return result.value as Exclude<Result<H, K>, undefined | void>;
154
+ }
155
+
156
+ return undefined;
157
+ }
158
+
159
+ #on(name: string, fn: HookFn, owner: string | undefined, options: HookOptions = {}): () => void {
160
+ if (typeof fn !== "function") throw new TypeError(`Hook handler for "${name}" must be a function`);
161
+
162
+ const rank = RANKS[options.order ?? "normal"];
163
+ if (rank === undefined) throw new TypeError(`Hook order must be "pre", "normal" or "post", got "${options.order}"`);
164
+
165
+ // Insert after the last handler of the same or an earlier bucket.
166
+ const handler: Handler = { fn, owner, rank };
167
+ const list = this.#handlers.get(name) ?? [];
168
+ const at = list.findIndex((h) => h.rank > rank);
169
+ this.#handlers.set(name, at === -1 ? [...list, handler] : [...list.slice(0, at), handler, ...list.slice(at)]);
170
+
171
+ return () => {
172
+ const list = this.#handlers.get(name);
173
+ if (list)
174
+ this.#handlers.set(
175
+ name,
176
+ list.filter((h) => h !== handler),
177
+ );
178
+ };
179
+ }
180
+
181
+ // Lists are replaced, never mutated, so handlers added mid-emit don't run in that emit.
182
+ // Handlers removed mid-emit (e.g. their plugin was disabled) are skipped by #handle.
183
+ #snapshot(name: string): readonly Handler[] {
184
+ return this.#handlers.get(name) ?? [];
185
+ }
186
+
187
+ /** Runs one handler (and checks its result); `undefined` means it was skipped or its error was handled. */
188
+ async #handle(h: Handler, name: string, args: unknown[], checked: boolean): Promise<{ value: unknown } | undefined> {
189
+ if (!this.#handlers.get(name)?.includes(h)) return undefined;
190
+
191
+ const startedAt = performance.now();
192
+ let result: { value: unknown };
193
+
194
+ try {
195
+ const value = await this.#invoke(h, name, args);
196
+
197
+ result = { value: checked && value !== undefined ? await this.#check(h, name, value) : value };
198
+ } catch (error) {
199
+ this.#notifyHandler(name, h, startedAt, error);
200
+
201
+ if (!this.#onError || h.owner === undefined || !(error instanceof PluginError)) throw error;
202
+
203
+ await this.#onError(error);
204
+
205
+ return undefined;
206
+ }
207
+
208
+ this.#notifyHandler(name, h, startedAt);
209
+
210
+ return result;
211
+ }
212
+
213
+ #notifyCall(hook: string, style: HookStyle, args: readonly unknown[]): void {
214
+ for (const interceptor of this.#interceptors) interceptor.call?.({ hook, style, args });
215
+ }
216
+
217
+ #notifyHandler(hook: string, h: Handler, startedAt: number, error?: unknown): void {
218
+ if (this.#interceptors.length === 0) return;
219
+
220
+ const event = {
221
+ hook,
222
+ plugin: h.owner,
223
+ ms: performance.now() - startedAt,
224
+ ...(error === undefined ? {} : { error }),
225
+ };
226
+ for (const interceptor of this.#interceptors) interceptor.handler?.(event);
227
+ }
228
+
229
+ async #invoke(h: Handler, name: string, args: unknown[]): Promise<unknown> {
230
+ try {
231
+ return await h.fn(...args);
232
+ } catch (cause) {
233
+ if (cause instanceof PluginError && cause.phase === "hook") throw cause;
234
+
235
+ throw new PluginError(h.owner ?? "<host>", "hook", `handler for "${name}" threw`, { cause });
236
+ }
237
+ }
238
+
239
+ async #check(h: Handler, name: string, value: unknown): Promise<any> {
240
+ const schema = this.#schemas[name as HookName<H>];
241
+ if (!schema) return value;
242
+
243
+ try {
244
+ return await parseWith(schema, value);
245
+ } catch (cause) {
246
+ const detail = cause instanceof Error ? cause.message : String(cause);
247
+
248
+ throw new PluginError(h.owner ?? "<host>", "hook", `handler for "${name}" returned an invalid value: ${detail}`, {
249
+ cause,
250
+ });
251
+ }
252
+ }
253
+ }