@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/CHANGELOG.md +92 -0
- package/LICENSE +1 -1
- package/dist/browser/bundle.js +2 -0
- package/dist/browser/bundle.js.map +1 -0
- package/dist/cjs/index.js +1618 -0
- package/dist/esm/index.mjs +1685 -0
- package/dist/types/index.d.ts +483 -0
- package/package.json +26 -23
- package/readme.md +84 -0
- package/src/index.ts +1252 -0
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
|
+
}
|