@logosdx/hooks 1.0.0-beta.2 → 1.0.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.
package/src/index.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  assert,
3
- AsyncFunc,
4
3
  attempt,
5
4
  attemptSync,
6
5
  FunctionProps,
@@ -11,17 +10,15 @@ import {
11
10
  /**
12
11
  * Error thrown when a hook calls `ctx.fail()`.
13
12
  *
14
- * This error is only created when using the default `handleFail` behavior.
13
+ * Only created when using the default `handleFail` behavior.
15
14
  * If a custom `handleFail` is provided, that error type is thrown instead.
16
15
  *
17
16
  * @example
18
- * hooks.on('validate', async (ctx) => {
19
- * if (!ctx.args[0].isValid) {
20
- * ctx.fail('Validation failed');
21
- * }
17
+ * hooks.add('validate', (data, ctx) => {
18
+ * if (!data.isValid) ctx.fail('Validation failed');
22
19
  * });
23
20
  *
24
- * const [, err] = await attempt(() => engine.emit('validate', data));
21
+ * const [, err] = await attempt(() => engine.run('validate', data));
25
22
  * if (isHookError(err)) {
26
23
  * console.log(err.hookName); // 'validate'
27
24
  * }
@@ -36,7 +33,7 @@ export class HookError extends Error {
36
33
 
37
34
  constructor(message: string) {
38
35
 
39
- super(message)
36
+ super(message);
40
37
  }
41
38
  }
42
39
 
@@ -44,172 +41,448 @@ export class HookError extends Error {
44
41
  * Type guard to check if an error is a HookError.
45
42
  *
46
43
  * @example
47
- * const { error } = await engine.emit('validate', data);
48
- * if (isHookError(error)) {
49
- * console.log(`Hook "${error.hookName}" failed`);
44
+ * const [, err] = await attempt(() => engine.run('validate', data));
45
+ * if (isHookError(err)) {
46
+ * console.log(`Hook "${err.hookName}" failed`);
50
47
  * }
51
48
  */
52
49
  export const isHookError = (error: unknown): error is HookError => {
53
50
 
54
- return (error as HookError)?.constructor?.name === HookError.name
55
- }
51
+ return (error as HookError)?.constructor?.name === HookError.name;
52
+ };
56
53
 
57
54
  /**
58
- * Result returned from `emit()` after running all hook callbacks.
55
+ * Custom error handler for `ctx.fail()`.
56
+ * Can be an Error constructor or a function that throws.
59
57
  */
