@logosdx/hooks 1.0.0-beta.1 → 1.0.0-beta.3

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 ADDED
@@ -0,0 +1,1252 @@
1
+ import {
2
+ assert,
3
+ attempt,
4
+ attemptSync,
5
+ FunctionProps,
6
+ isFunction,
7
+ isObject
8
+ } from '@logosdx/utils';
9
+
10
+ /**
11
+ * Error thrown when a hook calls `ctx.fail()`.
12
+ *
13
+ * Only created when using the default `handleFail` behavior.
14
+ * If a custom `handleFail` is provided, that error type is thrown instead.
15
+ *
16
+ * @example
17
+ * hooks.add('validate', (data, ctx) => {
18
+ * if (!data.isValid) ctx.fail('Validation failed');
19
+ * });
20
+ *
21
+ * const [, err] = await attempt(() => engine.run('validate', data));
22
+ * if (isHookError(err)) {
23
+ * console.log(err.hookName); // 'validate'
24
+ * }
25
+ */
26
+ export class HookError extends Error {
27
+
28
+ /** Name of the hook where the error occurred */
29
+ hookName?: string;
30
+
31
+ /** Original error if `fail()` was called with an Error instance */
32
+ originalError?: Error;
33
+
34
+ constructor(message: string) {
35
+
36
+ super(message);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Type guard to check if an error is a HookError.
42
+ *
43
+ * @example
44
+ * const [, err] = await attempt(() => engine.run('validate', data));
45
+ * if (isHookError(err)) {
46
+ * console.log(`Hook "${err.hookName}" failed`);
47
+ * }
48
+ */
49
+ export const isHookError = (error: unknown): error is HookError => {
50
+
51
+ return (error as HookError)?.constructor?.name === HookError.name;
52
+ };
53
+
54
+ /**
55
+ * Custom error handler for `ctx.fail()`.
56
+ * Can be an Error constructor or a function that throws.
57
+ */
58
+ export type HandleFail<Args extends unknown[] = [string]> =
59
+ | (new (...args: Args) => Error)
60
+ | ((...args: Args) => never);
61
+
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
+ }
100
+
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
+ }
108
+
109
+ /**
110
+ * Delete a key from the scope.
111
+ */
112
+ delete(key: symbol | string): boolean {
113
+
114
+ return this.#data.delete(key);
115
+ }
116
+ }
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
+
123
+ /**
124
+ * Extract only function property keys from a type.
125
+ *
126
+ * @example
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.
140
+ *
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);
156
+ * });
157
+ */
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;
171
+
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
+ ) {
191
+
192
+ this.#handleFail = handleFail;
193
+ this.#hookName = hookName;
194
+ this.#removeFn = removeFn;
195
+ this.scope = scope;
196
+ }
197
+
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 {
210
+
211
+ this.#args = args;
212
+ this.#argsChanged = true;
213
+ return EARLY_RETURN;
214
+ }
215
+
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 {
224
+
225
+ this.#result = value;
226
+ this.#earlyReturn = true;
227
+ return EARLY_RETURN;
228
+ }
229
+
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 {
238
+
239
+ const handler = this.#handleFail;
240
+
241
+ const isConstructor = typeof handler === 'function' &&
242
+ handler.prototype?.constructor === handler;
243
+
244
+ const [, error] = attemptSync(() => {
245
+
246
+ if (isConstructor) {
247
+
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
+ }
293
+
294
+ /**
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
+ * });
307
+ */
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
+ }
337
+
338
+ /**
339
+ * Replace args for `next()` and downstream middleware.
340
+ *
341
+ * @example
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.
352
+ *
353
+ * @example
354
+ * ctx.fail('Rate limit exceeded');
355
+ */
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
+ }
393
+ }
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
+
448
+ /**
449
+ * A lightweight, type-safe lifecycle hook system.
450
+ *
451
+ * HookEngine allows you to define lifecycle events and subscribe to them.
452
+ * Callbacks receive spread arguments with a context object as the last param.
453
+ *
454
+ * @example
455
+ * interface FetchLifecycle {
456
+ * beforeRequest(url: string, options: RequestInit): Promise<Response>;
457
+ * afterRequest(response: Response, url: string): Promise<Response>;
458
+ * }
459
+ *
460
+ * const hooks = new HookEngine<FetchLifecycle>();
461
+ *
462
+ * hooks.add('beforeRequest', (url, opts, ctx) => {
463
+ * ctx.args(url, { ...opts, headers: { ...opts.headers, 'X-Token': token } });
464
+ * });
465
+ *
466
+ * hooks.add('beforeRequest', (url, opts, ctx) => {
467
+ * const cached = cache.get(url);
468
+ * if (cached) return ctx.returns(cached);
469
+ * });
470
+ *
471
+ * const pre = await hooks.run('beforeRequest', url, options);
472
+ * if (pre.returned) return pre.result;
473
+ * const response = await fetch(...pre.args);
474
+ *
475
+ * @typeParam Lifecycle - Interface defining the lifecycle hooks
476
+ * @typeParam FailArgs - Arguments type for ctx.fail() (default: [string])
477
+ */
478
+ export class HookEngine<Lifecycle = DefaultLifecycle, FailArgs extends unknown[] = [string]> {
479
+
480
+ #hooks: Map<string, Array<HookEntry<any, FailArgs>>> = new Map();
481
+ #handleFail: HandleFail<FailArgs>;
482
+ #registered: Set<HookName<Lifecycle>> | null = null;
483
+ #callCounts: WeakMap<Function, number> = new WeakMap();
484
+
485
+ constructor(options: HookEngine.Options<FailArgs> = {}) {
486
+
487
+ this.#handleFail = options.handleFail ?? ((message: string): never => {
488
+
489
+ throw new HookError(message);
490
+ }) as unknown as HandleFail<FailArgs>;
491
+ }
492
+
493
+ /**
494
+ * Validate that a hook name is registered (when strict mode is active).
495
+ */
496
+ #assertRegistered(name: HookName<Lifecycle>, method: string) {
497
+
498
+ if (this.#registered !== null && !this.#registered.has(name)) {
499
+
500
+ const registered = [...this.#registered].map(String).join(', ');
501
+ throw new Error(
502
+ `Hook "${String(name)}" is not registered. ` +
503
+ `Call register("${String(name)}") before using ${method}(). ` +
504
+ `Registered hooks: ${registered || '(none)'}`
505
+ );
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Register hook names for runtime validation.
511
+ * Once any hooks are registered, all hooks must be registered before use.
512
+ *
513
+ * @param names - Hook names to register
514
+ * @returns this (for chaining)
515
+ *
516
+ * @example
517
+ * const hooks = new HookEngine<FetchLifecycle>()
518
+ * .register('beforeRequest', 'afterRequest');
519
+ *
520
+ * hooks.add('beforeRequest', cb); // OK
521
+ * hooks.add('beforeRequset', cb); // Error: not registered (typo caught!)
522
+ */
523
+ register(...names: HookName<Lifecycle>[]) {
524
+
525
+ assert(names.length > 0, 'register() requires at least one hook name');
526
+
527
+ if (this.#registered === null) {
528
+
529
+ this.#registered = new Set();
530
+ }
531
+
532
+ for (const name of names) {
533
+
534
+ assert(typeof name === 'string', `Hook name must be a string, got ${typeof name}`);
535
+ this.#registered.add(name);
536
+ }
537
+
538
+ return this;
539
+ }
540
+
541
+ /**
542
+ * Subscribe to a lifecycle hook.
543
+ *
544
+ * @param name - Name of the lifecycle hook
545
+ * @param callback - Callback function receiving spread args + ctx
546
+ * @param options - Options for this subscription
547
+ * @returns Cleanup function to remove the subscription
548
+ *
549
+ * @example
550
+ * // Simple callback
551
+ * const cleanup = hooks.add('beforeRequest', (url, opts, ctx) => {
552
+ * console.log('Request:', url);
553
+ * });
554
+ *
555
+ * // With options
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 });
562
+ *
563
+ * // Remove subscription
564
+ * cleanup();
565
+ */
566
+ add<K extends HookName<Lifecycle>>(
567
+ name: K,
568
+ callback: HookCallback<FuncOrNever<Lifecycle[K]>, FailArgs>,
569
+ options: HookEngine.AddOptions = {}
570
+ ): () => void {
571
+
572
+ assert(typeof name === 'string', '"name" must be a string');
573
+ assert(isFunction(callback), '"callback" must be a function');
574
+
575
+ this.#assertRegistered(name, 'add');
576
+
577
+ const priority = options.priority ?? 0;
578
+ const entry: HookEntry<any, FailArgs> = { callback, options, priority };
579
+
580
+ const hooks = this.#hooks.get(name as string) ?? [];
581
+
582
+ let inserted = false;
583
+
584
+ for (let i = 0; i < hooks.length; i++) {
585
+
586
+ if (hooks[i]!.priority > priority) {
587
+
588
+ hooks.splice(i, 0, entry);
589
+ inserted = true;
590
+ break;
591
+ }
592
+ }
593
+
594
+ if (!inserted) {
595
+
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
+ };
615
+ }
616
+
617
+ /**
618
+ * Run all callbacks for a hook asynchronously.
619
+ *
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
623
+ *
624
+ * @example
625
+ * const pre = await hooks.run('beforeRequest', url, options);
626
+ * if (pre.returned) return pre.result;
627
+ * const response = await fetch(...pre.args);
628
+ */
629
+ async run<K extends HookName<Lifecycle>>(
630
+ name: K,
631
+ ...args: Parameters<FuncOrNever<Lifecycle[K]>> | [...Parameters<FuncOrNever<Lifecycle[K]>>, HookEngine.RunOptions<FuncOrNever<Lifecycle[K]>>]
632
+ ): Promise<RunResult<FuncOrNever<Lifecycle[K]>>> {
633
+
634
+ this.#assertRegistered(name, 'run');
635
+
636
+ const { realArgs, runOptions } = this.#extractRunOptions(args);
637
+ let currentArgs = realArgs as Parameters<FuncOrNever<Lifecycle[K]>>;
638
+ const scope = runOptions?.scope ?? new HookScope();
639
+
640
+ const hooks = this.#hooks.get(name as string);
641
+ const entries = hooks ? [...hooks] : [];
642
+
643
+ if (runOptions?.append) {
644
+
645
+ entries.push({
646
+ callback: runOptions.append as unknown as HookCallback<any, FailArgs>,
647
+ options: {},
648
+ priority: Infinity
649
+ });
650
+ }
651
+
652
+ let result: Awaited<ReturnType<FuncOrNever<Lifecycle[K]>>> | undefined;
653
+ let returned = false;
654
+
655
+ for (const entry of entries) {
656
+
657
+ const { callback, options: opts } = entry;
658
+
659
+ const timesExceeded = this.#checkTimes(callback, opts);
660
+
661
+ if (timesExceeded) {
662
+
663
+ this.#removeEntry(name as string, entry);
664
+ continue;
665
+ }
666
+
667
+ const removeFn = () => this.#removeEntry(name as string, entry);
668
+ const ctx = new HookContext<any, any, FailArgs>(this.#handleFail, String(name), removeFn, scope);
669
+
670
+ if (opts.ignoreOnFail) {
671
+
672
+ const [, err] = await attempt(async () => {
673
+
674
+ const signal = await callback(...currentArgs, ctx);
675
+ this.#processCtx(ctx, signal, (a) => { currentArgs = a; }, (r) => { result = r; }, () => { returned = true; });
676
+ });
677
+
678
+ if (!err && opts.once) removeFn();
679
+
680
+ if (returned) break;
681
+ continue;
682
+ }
683
+
684
+ const signal = await callback(...currentArgs, ctx);
685
+ this.#processCtx(ctx, signal, (a) => { currentArgs = a; }, (r) => { result = r; }, () => { returned = true; });
686
+
687
+ if (opts.once) removeFn();
688
+
689
+ if (returned) break;
690
+ }
691
+
692
+ return { args: currentArgs, result, returned, scope };
693
+ }
694
+
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
+ });
727
+ }
728
+
729
+ let result: Awaited<ReturnType<FuncOrNever<Lifecycle[K]>>> | undefined;
730
+ let returned = false;
731
+
732
+ for (const entry of entries) {
733
+
734
+ const { callback, options: opts } = entry;
735
+
736
+ const timesExceeded = this.#checkTimes(callback, opts);
737
+
738
+ if (timesExceeded) {
739
+
740
+ this.#removeEntry(name as string, entry);
741
+ continue;
742
+ }
743
+
744
+ const removeFn = () => this.#removeEntry(name as string, entry);
745
+ const ctx = new HookContext<any, any, FailArgs>(this.#handleFail, String(name), removeFn, scope);
746
+
747
+ if (opts.ignoreOnFail) {
748
+
749
+ const [, err] = attemptSync(() => {
750
+
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 };
770
+ }
771
+
772
+ /**
773
+ * Wrap an async function with pre/post lifecycle hooks.
774
+ *
775
+ * - Pre hook: called with function args, can modify args or return early
776
+ * - Post hook: called with `(result, ...originalArgs)`, can transform result
777
+ *
778
+ * @param fn - The async function to wrap
779
+ * @param hooks - Object with optional pre and post hook names
780
+ * @returns Wrapped function with same signature
781
+ *
782
+ * @example
783
+ * const wrappedFetch = hooks.wrap(
784
+ * async (url: string, opts: RequestInit) => fetch(url, opts),
785
+ * { pre: 'beforeRequest', post: 'afterRequest' }
786
+ * );
787
+ */
788
+ wrap<F extends (...args: any[]) => Promise<any>>(
789
+ fn: F,
790
+ hooks:
791
+ | { pre: HookName<Lifecycle>; post?: HookName<Lifecycle> }
792
+ | { pre?: HookName<Lifecycle>; post: HookName<Lifecycle> }
793
+ ): (...args: Parameters<F>) => Promise<Awaited<ReturnType<F>>> {
794
+
795
+ assert(
796
+ hooks.pre || hooks.post,
797
+ 'wrap() requires at least one of "pre" or "post" hooks'
798
+ );
799
+
800
+ if (hooks.pre) this.#assertRegistered(hooks.pre, 'wrap');
801
+ if (hooks.post) this.#assertRegistered(hooks.post, 'wrap');
802
+
803
+ return async (...args: Parameters<F>): Promise<Awaited<ReturnType<F>>> => {
804
+
805
+ let currentArgs = args;
806
+
807
+ if (hooks.pre) {
808
+
809
+ const preResult = await this.run(hooks.pre, ...currentArgs as any);
810
+ currentArgs = preResult.args as Parameters<F>;
811
+
812
+ if (preResult.returned && preResult.result !== undefined) {
813
+
814
+ return preResult.result as Awaited<ReturnType<F>>;
815
+ }
816
+ }
817
+
818
+ const result = await fn(...currentArgs);
819
+
820
+ if (hooks.post) {
821
+
822
+ const postResult = await this.run(
823
+ hooks.post,
824
+ ...[result, ...currentArgs] as any
825
+ );
826
+
827
+ if (postResult.returned) {
828
+
829
+ return postResult.result as Awaited<ReturnType<F>>;
830
+ }
831
+ }
832
+
833
+ return result as Awaited<ReturnType<F>>;
834
+ };
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
+ }
1252
+ }