@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/CHANGELOG.md +140 -0
- package/dist/browser/bundle.js +1 -1
- package/dist/browser/bundle.js.map +1 -1
- package/dist/cjs/index.js +904 -192
- package/dist/esm/index.mjs +977 -202
- package/dist/types/index.d.ts +361 -170
- package/package.json +2 -2
- package/readme.md +3 -3
- package/src/index.ts +972 -285
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
|
-
*
|
|
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.
|
|
19
|
-
* if (!
|
|
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.
|
|
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
|
|
48
|
-
* if (isHookError(
|
|
49
|
-
* console.log(`Hook "${
|
|
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
|
-
*
|
|
55
|
+
* Custom error handler for `ctx.fail()`.
|
|
56
|
+
* Can be an Error constructor or a function that throws.
|
|
59
57
|
*/
|
|
60
|
-
export
|
|
58
|
+
export type HandleFail<Args extends unknown[] = [string]> =
|
|
59
|
+
| (new (...args: Args) => Error)
|
|
60
|
+
| ((...args: Args) => never);
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
/**
|
|
66
|
-
|
|
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
|
-
/**
|
|
69
|
-
|
|
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
|
-
*
|
|
74
|
-
* Provides access to arguments, results, and control methods.
|
|
124
|
+
* Extract only function property keys from a type.
|
|
75
125
|
*
|
|
76
126
|
* @example
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
192
|
+
this.#handleFail = handleFail;
|
|
193
|
+
this.#hookName = hookName;
|
|
194
|
+
this.#removeFn = removeFn;
|
|
195
|
+
this.scope = scope;
|
|
196
|
+
}
|
|
94
197
|
|
|
95
|
-
/**
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
211
|
+
this.#args = args;
|
|
212
|
+
this.#argsChanged = true;
|
|
213
|
+
return EARLY_RETURN;
|
|
214
|
+
}
|
|
100
215
|
|
|
101
|
-
/**
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
225
|
+
this.#result = value;
|
|
226
|
+
this.#earlyReturn = true;
|
|
227
|
+
return EARLY_RETURN;
|
|
228
|
+
}
|
|
106
229
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
(ctx: HookContext<F, FailArgs>) => Promise<void>;
|
|
239
|
+
const handler = this.#handleFail;
|
|
113
240
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
once?: true;
|
|
117
|
-
ignoreOnFail?: true;
|
|
118
|
-
}
|
|
241
|
+
const isConstructor = typeof handler === 'function' &&
|
|
242
|
+
handler.prototype?.constructor === handler;
|
|
119
243
|
|
|
120
|
-
|
|
121
|
-
HookFn<F, FailArgs> | HookOptions<F, FailArgs>;
|
|
244
|
+
const [, error] = attemptSync(() => {
|
|
122
245
|
|
|
123
|
-
|
|
246
|
+
if (isConstructor) {
|
|
124
247
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
144
|
-
*
|
|
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
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* handleFail: (msg, data) => { throw Boom.badRequest(msg, data); }
|
|
149
|
-
* });
|
|
353
|
+
* @example
|
|
354
|
+
* ctx.fail('Rate limit exceeded');
|
|
150
355
|
*/
|
|
151
|
-
|
|
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
|
|
452
|
+
* Callbacks receive spread arguments with a context object as the last param.
|
|
159
453
|
*
|
|
160
454
|
* @example
|
|
161
455
|
* interface FetchLifecycle {
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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.
|
|
170
|
-
*
|
|
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.
|
|
176
|
-
*
|
|
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
|
-
*
|
|
180
|
-
*
|
|
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<
|
|
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:
|
|
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 (
|
|
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('
|
|
518
|
+
* .register('beforeRequest', 'afterRequest');
|
|
246
519
|
*
|
|
247
|
-
* hooks.
|
|
248
|
-
* hooks.
|
|
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
|
|
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.
|
|
278
|
-
* console.log('Request:',
|
|
551
|
+
* const cleanup = hooks.add('beforeRequest', (url, opts, ctx) => {
|
|
552
|
+
* console.log('Request:', url);
|
|
279
553
|
* });
|
|
280
554
|
*
|
|
281
555
|
* // With options
|
|
282
|
-
* hooks.
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
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
|
-
|
|
566
|
+
add<K extends HookName<Lifecycle>>(
|
|
292
567
|
name: K,
|
|
293
|
-
|
|
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)
|
|
303
|
-
assert(isFunction(callback), 'callback must be a function');
|
|
573
|
+
assert(isFunction(callback), '"callback" must be a function');
|
|
304
574
|
|
|
305
|
-
this.#assertRegistered(name, '
|
|
575
|
+
this.#assertRegistered(name, 'add');
|
|
306
576
|
|
|
307
|
-
const
|
|
577
|
+
const priority = options.priority ?? 0;
|
|
578
|
+
const entry: HookEntry<any, FailArgs> = { callback, options, priority };
|
|
308
579
|
|
|
309
|
-
hooks.
|
|
580
|
+
const hooks = this.#hooks.get(name as string) ?? [];
|
|
310
581
|
|
|
311
|
-
|
|
312
|
-
this.#hookOpts.set(callback, opts);
|
|
582
|
+
let inserted = false;
|
|
313
583
|
|
|
314
|
-
|
|
584
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
315
585
|
|
|
316
|
-
hooks
|
|
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
|
-
|
|
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
|
-
*
|
|
618
|
+
* Run all callbacks for a hook asynchronously.
|
|
344
619
|
*
|
|
345
|
-
* @param name - Name of the lifecycle hook to
|
|
346
|
-
* @param args - Arguments to pass to callbacks
|
|
347
|
-
* @returns
|
|
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
|
|
351
|
-
*
|
|
352
|
-
*
|
|
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
|
|
629
|
+
async run<K extends HookName<Lifecycle>>(
|
|
360
630
|
name: K,
|
|
361
|
-
...args: Parameters<FuncOrNever<Lifecycle[K]>>
|
|
362
|
-
): Promise<
|
|
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, '
|
|
634
|
+
this.#assertRegistered(name, 'run');
|
|
365
635
|
|
|
366
|
-
|
|
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
|
-
|
|
371
|
-
args,
|
|
372
|
-
removeHook() {},
|
|
373
|
-
returnEarly() {
|
|
643
|
+
if (runOptions?.append) {
|
|
374
644
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
645
|
+
entries.push({
|
|
646
|
+
callback: runOptions.append as unknown as HookCallback<any, FailArgs>,
|
|
647
|
+
options: {},
|
|
648
|
+
priority: Infinity
|
|
649
|
+
});
|
|
650
|
+
}
|
|
378
651
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
652
|
+
let result: Awaited<ReturnType<FuncOrNever<Lifecycle[K]>>> | undefined;
|
|
653
|
+
let returned = false;
|
|
654
|
+
|
|
655
|
+
for (const entry of entries) {
|
|
383
656
|
|
|
384
|
-
|
|
385
|
-
},
|
|
386
|
-
setResult: (next) => {
|
|
657
|
+
const { callback, options: opts } = entry;
|
|
387
658
|
|
|
388
|
-
|
|
389
|
-
},
|
|
390
|
-
fail: ((...failArgs: FailArgs) => {
|
|
659
|
+
const timesExceeded = this.#checkTimes(callback, opts);
|
|
391
660
|
|
|
392
|
-
|
|
661
|
+
if (timesExceeded) {
|
|
393
662
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
663
|
+
this.#removeEntry(name as string, entry);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
397
666
|
|
|
398
|
-
|
|
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
|
-
|
|
670
|
+
if (opts.ignoreOnFail) {
|
|
401
671
|
|
|
402
|
-
|
|
403
|
-
}
|
|
672
|
+
const [, err] = await attempt(async () => {
|
|
404
673
|
|
|
405
|
-
|
|
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 (
|
|
678
|
+
if (!err && opts.once) removeFn();
|
|
409
679
|
|
|
410
|
-
|
|
680
|
+
if (returned) break;
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
411
683
|
|
|
412
|
-
|
|
413
|
-
|
|
684
|
+
const signal = await callback(...currentArgs, ctx);
|
|
685
|
+
this.#processCtx(ctx, signal, (a) => { currentArgs = a; }, (r) => { result = r; }, () => { returned = true; });
|
|
414
686
|
|
|
415
|
-
|
|
416
|
-
}
|
|
687
|
+
if (opts.once) removeFn();
|
|
417
688
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}) as (...args: FailArgs) => never
|
|
421
|
-
};
|
|
689
|
+
if (returned) break;
|
|
690
|
+
}
|
|
422
691
|
|
|
423
|
-
|
|
692
|
+
return { args: currentArgs, result, returned, scope };
|
|
693
|
+
}
|
|
424
694
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
729
|
+
let result: Awaited<ReturnType<FuncOrNever<Lifecycle[K]>>> | undefined;
|
|
730
|
+
let returned = false;
|
|
433
731
|
|
|
434
|
-
|
|
732
|
+
for (const entry of entries) {
|
|
435
733
|
|
|
436
|
-
const
|
|
437
|
-
const [, err] = await attempt(() => fn({ ...context } as any));
|
|
734
|
+
const { callback, options: opts } = entry;
|
|
438
735
|
|
|
439
|
-
|
|
736
|
+
const timesExceeded = this.#checkTimes(callback, opts);
|
|
440
737
|
|
|
441
|
-
if (
|
|
738
|
+
if (timesExceeded) {
|
|
442
739
|
|
|
443
|
-
|
|
740
|
+
this.#removeEntry(name as string, entry);
|
|
741
|
+
continue;
|
|
444
742
|
}
|
|
445
743
|
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
773
|
+
* Wrap an async function with pre/post lifecycle hooks.
|
|
475
774
|
*
|
|
476
|
-
* - Pre hook:
|
|
477
|
-
* - Post hook:
|
|
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: '
|
|
785
|
+
* { pre: 'beforeRequest', post: 'afterRequest' }
|
|
510
786
|
* );
|
|
511
787
|
*/
|
|
512
|
-
wrap<F extends
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
822
|
+
const postResult = await this.run(
|
|
552
823
|
hooks.post,
|
|
553
824
|
...[result, ...currentArgs] as any
|
|
554
825
|
);
|
|
555
826
|
|
|
556
|
-
if (postResult.
|
|
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
|
}
|