60
- export interface EmitResult<F extends AsyncFunc> {
58
+ export type HandleFail<Args extends unknown[] = [string]> =
59
+ | (new (...args: Args) => Error)
60
+ | ((...args: Args) => never);
61
61
 
62
- /** Current arguments (possibly modified by callbacks) */
63
- args: Parameters<F>;
62
+ /**
63
+ * Request-scoped state bag that flows across hook runs and engine instances.
64
+ *
65
+ * Use symbols for private plugin state and strings for shared cross-plugin contracts.
66
+ *
67
+ * @example
68
+ * // Private plugin state (symbol key)
69
+ * const CACHE_STATE = Symbol('cache');
70
+ * ctx.scope.set(CACHE_STATE, { key, rule });
71
+ *
72
+ * // Shared cross-plugin contract (string key)
73
+ * ctx.scope.set('serializedKey', key);
74
+ */
75
+ export class HookScope {
76
+
77
+ #data = new Map<symbol | string, unknown>();
78
+
79
+ /**
80
+ * Get a value from the scope.
81
+ *
82
+ * @example
83
+ * const state = ctx.scope.get<CacheState>(CACHE_STATE);
84
+ */
85
+ get<T = unknown>(key: symbol | string): T | undefined {
86
+
87
+ return this.#data.get(key) as T | undefined;
88
+ }
89
+
90
+ /**
91
+ * Set a value in the scope.
92
+ *
93
+ * @example
94
+ * ctx.scope.set(CACHE_STATE, { key: 'abc', rule });
95
+ */
96
+ set<T = unknown>(key: symbol | string, value: T): void {
97
+
98
+ this.#data.set(key, value);
99
+ }
64
100
 
65
- /** Result value (if set by a callback) */
66
- result?: Awaited<ReturnType<F>> | undefined;
101
+ /**
102
+ * Check if a key exists in the scope.
103
+ */
104
+ has(key: symbol | string): boolean {
105
+
106
+ return this.#data.has(key);
107
+ }
67
108
 
68
- /** Whether a callback called `returnEarly()` */
69
- earlyReturn: boolean;
109
+ /**
110
+ * Delete a key from the scope.
111
+ */
112
+ delete(key: symbol | string): boolean {
113
+
114
+ return this.#data.delete(key);
115
+ }
70
116
  }
71
117
 
118
+ const EARLY_RETURN: unique symbol = Symbol('early-return');
119
+ type EarlyReturnSignal = typeof EARLY_RETURN;
120
+
121
+ type FuncOrNever<T> = T extends (...args: any[]) => any ? T : never;
122
+
72
123
  /**
73
- * Context object passed to hook callbacks.
74
- * Provides access to arguments, results, and control methods.
124
+ * Extract only function property keys from a type.
75
125
  *
76
126
  * @example
77
- * hooks.on('cacheCheck', async (ctx) => {
78
- * const [url] = ctx.args;
79
- * const cached = cache.get(url);
127
+ * interface Doc {
128
+ * id: string;
129
+ * save(): Promise<void>;
130
+ * delete(): Promise<void>;
131
+ * }
132
+ *
133
+ * type DocHooks = HookName<Doc>; // 'save' | 'delete' (excludes 'id')
134
+ */
135
+ export type HookName<T> = FunctionProps<T>;
136
+
137
+ /**
138
+ * Context object passed as the last argument to hook callbacks.
139
+ * Provides methods to modify args, short-circuit, fail, or self-remove.
80
140
  *
81
- * if (cached) {
82
- * ctx.setResult(cached);
83
- * ctx.returnEarly();
84
- * }
141
+ * @example
142
+ * // Replace args for downstream callbacks
143
+ * hooks.add('beforeRequest', (url, opts, ctx) => {
144
+ * ctx.args(url, { ...opts, cache: 'no-store' });
145
+ * });
146
+ *
147
+ * // Short-circuit: replace args AND stop the chain
148
+ * hooks.add('beforeRequest', (url, opts, ctx) => {
149
+ * return ctx.args(normalizedUrl, opts);
150
+ * });
151
+ *
152
+ * // Short-circuit with a result value
153
+ * hooks.add('beforeRequest', (url, opts, ctx) => {
154
+ * const cached = cache.get(url);
155
+ * if (cached) return ctx.returns(cached);
85
156
  * });
86
157
  */
87
- export interface HookContext<F extends AsyncFunc, FailArgs extends unknown[] = [string]> {
158
+ export class HookContext<
159
+ Args extends unknown[] = unknown[],
160
+ Result = unknown,
161
+ FailArgs extends unknown[] = [string]
162
+ > {
163
+
164
+ #args: Args | undefined;
165
+ #argsChanged = false;
166
+ #result: Result | undefined;
167
+ #earlyReturn = false;
168
+ #handleFail: HandleFail<FailArgs>;
169
+ #hookName: string;
170
+ #removeFn: () => void;
88
171
 
89
- /** Current arguments passed to emit() */
90
- args: Parameters<F>;
172
+ /**
173
+ * Request-scoped state bag shared across hook runs and engine instances.
174
+ *
175
+ * @example
176
+ * // In beforeRequest hook
177
+ * ctx.scope.set(CACHE_KEY, serializedKey);
178
+ *
179
+ * // In afterRequest hook (same scope)
180
+ * const key = ctx.scope.get<string>(CACHE_KEY);
181
+ */
182
+ readonly scope: HookScope;
183
+
184
+ /** @internal */
185
+ constructor(
186
+ handleFail: HandleFail<FailArgs>,
187
+ hookName: string,
188
+ removeFn: () => void,
189
+ scope: HookScope
190
+ ) {
91
191
 
92
- /** Result value (can be set by callbacks) */
93
- result?: Awaited<ReturnType<F>>;
192
+ this.#handleFail = handleFail;
193
+ this.#hookName = hookName;
194
+ this.#removeFn = removeFn;
195
+ this.scope = scope;
196
+ }
94
197
 
95
- /** Abort hook execution with an error. */
96
- fail: (...args: FailArgs) => never;
198
+ /**
199
+ * Replace args for downstream callbacks.
200
+ * When used with `return`, also stops the chain.
201
+ *
202
+ * @example
203
+ * // Just replace args, continue chain
204
+ * ctx.args(newUrl, newOpts);
205
+ *
206
+ * // Replace args AND stop the chain
207
+ * return ctx.args(newUrl, newOpts);
208
+ */
209
+ args(...args: Args): EarlyReturnSignal {
97
210
 
98
- /** Replace the arguments for subsequent callbacks */
99
- setArgs: (next: Parameters<F>) => void;
211
+ this.#args = args;
212
+ this.#argsChanged = true;
213
+ return EARLY_RETURN;
214
+ }
100
215
 
101
- /** Set the result value */
102
- setResult: (next: Awaited<ReturnType<F>>) => void;
216
+ /**
217
+ * Set a result value and stop the chain.
218
+ * Always used with `return`.
219
+ *
220
+ * @example
221
+ * return ctx.returns(cachedResponse);
222
+ */
223
+ returns(value: Result): EarlyReturnSignal {
103
224
 
104
- /** Stop processing remaining callbacks and return early */
105
- returnEarly: () => void;
225
+ this.#result = value;
226
+ this.#earlyReturn = true;
227
+ return EARLY_RETURN;
228
+ }
106
229
 
107
- /** Remove this callback from the hook */
108
- removeHook: () => void;
109
- }
230
+ /**
231
+ * Abort hook execution with an error.
232
+ * Uses the engine's `handleFail` to create the error.
233
+ *
234
+ * @example
235
+ * ctx.fail('Validation failed');
236
+ */
237
+ fail(...args: FailArgs): never {
110
238
 
111
- export type HookFn<F extends AsyncFunc, FailArgs extends unknown[] = [string]> =
112
- (ctx: HookContext<F, FailArgs>) => Promise<void>;
239
+ const handler = this.#handleFail;
113
240
 
114
- type HookOptions<F extends AsyncFunc, FailArgs extends unknown[] = [string]> = {
115
- callback: HookFn<F, FailArgs>;
116
- once?: true;
117
- ignoreOnFail?: true;
118
- }
241
+ const isConstructor = typeof handler === 'function' &&
242
+ handler.prototype?.constructor === handler;
119
243
 
120
- type HookOrOptions<F extends AsyncFunc, FailArgs extends unknown[] = [string]> =
121
- HookFn<F, FailArgs> | HookOptions<F, FailArgs>;
244
+ const [, error] = attemptSync(() => {
122
245
 
123
- type FuncOrNever<T> = T extends AsyncFunc ? T : never;
246
+ if (isConstructor) {
124
247
 
125
- /**
126
- * Custom error handler for `ctx.fail()`.
127
- * Can be an Error constructor or a function that throws.
128
- */
129
- export type HandleFail<Args extends unknown[] = [string]> =
130
- | (new (...args: Args) => Error)
131
- | ((...args: Args) => never);
248
+ throw new (handler as new (...a: FailArgs) => Error)(...args);
249
+ }
250
+
251
+ (handler as (...a: FailArgs) => never)(...args);
252
+ });
253
+
254
+ if (error) {
255
+
256
+ if (error instanceof HookError) {
257
+
258
+ error.hookName = this.#hookName;
259
+ }
260
+
261
+ throw error;
262
+ }
263
+
264
+ throw new HookError('ctx.fail() handler did not throw');
265
+ }
266
+
267
+ /**
268
+ * Remove this callback from future runs.
269
+ *
270
+ * @example
271
+ * hooks.add('init', (config, ctx) => {
272
+ * bootstrap(config);
273
+ * ctx.removeHook();
274
+ * });
275
+ */
276
+ removeHook(): void {
277
+
278
+ this.#removeFn();
279
+ }
280
+
281
+ /** @internal */
282
+ get _argsChanged(): boolean { return this.#argsChanged; }
283
+
284
+ /** @internal */
285
+ get _newArgs(): Args | undefined { return this.#args; }
286
+
287
+ /** @internal */
288
+ get _result(): Result | undefined { return this.#result; }
289
+
290
+ /** @internal */
291
+ get _earlyReturn(): boolean { return this.#earlyReturn; }
292
+ }
132
293
 
133
294
  /**
134
- * Options for HookEngine constructor.
295
+ * Context object passed as the last argument to pipe middleware callbacks.
296
+ * Simpler than HookContext — no `returns()` needed since you control
297
+ * flow by calling or not calling `next()`.
298
+ *
299
+ * @example
300
+ * hooks.add('execute', async (next, opts, ctx) => {
301
+ * // Modify opts for inner layers
302
+ * ctx.args({ ...opts, headers: { ...opts.headers, Auth: token } });
303
+ *
304
+ * // Call next to continue the chain, or don't to short-circuit
305
+ * return next();
306
+ * });
135
307
  */
136
- export interface HookEngineOptions<FailArgs extends unknown[] = [string]> {
308
+ export class PipeContext<
309
+ FailArgs extends unknown[] = [string]
310
+ > {
311
+
312
+ #handleFail: HandleFail<FailArgs>;
313
+ #hookName: string;
314
+ #removeFn: () => void;
315
+ #setArgs: (args: unknown[]) => void;
316
+
317
+ /**
318
+ * Request-scoped state bag shared across hook runs and engine instances.
319
+ */
320
+ readonly scope: HookScope;
321
+
322
+ /** @internal */
323
+ constructor(
324
+ handleFail: HandleFail<FailArgs>,
325
+ hookName: string,
326
+ removeFn: () => void,
327
+ scope: HookScope,
328
+ setArgs: (args: unknown[]) => void
329
+ ) {
330
+
331
+ this.#handleFail = handleFail;
332
+ this.#hookName = hookName;
333
+ this.#removeFn = removeFn;
334
+ this.scope = scope;
335
+ this.#setArgs = setArgs;
336
+ }
137
337
 
138
338
  /**
139
- * Custom handler for `ctx.fail()`.
140
- * Can be an Error constructor or a function that throws.
339
+ * Replace args for `next()` and downstream middleware.
141
340
  *
142
341
  * @example
143
- * // Use Firebase HttpsError
144
- * new HookEngine({ handleFail: HttpsError });
342
+ * ctx.args({ ...opts, timeout: 5000 });
343
+ * return next(); // next receives modified opts
344
+ */
345
+ args(...args: unknown[]): void {
346
+
347
+ this.#setArgs(args);
348
+ }
349
+
350
+ /**
351
+ * Abort execution with an error.
145
352
  *
146
- * // Use custom function
147
- * new HookEngine({
148
- * handleFail: (msg, data) => { throw Boom.badRequest(msg, data); }
149
- * });
353
+ * @example
354
+ * ctx.fail('Rate limit exceeded');
150
355
  */
151
- handleFail?: HandleFail<FailArgs>;
356
+ fail(...args: FailArgs): never {
357
+
358
+ const handler = this.#handleFail;
359
+
360
+ const isConstructor = typeof handler === 'function' &&
361
+ handler.prototype?.constructor === handler;
362
+
363
+ const [, error] = attemptSync(() => {
364
+
365
+ if (isConstructor) {
366
+
367
+ throw new (handler as new (...a: FailArgs) => Error)(...args);
368
+ }
369
+
370
+ (handler as (...a: FailArgs) => never)(...args);
371
+ });
372
+
373
+ if (error) {
374
+
375
+ if (error instanceof HookError) {
376
+
377
+ error.hookName = this.#hookName;
378
+ }
379
+
380
+ throw error;
381
+ }
382
+
383
+ throw new HookError('ctx.fail() handler did not throw');
384
+ }
385
+
386
+ /**
387
+ * Remove this middleware from future runs.
388
+ */
389
+ removeHook(): void {
390
+
391
+ this.#removeFn();
392
+ }
152
393
  }
153
394
 
395
+ /**
396
+ * Callback type for hooks. Receives spread args + ctx as last param.
397
+ *
398
+ * @example
399
+ * type BeforeRequest = HookCallback<(url: string, opts: RequestInit) => Promise<Response>>;
400
+ * // (url: string, opts: RequestInit, ctx: HookContext) => void | EarlyReturnSignal | Promise<...>
401
+ */
402
+ export type HookCallback<
403
+ F extends (...args: any[]) => any = (...args: any[]) => any,
404
+ FailArgs extends unknown[] = [string]
405
+ > = F extends (...args: infer A) => infer R
406
+ ? (...args: [...A, HookContext<A, Awaited<R>, FailArgs>]) => void | EarlyReturnSignal | Promise<void | EarlyReturnSignal>
407
+ : never;
408
+
409
+ /**
410
+ * Callback type for pipe middleware. Receives `(next, ...args, ctx)`.
411
+ *
412
+ * @example
413
+ * type ExecuteMiddleware = PipeCallback<[opts: RequestOpts]>;
414
+ * // (next: () => Promise<R>, opts: RequestOpts, ctx: PipeContext) => R | Promise<R>
415
+ */
416
+ export type PipeCallback<
417
+ Args extends unknown[] = unknown[],
418
+ R = unknown,
419
+ FailArgs extends unknown[] = [string]
420
+ > = (next: () => R | Promise<R>, ...args: [...Args, PipeContext<FailArgs>]) => R | Promise<R>;
421
+
422
+ /**
423
+ * Result returned from `run()`/`runSync()` after executing all hook callbacks.
424
+ */
425
+ export interface RunResult<F extends (...args: any[]) => any = (...args: any[]) => any> {
426
+
427
+ /** Current arguments (possibly modified by callbacks) */
428
+ args: Parameters<F>;
429
+
430
+ /** Result value (if set via `ctx.returns()`) */
431
+ result: Awaited<ReturnType<F>> | undefined;
432
+
433
+ /** Whether a callback short-circuited via `return ctx.returns()` or `return ctx.args()` */
434
+ returned: boolean;
435
+
436
+ /** The scope used during this run (pass to subsequent runs to share state) */
437
+ scope: HookScope;
438
+ }
439
+
440
+ type DefaultLifecycle = Record<string, (...args: any[]) => any>;
441
+
442
+ type HookEntry<F extends (...args: any[]) => any, FailArgs extends unknown[]> = {
443
+ callback: HookCallback<F, FailArgs>;
444
+ options: HookEngine.AddOptions;
445
+ priority: number;
446
+ };
447
+
154
448
  /**
155
449
  * A lightweight, type-safe lifecycle hook system.
156
450
  *
157
451
  * HookEngine allows you to define lifecycle events and subscribe to them.
158
- * Callbacks can modify arguments, set results, or abort execution.
452
+ * Callbacks receive spread arguments with a context object as the last param.
159
453
  *
160
454
  * @example
161
455
  * interface FetchLifecycle {
162
- * preRequest(url: string, options: RequestInit): Promise<Response>;
163
- * rateLimit(error: Error, attempt: number): Promise<void>;
164
- * cacheHit(url: string, data: unknown): Promise<unknown>;
456
+ * beforeRequest(url: string, options: RequestInit): Promise<Response>;
457
+ * afterRequest(response: Response, url: string): Promise<Response>;
165
458
  * }
166
459
  *
167
460
  * const hooks = new HookEngine<FetchLifecycle>();
168
461
  *
169
- * hooks.on('rateLimit', async (ctx) => {
170
- * const [error, attempt] = ctx.args;
171
- * if (attempt > 3) ctx.fail('Max retries exceeded');
172
- * await sleep(error.retryAfter * 1000);
462
+ * hooks.add('beforeRequest', (url, opts, ctx) => {
463
+ * ctx.args(url, { ...opts, headers: { ...opts.headers, 'X-Token': token } });
173
464
  * });
174
465
  *
175
- * hooks.on('cacheHit', async (ctx) => {
176
- * console.log('Cache hit for:', ctx.args[0]);
466
+ * hooks.add('beforeRequest', (url, opts, ctx) => {
467
+ * const cached = cache.get(url);
468
+ * if (cached) return ctx.returns(cached);
177
469
  * });
178
470
  *
179
- * // In your implementation
180
- * const result = await hooks.emit('cacheHit', url, cachedData);
471
+ * const pre = await hooks.run('beforeRequest', url, options);
472
+ * if (pre.returned) return pre.result;
473
+ * const response = await fetch(...pre.args);
181
474
  *
182
475
  * @typeParam Lifecycle - Interface defining the lifecycle hooks
183
476
  * @typeParam FailArgs - Arguments type for ctx.fail() (default: [string])
184
477
  */
185
- /**
186
- * Default permissive lifecycle type when no type parameter is provided.
187
- */
188
- type DefaultLifecycle = Record<string, AsyncFunc>;
189
-
190
- /**
191
- * Extract only function property keys from a type.
192
- * This ensures only methods are available as hook names, not data properties.
193
- *
194
- * @example
195
- * interface Doc {
196
- * id: string;
197
- * save(): Promise<void>;
198
- * delete(): Promise<void>;
199
- * }
200
- *
201
- * type DocHooks = HookName<Doc>; // 'save' | 'delete' (excludes 'id')
202
- */
203
- export type HookName<T> = FunctionProps<T>;
204
-
205
478
  export class HookEngine<Lifecycle = DefaultLifecycle, FailArgs extends unknown[] = [string]> {
206
479
 
207
- #hooks: Map<HookName<Lifecycle>, Set<HookFn<FuncOrNever<Lifecycle[HookName<Lifecycle>]>, FailArgs>>> = new Map();
208
- #hookOpts = new WeakMap<HookFn<any, any>, HookOptions<any, any>>();
480
+ #hooks: Map<string, Array<HookEntry<any, FailArgs>>> = new Map();
209
481
  #handleFail: HandleFail<FailArgs>;
210
482
  #registered: Set<HookName<Lifecycle>> | null = null;
483
+ #callCounts: WeakMap<Function, number> = new WeakMap();
211
484
 
212
- constructor(options: HookEngineOptions<FailArgs> = {}) {
485
+ constructor(options: HookEngine.Options<FailArgs> = {}) {
213
486
 
214
487
  this.#handleFail = options.handleFail ?? ((message: string): never => {
215
488
 
@@ -218,7 +491,7 @@ export class HookEngine<Lifecycle = DefaultLifecycle, FailArgs extends unknown[]
218
491
  }
219
492
 
220
493
  /**
221
- * Validate that a hook is registered (if registration is enabled).
494
+ * Validate that a hook name is registered (when strict mode is active).
222
495
  */
223
496
  #assertRegistered(name: HookName<Lifecycle>, method: string) {
224
497
 
@@ -242,10 +515,10 @@ export class HookEngine<Lifecycle = DefaultLifecycle, FailArgs extends unknown[]
242
515
  *
243
516
  * @example
244
517
  * const hooks = new HookEngine<FetchLifecycle>()
245
- * .register('preRequest', 'postRequest', 'rateLimit');
518
+ * .register('beforeRequest', 'afterRequest');
246
519
  *
247
- * hooks.on('preRequest', cb); // OK
248
- * hooks.on('preRequset', cb); // Error: not registered (typo caught!)
520
+ * hooks.add('beforeRequest', cb); // OK
521
+ * hooks.add('beforeRequset', cb); // Error: not registered (typo caught!)
249
522
  */
250
523
  register(...names: HookName<Lifecycle>[]) {
251
524
 
@@ -269,247 +542,250 @@ export class HookEngine<Lifecycle = DefaultLifecycle, FailArgs extends unknown[]
269
542
  * Subscribe to a lifecycle hook.
270
543
  *
271
544
  * @param name - Name of the lifecycle hook
272
- * @param cbOrOpts - Callback function or options object
545
+ * @param callback - Callback function receiving spread args + ctx
546
+ * @param options - Options for this subscription
273
547
  * @returns Cleanup function to remove the subscription
274
548
  *
275
549
  * @example
276
550
  * // Simple callback
277
- * const cleanup = hooks.on('preRequest', async (ctx) => {
278
- * console.log('Request:', ctx.args[0]);
551
+ * const cleanup = hooks.add('beforeRequest', (url, opts, ctx) => {
552
+ * console.log('Request:', url);
279
553
  * });
280
554
  *
281
555
  * // With options
282
- * hooks.on('analytics', {
283
- * callback: async (ctx) => { track(ctx.args); },
284
- * once: true, // Remove after first run
285
- * ignoreOnFail: true // Don't throw if callback fails
286
- * });
556
+ * hooks.add('analytics', (event, ctx) => {
557
+ * track(event);
558
+ * }, { once: true, ignoreOnFail: true });
559
+ *
560
+ * // With priority (lower runs first)
561
+ * hooks.add('beforeRequest', cb, { priority: -10 });
287
562
  *
288
563
  * // Remove subscription
289
564
  * cleanup();
290
565
  */
291
- on<K extends HookName<Lifecycle>>(
566
+ add<K extends HookName<Lifecycle>>(
292
567
  name: K,
293
- cbOrOpts: HookOrOptions<FuncOrNever<Lifecycle[K]>, FailArgs>
294
- ) {
295
-
296
- const callback = typeof cbOrOpts === 'function' ? cbOrOpts : cbOrOpts?.callback;
297
- const opts = typeof cbOrOpts === 'function'
298
- ? {} as HookOptions<FuncOrNever<Lifecycle[K]>, FailArgs>
299
- : cbOrOpts;
568
+ callback: HookCallback<FuncOrNever<Lifecycle[K]>, FailArgs>,
569
+ options: HookEngine.AddOptions = {}
570
+ ): () => void {
300
571
 
301
572
  assert(typeof name === 'string', '"name" must be a string');
302
- assert(isFunction(callback) || isObject(cbOrOpts), '"cbOrOpts" must be a callback or options');
303
- assert(isFunction(callback), 'callback must be a function');
573
+ assert(isFunction(callback), '"callback" must be a function');
304
574
 
305
- this.#assertRegistered(name, 'on');
575
+ this.#assertRegistered(name, 'add');
306
576
 
307
- const hooks = this.#hooks.get(name) ?? new Set();
577
+ const priority = options.priority ?? 0;
578
+ const entry: HookEntry<any, FailArgs> = { callback, options, priority };
308
579
 
309
- hooks.add(callback as HookFn<FuncOrNever<Lifecycle[keyof Lifecycle]>, FailArgs>);
580
+ const hooks = this.#hooks.get(name as string) ?? [];
310
581
 
311
- this.#hooks.set(name, hooks);
312
- this.#hookOpts.set(callback, opts);
582
+ let inserted = false;
313
583
 
314
- return () => {
584
+ for (let i = 0; i < hooks.length; i++) {
315
585
 
316
- hooks.delete(callback as HookFn<FuncOrNever<Lifecycle[keyof Lifecycle]>, FailArgs>);
586
+ if (hooks[i]!.priority > priority) {
587
+
588
+ hooks.splice(i, 0, entry);
589
+ inserted = true;
590
+ break;
591
+ }
317
592
  }
318
- }
319
593
 
320
- /**
321
- * Subscribe to a lifecycle hook that fires only once.
322
- * Sugar for `on(name, { callback, once: true })`.
323
- *
324
- * @param name - Name of the lifecycle hook
325
- * @param callback - Callback function
326
- * @returns Cleanup function to remove the subscription
327
- *
328
- * @example
329
- * // Log only the first request
330
- * hooks.once('preRequest', async (ctx) => {
331
- * console.log('First request:', ctx.args[0]);
332
- * });
333
- */
334
- once<K extends HookName<Lifecycle>>(
335
- name: K,
336
- callback: HookFn<FuncOrNever<Lifecycle[K]>, FailArgs>
337
- ) {
594
+ if (!inserted) {
338
595
 
339
- return this.on(name, { callback, once: true });
596
+ hooks.push(entry);
597
+ }
598
+
599
+ this.#hooks.set(name as string, hooks);
600
+
601
+ return () => {
602
+
603
+ const arr = this.#hooks.get(name as string);
604
+
605
+ if (arr) {
606
+
607
+ const idx = arr.indexOf(entry);
608
+
609
+ if (idx !== -1) {
610
+
611
+ arr.splice(idx, 1);
612
+ }
613
+ }
614
+ };
340
615
  }
341
616
 
342
617
  /**
343
- * Emit a lifecycle hook, running all subscribed callbacks.
618
+ * Run all callbacks for a hook asynchronously.
344
619
  *
345
- * @param name - Name of the lifecycle hook to emit
346
- * @param args - Arguments to pass to callbacks
347
- * @returns EmitResult with final args, result, and earlyReturn flag
620
+ * @param name - Name of the lifecycle hook to run
621
+ * @param args - Arguments to pass to callbacks (spread + ctx)
622
+ * @returns RunResult with final args, result, and returned flag
348
623
  *
349
624
  * @example
350
- * const result = await hooks.emit('cacheCheck', url);
351
- *
352
- * if (result.earlyReturn && result.result) {
353
- * return result.result; // Use cached value
354
- * }
355
- *
356
- * // Continue with modified args
357
- * const [modifiedUrl] = result.args;
625
+ * const pre = await hooks.run('beforeRequest', url, options);
626
+ * if (pre.returned) return pre.result;
627
+ * const response = await fetch(...pre.args);
358
628
  */
359
- async emit<K extends HookName<Lifecycle>>(
629
+ async run<K extends HookName<Lifecycle>>(
360
630
  name: K,
361
- ...args: Parameters<FuncOrNever<Lifecycle[K]>>
362
- ): Promise<EmitResult<FuncOrNever<Lifecycle[K]>>> {
631
+ ...args: Parameters<FuncOrNever<Lifecycle[K]>> | [...Parameters<FuncOrNever<Lifecycle[K]>>, HookEngine.RunOptions<FuncOrNever<Lifecycle[K]>>]
632
+ ): Promise<RunResult<FuncOrNever<Lifecycle[K]>>> {
363
633
 
364
- this.#assertRegistered(name, 'emit');
634
+ this.#assertRegistered(name, 'run');
365
635
 
366
- let earlyReturn = false;
636
+ const { realArgs, runOptions } = this.#extractRunOptions(args);
637
+ let currentArgs = realArgs as Parameters<FuncOrNever<Lifecycle[K]>>;
638
+ const scope = runOptions?.scope ?? new HookScope();
367
639
 
368
- const hooks = this.#hooks.get(name);
640
+ const hooks = this.#hooks.get(name as string);
641
+ const entries = hooks ? [...hooks] : [];
369
642
 
370
- const context: HookContext<FuncOrNever<Lifecycle[K]>, FailArgs> = {
371
- args,
372
- removeHook() {},
373
- returnEarly() {
643
+ if (runOptions?.append) {
374
644
 
375
- earlyReturn = true;
376
- },
377
- setArgs: (next) => {
645
+ entries.push({
646
+ callback: runOptions.append as unknown as HookCallback<any, FailArgs>,
647
+ options: {},
648
+ priority: Infinity
649
+ });
650
+ }
378
651
 
379
- assert(
380
- Array.isArray(next),
381
- `setArgs: args for '${String(name)}' must be an array`
382
- );
652
+ let result: Awaited<ReturnType<FuncOrNever<Lifecycle[K]>>> | undefined;
653
+ let returned = false;
654
+
655
+ for (const entry of entries) {
383
656
 
384
- context.args = next;
385
- },
386
- setResult: (next) => {
657
+ const { callback, options: opts } = entry;
387
658
 
388
- context.result = next;
389
- },
390
- fail: ((...failArgs: FailArgs) => {
659
+ const timesExceeded = this.#checkTimes(callback, opts);
391
660
 
392
- const handler = this.#handleFail;
661
+ if (timesExceeded) {
393
662
 
394
- // Check if handler is a constructor (class or function with prototype)
395
- const isConstructor = typeof handler === 'function' &&
396
- handler.prototype?.constructor === handler;
663
+ this.#removeEntry(name as string, entry);
664
+ continue;
665
+ }
397
666
 
398
- const [, error] = attemptSync(() => {
667
+ const removeFn = () => this.#removeEntry(name as string, entry);
668
+ const ctx = new HookContext<any, any, FailArgs>(this.#handleFail, String(name), removeFn, scope);
399
669
 
400
- if (isConstructor) {
670
+ if (opts.ignoreOnFail) {
401
671
 
402
- throw new (handler as new (...args: FailArgs) => Error)(...failArgs);
403
- }
672
+ const [, err] = await attempt(async () => {
404
673
 
405
- (handler as (...args: FailArgs) => never)(...failArgs);
674
+ const signal = await callback(...currentArgs, ctx);
675
+ this.#processCtx(ctx, signal, (a) => { currentArgs = a; }, (r) => { result = r; }, () => { returned = true; });
406
676
  });
407
677
 
408
- if (error) {
678
+ if (!err && opts.once) removeFn();
409
679
 
410
- if (error instanceof HookError) {
680
+ if (returned) break;
681
+ continue;
682
+ }
411
683
 
412
- error.hookName = String(name);
413
- }
684
+ const signal = await callback(...currentArgs, ctx);
685
+ this.#processCtx(ctx, signal, (a) => { currentArgs = a; }, (r) => { result = r; }, () => { returned = true; });
414
686
 
415
- throw error;
416
- }
687
+ if (opts.once) removeFn();
417
688
 
418
- // If handler didn't throw, we need to throw something
419
- throw new HookError('ctx.fail() handler did not throw');
420
- }) as (...args: FailArgs) => never
421
- };
689
+ if (returned) break;
690
+ }
422
691
 
423
- if (!hooks || hooks.size === 0) {
692
+ return { args: currentArgs, result, returned, scope };
693
+ }
424
694
 
425
- return {
426
- args: context.args,
427
- result: context.result,
428
- earlyReturn: false
429
- };
695
+ /**
696
+ * Run all callbacks for a hook synchronously.
697
+ *
698
+ * @param name - Name of the lifecycle hook to run
699
+ * @param args - Arguments to pass to callbacks (spread + ctx)
700
+ * @returns RunResult with final args, result, and returned flag
701
+ *
702
+ * @example
703
+ * const pre = hooks.runSync('beforeValidation', data);
704
+ * if (pre.returned) return pre.result;
705
+ */
706
+ runSync<K extends HookName<Lifecycle>>(
707
+ name: K,
708
+ ...args: Parameters<FuncOrNever<Lifecycle[K]>> | [...Parameters<FuncOrNever<Lifecycle[K]>>, HookEngine.RunOptions<FuncOrNever<Lifecycle[K]>>]
709
+ ): RunResult<FuncOrNever<Lifecycle[K]>> {
710
+
711
+ this.#assertRegistered(name, 'runSync');
712
+
713
+ const { realArgs, runOptions } = this.#extractRunOptions(args as unknown[]);
714
+ let currentArgs = realArgs as Parameters<FuncOrNever<Lifecycle[K]>>;
715
+ const scope = runOptions?.scope ?? new HookScope();
716
+
717
+ const hooks = this.#hooks.get(name as string);
718
+ const entries = hooks ? [...hooks] : [];
719
+
720
+ if (runOptions?.append) {
721
+
722
+ entries.push({
723
+ callback: runOptions.append as unknown as HookCallback<any, FailArgs>,
724
+ options: {},
725
+ priority: Infinity
726
+ });
430
727
  }
431
728
 
432
- for (const fn of hooks) {
729
+ let result: Awaited<ReturnType<FuncOrNever<Lifecycle[K]>>> | undefined;
730
+ let returned = false;
433
731
 
434
- context.removeHook = () => hooks.delete(fn as any);
732
+ for (const entry of entries) {
435
733
 
436
- const opts: HookOptions<any, any> = this.#hookOpts.get(fn) ?? { callback: fn };
437
- const [, err] = await attempt(() => fn({ ...context } as any));
734
+ const { callback, options: opts } = entry;
438
735
 
439
- if (opts.once) context.removeHook();
736
+ const timesExceeded = this.#checkTimes(callback, opts);
440
737
 
441
- if (err && opts.ignoreOnFail !== true) {
738
+ if (timesExceeded) {
442
739
 
443
- throw err;
740
+ this.#removeEntry(name as string, entry);
741
+ continue;
444
742
  }
445
743
 
446
- if (earlyReturn) break;
447
- }
744
+ const removeFn = () => this.#removeEntry(name as string, entry);
745
+ const ctx = new HookContext<any, any, FailArgs>(this.#handleFail, String(name), removeFn, scope);
448
746
 
449
- return {
450
- args: context.args,
451
- result: context.result,
452
- earlyReturn
453
- };
454
- }
747
+ if (opts.ignoreOnFail) {
455
748
 
456
- /**
457
- * Clear all registered hooks.
458
- *
459
- * @example
460
- * hooks.on('preRequest', validator);
461
- * hooks.on('postRequest', logger);
462
- *
463
- * // Reset for testing
464
- * hooks.clear();
465
- */
466
- clear() {
749
+ const [, err] = attemptSync(() => {
467
750
 
468
- this.#hooks.clear();
469
- this.#hookOpts = new WeakMap();
470
- this.#registered = null;
751
+ const signal = callback(...currentArgs, ctx);
752
+ this.#processCtx(ctx, signal, (a) => { currentArgs = a; }, (r) => { result = r; }, () => { returned = true; });
753
+ });
754
+
755
+ if (!err && opts.once) removeFn();
756
+
757
+ if (returned) break;
758
+ continue;
759
+ }
760
+
761
+ const signal = callback(...currentArgs, ctx);
762
+ this.#processCtx(ctx, signal, (a) => { currentArgs = a; }, (r) => { result = r; }, () => { returned = true; });
763
+
764
+ if (opts.once) removeFn();
765
+
766
+ if (returned) break;
767
+ }
768
+
769
+ return { args: currentArgs, result, returned, scope };
471
770
  }
472
771
 
473
772
  /**
474
- * Wrap a function with pre/post lifecycle hooks.
773
+ * Wrap an async function with pre/post lifecycle hooks.
475
774
  *
476
- * - Pre hook: emitted with function args, can modify args or returnEarly with result
477
- * - Post hook: emitted with [result, ...args], can modify result
775
+ * - Pre hook: called with function args, can modify args or return early
776
+ * - Post hook: called with `(result, ...originalArgs)`, can transform result
478
777
  *
479
778
  * @param fn - The async function to wrap
480
779
  * @param hooks - Object with optional pre and post hook names
481
780
  * @returns Wrapped function with same signature
482
781
  *
483
782
  * @example
484
- * interface Lifecycle {
485
- * preRequest(url: string, opts: RequestInit): Promise<Response>;
486
- * postRequest(result: Response, url: string, opts: RequestInit): Promise<Response>;
487
- * }
488
- *
489
- * const hooks = new HookEngine<Lifecycle>();
490
- *
491
- * // Add cache check in pre hook
492
- * hooks.on('preRequest', async (ctx) => {
493
- * const cached = cache.get(ctx.args[0]);
494
- * if (cached) {
495
- * ctx.setResult(cached);
496
- * ctx.returnEarly();
497
- * }
498
- * });
499
- *
500
- * // Log result in post hook
501
- * hooks.on('postRequest', async (ctx) => {
502
- * const [result, url] = ctx.args;
503
- * console.log(`Fetched ${url}:`, result.status);
504
- * });
505
- *
506
- * // Wrap the fetch function
507
783
  * const wrappedFetch = hooks.wrap(
508
784
  * async (url: string, opts: RequestInit) => fetch(url, opts),
509
- * { pre: 'preRequest', post: 'postRequest' }
785
+ * { pre: 'beforeRequest', post: 'afterRequest' }
510
786
  * );
511
787
  */
512
- wrap<F extends AsyncFunc>(
788
+ wrap<F extends (...args: any[]) => Promise<any>>(
513
789
  fn: F,
514
790
  hooks:
515
791
  | { pre: HookName<Lifecycle>; post?: HookName<Lifecycle> }
@@ -527,33 +803,28 @@ export class HookEngine<Lifecycle = DefaultLifecycle, FailArgs extends unknown[]
527
803
  return async (...args: Parameters<F>): Promise<Awaited<ReturnType<F>>> => {
528
804
 
529
805
  let currentArgs = args;
530
- let result: Awaited<ReturnType<F>> | undefined;
531
806
 
532
- // Pre hook
533
807
  if (hooks.pre) {
534
808
 
535
- const preResult = await this.emit(hooks.pre, ...currentArgs as any);
536
-
809
+ const preResult = await this.run(hooks.pre, ...currentArgs as any);
537
810
  currentArgs = preResult.args as Parameters<F>;
538
811
 
539
- if (preResult.earlyReturn && preResult.result !== undefined) {
812
+ if (preResult.returned && preResult.result !== undefined) {
540
813
 
541
814
  return preResult.result as Awaited<ReturnType<F>>;
542
815
  }
543
816
  }
544
817
 
545
- // Execute function
546
- result = await fn(...currentArgs);
818
+ const result = await fn(...currentArgs);
547
819
 
548
- // Post hook
549
820
  if (hooks.post) {
550
821
 
551
- const postResult = await this.emit(
822
+ const postResult = await this.run(
552
823
  hooks.post,
553
824
  ...[result, ...currentArgs] as any
554
825
  );
555
826
 
556
- if (postResult.result !== undefined) {
827
+ if (postResult.returned) {
557
828
 
558
829
  return postResult.result as Awaited<ReturnType<F>>;
559
830
  }
@@ -562,4 +833,420 @@ export class HookEngine<Lifecycle = DefaultLifecycle, FailArgs extends unknown[]
562
833
  return result as Awaited<ReturnType<F>>;
563
834
  };
564
835
  }
836
+
837
+ /**
838
+ * Wrap a synchronous function with pre/post lifecycle hooks.
839
+ *
840
+ * @param fn - The sync function to wrap
841
+ * @param hooks - Object with optional pre and post hook names
842
+ * @returns Wrapped function with same signature
843
+ *
844
+ * @example
845
+ * const wrappedValidate = hooks.wrapSync(
846
+ * (data: UserData) => validate(data),
847
+ * { pre: 'beforeValidate' }
848
+ * );
849
+ */
850
+ wrapSync<F extends (...args: any[]) => any>(
851
+ fn: F,
852
+ hooks:
853
+ | { pre: HookName<Lifecycle>; post?: HookName<Lifecycle> }
854
+ | { pre?: HookName<Lifecycle>; post: HookName<Lifecycle> }
855
+ ): (...args: Parameters<F>) => ReturnType<F> {
856
+
857
+ assert(
858
+ hooks.pre || hooks.post,
859
+ 'wrapSync() requires at least one of "pre" or "post" hooks'
860
+ );
861
+
862
+ if (hooks.pre) this.#assertRegistered(hooks.pre, 'wrapSync');
863
+ if (hooks.post) this.#assertRegistered(hooks.post, 'wrapSync');
864
+
865
+ return (...args: Parameters<F>): ReturnType<F> => {
866
+
867
+ let currentArgs = args;
868
+
869
+ if (hooks.pre) {
870
+
871
+ const preResult = this.runSync(hooks.pre, ...currentArgs as any);
872
+ currentArgs = preResult.args as Parameters<F>;
873
+
874
+ if (preResult.returned && preResult.result !== undefined) {
875
+
876
+ return preResult.result as ReturnType<F>;
877
+ }
878
+ }
879
+
880
+ const result = fn(...currentArgs);
881
+
882
+ if (hooks.post) {
883
+
884
+ const postResult = this.runSync(
885
+ hooks.post,
886
+ ...[result, ...currentArgs] as any
887
+ );
888
+
889
+ if (postResult.returned) {
890
+
891
+ return postResult.result as ReturnType<F>;
892
+ }
893
+ }
894
+
895
+ return result as ReturnType<F>;
896
+ };
897
+ }
898
+
899
+ /**
900
+ * Execute middleware hooks as an onion (nested) composition.
901
+ *
902
+ * Unlike `run()` which executes hooks linearly, `pipe()` composes hooks
903
+ * as nested middleware. Each hook receives a `next` function that calls
904
+ * the next layer. The innermost layer is `coreFn`. Control flow is
905
+ * managed by calling or not calling `next()` — no `ctx.returns()` needed.
906
+ *
907
+ * Hooks execute in priority order (lower first = outermost layer).
908
+ *
909
+ * @param name - Name of the lifecycle hook
910
+ * @param coreFn - The innermost function to wrap
911
+ * @param args - Arguments passed to each middleware
912
+ * @returns The result from the middleware chain
913
+ *
914
+ * @example
915
+ * // Retry plugin wraps the fetch call
916
+ * hooks.add('execute', async (next, opts, ctx) => {
917
+ * for (let i = 0; i < 3; i++) {
918
+ * const [result, err] = await attempt(next);
919
+ * if (!err) return result;
920
+ * await wait(1000 * i);
921
+ * }
922
+ * throw lastError;
923
+ * }, { priority: -20 });
924
+ *
925
+ * // Dedupe plugin wraps retry
926
+ * hooks.add('execute', async (next, opts, ctx) => {
927
+ * const inflight = getInflight(key);
928
+ * if (inflight) return inflight;
929
+ * const result = await next();
930
+ * share(result);
931
+ * return result;
932
+ * }, { priority: -30 });
933
+ *
934
+ * // Execute: dedupe( retry( makeCall() ) )
935
+ * const response = await hooks.pipe(
936
+ * 'execute',
937
+ * () => makeCall(opts),
938
+ * opts
939
+ * );
940
+ */
941
+ async pipe<K extends HookName<Lifecycle>, R = unknown>(
942
+ name: K,
943
+ coreFn: () => Promise<R>,
944
+ ...args: unknown[]
945
+ ): Promise<R> {
946
+
947
+ this.#assertRegistered(name, 'pipe');
948
+
949
+ const { realArgs, runOptions } = this.#extractRunOptions(args);
950
+ const scope = runOptions?.scope ?? new HookScope();
951
+
952
+ const hooks = this.#hooks.get(name as string);
953
+ const entries = hooks ? [...hooks] : [];
954
+
955
+ if (runOptions?.append) {
956
+
957
+ entries.push({
958
+ callback: runOptions.append as unknown as HookCallback<any, FailArgs>,
959
+ options: {},
960
+ priority: Infinity
961
+ });
962
+ }
963
+
964
+ let currentArgs = realArgs;
965
+
966
+ const buildChain = (index: number): (() => Promise<R>) => {
967
+
968
+ if (index >= entries.length) return coreFn;
969
+
970
+ return async () => {
971
+
972
+ const entry = entries[index]!;
973
+ const { callback, options: opts } = entry;
974
+
975
+ const timesExceeded = this.#checkTimes(callback, opts);
976
+
977
+ if (timesExceeded) {
978
+
979
+ this.#removeEntry(name as string, entry);
980
+ return buildChain(index + 1)();
981
+ }
982
+
983
+ const removeFn = () => this.#removeEntry(name as string, entry);
984
+ const ctx = new PipeContext<FailArgs>(
985
+ this.#handleFail,
986
+ String(name),
987
+ removeFn,
988
+ scope,
989
+ (newArgs) => { currentArgs = newArgs; }
990
+ );
991
+
992
+ const next = buildChain(index + 1);
993
+ const cb = callback as any;
994
+
995
+ if (opts.ignoreOnFail) {
996
+
997
+ const [result, err] = await attempt(
998
+ async () => cb(next, ...currentArgs, ctx)
999
+ );
1000
+
1001
+ if (opts.once) removeFn();
1002
+
1003
+ if (err) return next();
1004
+
1005
+ return result as R;
1006
+ }
1007
+
1008
+ const result = await cb(next, ...currentArgs, ctx);
1009
+
1010
+ if (opts.once) removeFn();
1011
+
1012
+ return result as R;
1013
+ };
1014
+ };
1015
+
1016
+ return buildChain(0)();
1017
+ }
1018
+
1019
+ /**
1020
+ * Synchronous version of `pipe()`.
1021
+ *
1022
+ * @param name - Name of the lifecycle hook
1023
+ * @param coreFn - The innermost function to wrap
1024
+ * @param args - Arguments passed to each middleware
1025
+ * @returns The result from the middleware chain
1026
+ */
1027
+ pipeSync<K extends HookName<Lifecycle>, R = unknown>(
1028
+ name: K,
1029
+ coreFn: () => R,
1030
+ ...args: unknown[]
1031
+ ): R {
1032
+
1033
+ this.#assertRegistered(name, 'pipeSync');
1034
+
1035
+ const { realArgs, runOptions } = this.#extractRunOptions(args);
1036
+ const scope = runOptions?.scope ?? new HookScope();
1037
+
1038
+ const hooks = this.#hooks.get(name as string);
1039
+ const entries = hooks ? [...hooks] : [];
1040
+
1041
+ if (runOptions?.append) {
1042
+
1043
+ entries.push({
1044
+ callback: runOptions.append as unknown as HookCallback<any, FailArgs>,
1045
+ options: {},
1046
+ priority: Infinity
1047
+ });
1048
+ }
1049
+
1050
+ let currentArgs = realArgs;
1051
+
1052
+ const buildChain = (index: number): (() => R) => {
1053
+
1054
+ if (index >= entries.length) return coreFn;
1055
+
1056
+ return () => {
1057
+
1058
+ const entry = entries[index]!;
1059
+ const { callback, options: opts } = entry;
1060
+
1061
+ const timesExceeded = this.#checkTimes(callback, opts);
1062
+
1063
+ if (timesExceeded) {
1064
+
1065
+ this.#removeEntry(name as string, entry);
1066
+ return buildChain(index + 1)();
1067
+ }
1068
+
1069
+ const removeFn = () => this.#removeEntry(name as string, entry);
1070
+ const ctx = new PipeContext<FailArgs>(
1071
+ this.#handleFail,
1072
+ String(name),
1073
+ removeFn,
1074
+ scope,
1075
+ (newArgs) => { currentArgs = newArgs; }
1076
+ );
1077
+
1078
+ const next = buildChain(index + 1);
1079
+ const cb = callback as any;
1080
+
1081
+ if (opts.ignoreOnFail) {
1082
+
1083
+ const [result, err] = attemptSync(
1084
+ () => cb(next, ...currentArgs, ctx)
1085
+ );
1086
+
1087
+ if (opts.once) removeFn();
1088
+
1089
+ if (err) return next();
1090
+
1091
+ return result as R;
1092
+ }
1093
+
1094
+ const result = cb(next, ...currentArgs, ctx);
1095
+
1096
+ if (opts.once) removeFn();
1097
+
1098
+ return result as R;
1099
+ };
1100
+ };
1101
+
1102
+ return buildChain(0)();
1103
+ }
1104
+
1105
+ /**
1106
+ * Clear all hooks and reset registration state.
1107
+ *
1108
+ * @example
1109
+ * hooks.add('beforeRequest', validator);
1110
+ * hooks.clear();
1111
+ * // All hooks removed, back to permissive mode
1112
+ */
1113
+ clear() {
1114
+
1115
+ this.#hooks.clear();
1116
+ this.#registered = null;
1117
+ this.#callCounts = new WeakMap();
1118
+ }
1119
+
1120
+ /**
1121
+ * Process a HookContext after a callback has run.
1122
+ */
1123
+ #processCtx(
1124
+ ctx: HookContext<any, any, FailArgs>,
1125
+ signal: unknown,
1126
+ setArgs: (a: any) => void,
1127
+ setResult: (r: any) => void,
1128
+ setReturned: () => void
1129
+ ) {
1130
+
1131
+ if (ctx._earlyReturn) {
1132
+
1133
+ setResult(ctx._result);
1134
+ setReturned();
1135
+ return;
1136
+ }
1137
+
1138
+ if (ctx._argsChanged) {
1139
+
1140
+ setArgs(ctx._newArgs);
1141
+
1142
+ if (signal === EARLY_RETURN) {
1143
+
1144
+ setReturned();
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * Check times limit and increment counter. Returns true if exceeded.
1151
+ */
1152
+ #checkTimes(callback: Function, opts: HookEngine.AddOptions): boolean {
1153
+
1154
+ if (opts.times === undefined) return false;
1155
+
1156
+ const count = this.#callCounts.get(callback) ?? 0;
1157
+
1158
+ if (count >= opts.times) return true;
1159
+
1160
+ this.#callCounts.set(callback, count + 1);
1161
+ return false;
1162
+ }
1163
+
1164
+ /**
1165
+ * Remove an entry from the hooks array.
1166
+ */
1167
+ #removeEntry(name: string, entry: HookEntry<any, FailArgs>) {
1168
+
1169
+ const arr = this.#hooks.get(name);
1170
+
1171
+ if (arr) {
1172
+
1173
+ const idx = arr.indexOf(entry);
1174
+
1175
+ if (idx !== -1) {
1176
+
1177
+ arr.splice(idx, 1);
1178
+ }
1179
+ }
1180
+ }
1181
+
1182
+ /**
1183
+ * Extract RunOptions from the args array if present.
1184
+ */
1185
+ #extractRunOptions(
1186
+ args: unknown[]
1187
+ ): { realArgs: unknown[]; runOptions: HookEngine.RunOptions<any> | undefined } {
1188
+
1189
+ const last = args[args.length - 1];
1190
+
1191
+ if (isObject(last) && (
1192
+ ('append' in (last as object) && isFunction((last as any).append)) ||
1193
+ ('scope' in (last as object) && (last as any).scope instanceof HookScope)
1194
+ )) {
1195
+
1196
+ return {
1197
+ realArgs: args.slice(0, -1),
1198
+ runOptions: last as HookEngine.RunOptions<any>
1199
+ };
1200
+ }
1201
+
1202
+ return { realArgs: args, runOptions: undefined };
1203
+ }
1204
+ }
1205
+
1206
+ export namespace HookEngine {
1207
+
1208
+ export interface Options<FailArgs extends unknown[] = [string]> {
1209
+
1210
+ /**
1211
+ * Custom handler for `ctx.fail()`.
1212
+ * Can be an Error constructor or a function that throws.
1213
+ *
1214
+ * @example
1215
+ * new HookEngine({ handleFail: HttpsError });
1216
+ */
1217
+ handleFail?: HandleFail<FailArgs>;
1218
+ }
1219
+
1220
+ export interface AddOptions {
1221
+
1222
+ /** Remove after first run (sugar for `times: 1`) */
1223
+ once?: true;
1224
+
1225
+ /** Run N times then auto-remove */
1226
+ times?: number;
1227
+
1228
+ /** Swallow errors from this callback, continue chain */
1229
+ ignoreOnFail?: true;
1230
+
1231
+ /** Execution order, lower runs first. Default 0. */
1232
+ priority?: number;
1233
+ }
1234
+
1235
+ export interface RunOptions<F extends (...args: any[]) => any = (...args: any[]) => any> {
1236
+
1237
+ /** Ephemeral callback that runs last (for per-request hooks) */
1238
+ append?: HookCallback<F>;
1239
+
1240
+ /** Shared scope that flows across hook runs and engine instances */
1241
+ scope?: HookScope;
1242
+ }
1243
+
1244
+ export interface PipeOptions {
1245
+
1246
+ /** Ephemeral middleware that runs last (innermost before coreFn) */
1247
+ append?: (...args: any[]) => any;
1248
+
1249
+ /** Shared scope that flows across hook runs and engine instances */
1250
+ scope?: HookScope;
1251
+ }
565
1252
  }