@logosdx/hooks 1.0.0-beta.0 → 1.0.0-beta.2
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 +7 -0
- package/dist/browser/bundle.js +1 -1
- package/dist/browser/bundle.js.map +1 -1
- package/dist/cjs/index.js +415 -305
- package/dist/esm/index.mjs +427 -347
- package/dist/types/index.d.ts +196 -132
- package/package.json +15 -8
- package/readme.md +1 -5
- package/src/index.ts +385 -260
package/src/index.ts
CHANGED
|
@@ -2,25 +2,28 @@ import {
|
|
|
2
2
|
assert,
|
|
3
3
|
AsyncFunc,
|
|
4
4
|
attempt,
|
|
5
|
+
attemptSync,
|
|
6
|
+
FunctionProps,
|
|
5
7
|
isFunction,
|
|
6
|
-
isObject
|
|
7
|
-
FunctionProps
|
|
8
|
+
isObject
|
|
8
9
|
} from '@logosdx/utils';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
* Error thrown when a hook
|
|
12
|
+
* Error thrown when a hook calls `ctx.fail()`.
|
|
13
|
+
*
|
|
14
|
+
* This error is only created when using the default `handleFail` behavior.
|
|
15
|
+
* If a custom `handleFail` is provided, that error type is thrown instead.
|
|
12
16
|
*
|
|
13
17
|
* @example
|
|
14
|
-
*
|
|
18
|
+
* hooks.on('validate', async (ctx) => {
|
|
15
19
|
* if (!ctx.args[0].isValid) {
|
|
16
20
|
* ctx.fail('Validation failed');
|
|
17
21
|
* }
|
|
18
22
|
* });
|
|
19
23
|
*
|
|
20
|
-
* const [, err] = await attempt(() =>
|
|
24
|
+
* const [, err] = await attempt(() => engine.emit('validate', data));
|
|
21
25
|
* if (isHookError(err)) {
|
|
22
|
-
* console.log(err.hookName); // '
|
|
23
|
-
* console.log(err.extPoint); // 'before'
|
|
26
|
+
* console.log(err.hookName); // 'validate'
|
|
24
27
|
* }
|
|
25
28
|
*/
|
|
26
29
|
export class HookError extends Error {
|
|
@@ -28,15 +31,9 @@ export class HookError extends Error {
|
|
|
28
31
|
/** Name of the hook where the error occurred */
|
|
29
32
|
hookName?: string;
|
|
30
33
|
|
|
31
|
-
/** Extension point where the error occurred: 'before', 'after', or 'error' */
|
|
32
|
-
extPoint?: string;
|
|
33
|
-
|
|
34
34
|
/** Original error if `fail()` was called with an Error instance */
|
|
35
35
|
originalError?: Error;
|
|
36
36
|
|
|
37
|
-
/** Whether the hook was explicitly aborted via `fail()` */
|
|
38
|
-
aborted = false;
|
|
39
|
-
|
|
40
37
|
constructor(message: string) {
|
|
41
38
|
|
|
42
39
|
super(message)
|
|
@@ -47,9 +44,9 @@ export class HookError extends Error {
|
|
|
47
44
|
* Type guard to check if an error is a HookError.
|
|
48
45
|
*
|
|
49
46
|
* @example
|
|
50
|
-
* const
|
|
51
|
-
* if (isHookError(
|
|
52
|
-
* console.log(`Hook "${
|
|
47
|
+
* const { error } = await engine.emit('validate', data);
|
|
48
|
+
* if (isHookError(error)) {
|
|
49
|
+
* console.log(`Hook "${error.hookName}" failed`);
|
|
53
50
|
* }
|
|
54
51
|
*/
|
|
55
52
|
export const isHookError = (error: unknown): error is HookError => {
|
|
@@ -57,384 +54,512 @@ export const isHookError = (error: unknown): error is HookError => {
|
|
|
57
54
|
return (error as HookError)?.constructor?.name === HookError.name
|
|
58
55
|
}
|
|
59
56
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Result returned from `emit()` after running all hook callbacks.
|
|
59
|
+
*/
|
|
60
|
+
export interface EmitResult<F extends AsyncFunc> {
|
|
61
|
+
|
|
62
|
+
/** Current arguments (possibly modified by callbacks) */
|
|
63
|
+
args: Parameters<F>;
|
|
64
|
+
|
|
65
|
+
/** Result value (if set by a callback) */
|
|
66
|
+
result?: Awaited<ReturnType<F>> | undefined;
|
|
67
|
+
|
|
68
|
+
/** Whether a callback called `returnEarly()` */
|
|
69
|
+
earlyReturn: boolean;
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
/**
|
|
66
|
-
* Context object passed to hook
|
|
73
|
+
* Context object passed to hook callbacks.
|
|
67
74
|
* Provides access to arguments, results, and control methods.
|
|
68
75
|
*
|
|
69
76
|
* @example
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* const
|
|
77
|
+
* hooks.on('cacheCheck', async (ctx) => {
|
|
78
|
+
* const [url] = ctx.args;
|
|
79
|
+
* const cached = cache.get(url);
|
|
73
80
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* // Or skip the original function entirely
|
|
78
|
-
* if (isCached(url)) {
|
|
79
|
-
* ctx.setResult(getCached(url));
|
|
81
|
+
* if (cached) {
|
|
82
|
+
* ctx.setResult(cached);
|
|
80
83
|
* ctx.returnEarly();
|
|
81
84
|
* }
|
|
82
85
|
* });
|
|
83
86
|
*/
|
|
84
|
-
export interface HookContext<F extends AsyncFunc
|
|
87
|
+
export interface HookContext<F extends AsyncFunc, FailArgs extends unknown[] = [string]> {
|
|
85
88
|
|
|
86
|
-
/** Current
|
|
87
|
-
|
|
89
|
+
/** Current arguments passed to emit() */
|
|
90
|
+
args: Parameters<F>;
|
|
88
91
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
92
|
+
/** Result value (can be set by callbacks) */
|
|
93
|
+
result?: Awaited<ReturnType<F>>;
|
|
91
94
|
|
|
92
|
-
/** Abort hook execution with an error.
|
|
93
|
-
fail: (
|
|
95
|
+
/** Abort hook execution with an error. */
|
|
96
|
+
fail: (...args: FailArgs) => never;
|
|
94
97
|
|
|
95
|
-
/** Replace the arguments
|
|
96
|
-
setArgs: (next: Parameters<F>) => void
|
|
98
|
+
/** Replace the arguments for subsequent callbacks */
|
|
99
|
+
setArgs: (next: Parameters<F>) => void;
|
|
97
100
|
|
|
98
|
-
/**
|
|
99
|
-
setResult: (next: Awaited<ReturnType<F>>) => void
|
|
101
|
+
/** Set the result value */
|
|
102
|
+
setResult: (next: Awaited<ReturnType<F>>) => void;
|
|
100
103
|
|
|
101
|
-
/**
|
|
104
|
+
/** Stop processing remaining callbacks and return early */
|
|
102
105
|
returnEarly: () => void;
|
|
103
106
|
|
|
104
|
-
/** Remove this
|
|
107
|
+
/** Remove this callback from the hook */
|
|
105
108
|
removeHook: () => void;
|
|
106
109
|
}
|
|
107
110
|
|
|
108
|
-
export type HookFn<F extends AsyncFunc
|
|
111
|
+
export type HookFn<F extends AsyncFunc, FailArgs extends unknown[] = [string]> =
|
|
112
|
+
(ctx: HookContext<F, FailArgs>) => Promise<void>;
|
|
109
113
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
type HookOptions<F extends AsyncFunc, FailArgs extends unknown[] = [string]> = {
|
|
115
|
+
callback: HookFn<F, FailArgs>;
|
|
116
|
+
once?: true;
|
|
117
|
+
ignoreOnFail?: true;
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
'after',
|
|
119
|
-
'error'
|
|
120
|
-
]);
|
|
120
|
+
type HookOrOptions<F extends AsyncFunc, FailArgs extends unknown[] = [string]> =
|
|
121
|
+
HookFn<F, FailArgs> | HookOptions<F, FailArgs>;
|
|
121
122
|
|
|
122
|
-
type
|
|
123
|
-
callback: HookFn<F>,
|
|
124
|
-
once?: true,
|
|
125
|
-
ignoreOnFail?: true
|
|
126
|
-
}
|
|
123
|
+
type FuncOrNever<T> = T extends AsyncFunc ? T : never;
|
|
127
124
|
|
|
128
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Custom error handler for `ctx.fail()`.
|
|
127
|
+
* Can be an Error constructor or a function that throws.
|
|
128
|
+
*/
|
|
129
|
+
export type HandleFail<Args extends unknown[] = [string]> =
|
|
130
|
+
| (new (...args: Args) => Error)
|
|
131
|
+
| ((...args: Args) => never);
|
|
129
132
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Options for HookEngine constructor.
|
|
135
|
+
*/
|
|
136
|
+
export interface HookEngineOptions<FailArgs extends unknown[] = [string]> {
|
|
133
137
|
|
|
134
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Custom handler for `ctx.fail()`.
|
|
140
|
+
* Can be an Error constructor or a function that throws.
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* // Use Firebase HttpsError
|
|
144
|
+
* new HookEngine({ handleFail: HttpsError });
|
|
145
|
+
*
|
|
146
|
+
* // Use custom function
|
|
147
|
+
* new HookEngine({
|
|
148
|
+
* handleFail: (msg, data) => { throw Boom.badRequest(msg, data); }
|
|
149
|
+
* });
|
|
150
|
+
*/
|
|
151
|
+
handleFail?: HandleFail<FailArgs>;
|
|
152
|
+
}
|
|
135
153
|
|
|
136
154
|
/**
|
|
137
|
-
* A lightweight, type-safe hook system
|
|
155
|
+
* A lightweight, type-safe lifecycle hook system.
|
|
138
156
|
*
|
|
139
|
-
* HookEngine allows you to
|
|
140
|
-
*
|
|
141
|
-
* results, or abort execution entirely.
|
|
157
|
+
* HookEngine allows you to define lifecycle events and subscribe to them.
|
|
158
|
+
* Callbacks can modify arguments, set results, or abort execution.
|
|
142
159
|
*
|
|
143
160
|
* @example
|
|
144
|
-
* interface
|
|
145
|
-
*
|
|
146
|
-
*
|
|
161
|
+
* interface FetchLifecycle {
|
|
162
|
+
* preRequest(url: string, options: RequestInit): Promise<Response>;
|
|
163
|
+
* rateLimit(error: Error, attempt: number): Promise<void>;
|
|
164
|
+
* cacheHit(url: string, data: unknown): Promise<unknown>;
|
|
147
165
|
* }
|
|
148
166
|
*
|
|
149
|
-
* const
|
|
150
|
-
* const hooks = new HookEngine<MyApp>();
|
|
167
|
+
* const hooks = new HookEngine<FetchLifecycle>();
|
|
151
168
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* hooks.extend('save', 'before', async (ctx) => {
|
|
157
|
-
* if (!ctx.args[0].isValid) {
|
|
158
|
-
* ctx.fail('Validation failed');
|
|
159
|
-
* }
|
|
169
|
+
* hooks.on('rateLimit', async (ctx) => {
|
|
170
|
+
* const [error, attempt] = ctx.args;
|
|
171
|
+
* if (attempt > 3) ctx.fail('Max retries exceeded');
|
|
172
|
+
* await sleep(error.retryAfter * 1000);
|
|
160
173
|
* });
|
|
161
174
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* console.log('Saved:', ctx.results);
|
|
175
|
+
* hooks.on('cacheHit', async (ctx) => {
|
|
176
|
+
* console.log('Cache hit for:', ctx.args[0]);
|
|
165
177
|
* });
|
|
166
178
|
*
|
|
167
|
-
*
|
|
179
|
+
* // In your implementation
|
|
180
|
+
* const result = await hooks.emit('cacheHit', url, cachedData);
|
|
181
|
+
*
|
|
182
|
+
* @typeParam Lifecycle - Interface defining the lifecycle hooks
|
|
183
|
+
* @typeParam FailArgs - Arguments type for ctx.fail() (default: [string])
|
|
184
|
+
*/
|
|
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')
|
|
168
202
|
*/
|
|
169
|
-
export
|
|
203
|
+
export type HookName<T> = FunctionProps<T>;
|
|
204
|
+
|
|
205
|
+
export class HookEngine<Lifecycle = DefaultLifecycle, FailArgs extends unknown[] = [string]> {
|
|
206
|
+
|
|
207
|
+
#hooks: Map<HookName<Lifecycle>, Set<HookFn<FuncOrNever<Lifecycle[HookName<Lifecycle>]>, FailArgs>>> = new Map();
|
|
208
|
+
#hookOpts = new WeakMap<HookFn<any, any>, HookOptions<any, any>>();
|
|
209
|
+
#handleFail: HandleFail<FailArgs>;
|
|
210
|
+
#registered: Set<HookName<Lifecycle>> | null = null;
|
|
211
|
+
|
|
212
|
+
constructor(options: HookEngineOptions<FailArgs> = {}) {
|
|
213
|
+
|
|
214
|
+
this.#handleFail = options.handleFail ?? ((message: string): never => {
|
|
215
|
+
|
|
216
|
+
throw new HookError(message);
|
|
217
|
+
}) as unknown as HandleFail<FailArgs>;
|
|
218
|
+
}
|
|
170
219
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
#
|
|
220
|
+
/**
|
|
221
|
+
* Validate that a hook is registered (if registration is enabled).
|
|
222
|
+
*/
|
|
223
|
+
#assertRegistered(name: HookName<Lifecycle>, method: string) {
|
|
224
|
+
|
|
225
|
+
if (this.#registered !== null && !this.#registered.has(name)) {
|
|
226
|
+
|
|
227
|
+
const registered = [...this.#registered].map(String).join(', ');
|
|
228
|
+
throw new Error(
|
|
229
|
+
`Hook "${String(name)}" is not registered. ` +
|
|
230
|
+
`Call register("${String(name)}") before using ${method}(). ` +
|
|
231
|
+
`Registered hooks: ${registered || '(none)'}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
175
235
|
|
|
176
236
|
/**
|
|
177
|
-
*
|
|
237
|
+
* Register hook names for runtime validation.
|
|
238
|
+
* Once any hooks are registered, all hooks must be registered before use.
|
|
178
239
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* - `after`: Runs after successful execution. Can modify the result.
|
|
182
|
-
* - `error`: Runs when the original function throws. Can handle or transform errors.
|
|
240
|
+
* @param names - Hook names to register
|
|
241
|
+
* @returns this (for chaining)
|
|
183
242
|
*
|
|
184
|
-
* @
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
243
|
+
* @example
|
|
244
|
+
* const hooks = new HookEngine<FetchLifecycle>()
|
|
245
|
+
* .register('preRequest', 'postRequest', 'rateLimit');
|
|
246
|
+
*
|
|
247
|
+
* hooks.on('preRequest', cb); // OK
|
|
248
|
+
* hooks.on('preRequset', cb); // Error: not registered (typo caught!)
|
|
249
|
+
*/
|
|
250
|
+
register(...names: HookName<Lifecycle>[]) {
|
|
251
|
+
|
|
252
|
+
assert(names.length > 0, 'register() requires at least one hook name');
|
|
253
|
+
|
|
254
|
+
if (this.#registered === null) {
|
|
255
|
+
|
|
256
|
+
this.#registered = new Set();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const name of names) {
|
|
260
|
+
|
|
261
|
+
assert(typeof name === 'string', `Hook name must be a string, got ${typeof name}`);
|
|
262
|
+
this.#registered.add(name);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return this;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Subscribe to a lifecycle hook.
|
|
270
|
+
*
|
|
271
|
+
* @param name - Name of the lifecycle hook
|
|
272
|
+
* @param cbOrOpts - Callback function or options object
|
|
273
|
+
* @returns Cleanup function to remove the subscription
|
|
188
274
|
*
|
|
189
275
|
* @example
|
|
190
276
|
* // Simple callback
|
|
191
|
-
* const cleanup = hooks.
|
|
192
|
-
* console.log('
|
|
277
|
+
* const cleanup = hooks.on('preRequest', async (ctx) => {
|
|
278
|
+
* console.log('Request:', ctx.args[0]);
|
|
193
279
|
* });
|
|
194
280
|
*
|
|
195
281
|
* // With options
|
|
196
|
-
* hooks.
|
|
197
|
-
* callback: async (ctx) => {
|
|
282
|
+
* hooks.on('analytics', {
|
|
283
|
+
* callback: async (ctx) => { track(ctx.args); },
|
|
198
284
|
* once: true, // Remove after first run
|
|
199
|
-
* ignoreOnFail: true // Don't throw if
|
|
285
|
+
* ignoreOnFail: true // Don't throw if callback fails
|
|
200
286
|
* });
|
|
201
287
|
*
|
|
202
|
-
* //
|
|
288
|
+
* // Remove subscription
|
|
203
289
|
* cleanup();
|
|
204
290
|
*/
|
|
205
|
-
|
|
291
|
+
on<K extends HookName<Lifecycle>>(
|
|
206
292
|
name: K,
|
|
207
|
-
|
|
208
|
-
cbOrOpts: HookExtOrOptions<FuncOrNever<Shape[K]>>
|
|
293
|
+
cbOrOpts: HookOrOptions<FuncOrNever<Lifecycle[K]>, FailArgs>
|
|
209
294
|
) {
|
|
295
|
+
|
|
210
296
|
const callback = typeof cbOrOpts === 'function' ? cbOrOpts : cbOrOpts?.callback;
|
|
211
|
-
const opts = typeof cbOrOpts === 'function'
|
|
297
|
+
const opts = typeof cbOrOpts === 'function'
|
|
298
|
+
? {} as HookOptions<FuncOrNever<Lifecycle[K]>, FailArgs>
|
|
299
|
+
: cbOrOpts;
|
|
212
300
|
|
|
213
301
|
assert(typeof name === 'string', '"name" must be a string');
|
|
214
|
-
assert(
|
|
215
|
-
assert(typeof extensionPoint === 'string', '"extensionPoint" must be a string');
|
|
216
|
-
assert(allowedExtPoints.has(extensionPoint), `'${extensionPoint}' is not a valid extension point`);
|
|
217
|
-
assert(isFunction(callback) || isObject(cbOrOpts), '"cbOrOpts" must be a extension callback or options');
|
|
302
|
+
assert(isFunction(callback) || isObject(cbOrOpts), '"cbOrOpts" must be a callback or options');
|
|
218
303
|
assert(isFunction(callback), 'callback must be a function');
|
|
219
304
|
|
|
220
|
-
|
|
305
|
+
this.#assertRegistered(name, 'on');
|
|
306
|
+
|
|
307
|
+
const hooks = this.#hooks.get(name) ?? new Set();
|
|
221
308
|
|
|
222
|
-
|
|
309
|
+
hooks.add(callback as HookFn<FuncOrNever<Lifecycle[keyof Lifecycle]>, FailArgs>);
|
|
223
310
|
|
|
224
|
-
this.#hooks.set(name,
|
|
225
|
-
this.#
|
|
311
|
+
this.#hooks.set(name, hooks);
|
|
312
|
+
this.#hookOpts.set(callback, opts);
|
|
226
313
|
|
|
227
|
-
/**
|
|
228
|
-
* Removes the registered hook extension
|
|
229
|
-
*/
|
|
230
314
|
return () => {
|
|
231
315
|
|
|
232
|
-
|
|
316
|
+
hooks.delete(callback as HookFn<FuncOrNever<Lifecycle[keyof Lifecycle]>, FailArgs>);
|
|
233
317
|
}
|
|
234
318
|
}
|
|
235
319
|
|
|
236
320
|
/**
|
|
237
|
-
*
|
|
321
|
+
* Subscribe to a lifecycle hook that fires only once.
|
|
322
|
+
* Sugar for `on(name, { callback, once: true })`.
|
|
238
323
|
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
* @param name - Unique name for this hook (must match a key in Shape)
|
|
244
|
-
* @param cb - The original function to wrap
|
|
245
|
-
* @param opts - Options for the wrapped function
|
|
246
|
-
* @returns Wrapped function with hook support
|
|
324
|
+
* @param name - Name of the lifecycle hook
|
|
325
|
+
* @param callback - Callback function
|
|
326
|
+
* @returns Cleanup function to remove the subscription
|
|
247
327
|
*
|
|
248
328
|
* @example
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* hooks.extend('fetch', 'before', async (ctx) => {
|
|
254
|
-
* console.log('Fetching:', ctx.args[0]);
|
|
329
|
+
* // Log only the first request
|
|
330
|
+
* hooks.once('preRequest', async (ctx) => {
|
|
331
|
+
* console.log('First request:', ctx.args[0]);
|
|
255
332
|
* });
|
|
256
|
-
*
|
|
257
|
-
* await hookedFetch('/api/data');
|
|
258
333
|
*/
|
|
259
|
-
|
|
334
|
+
once<K extends HookName<Lifecycle>>(
|
|
260
335
|
name: K,
|
|
261
|
-
|
|
262
|
-
opts: MakeHookOptions = {}
|
|
336
|
+
callback: HookFn<FuncOrNever<Lifecycle[K]>, FailArgs>
|
|
263
337
|
) {
|
|
264
338
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
assert(isFunction(cb), '"cb" must be a function');
|
|
268
|
-
assert(isObject(opts), '"opts" must be an object');
|
|
269
|
-
|
|
270
|
-
this.#registered.add(name);
|
|
339
|
+
return this.on(name, { callback, once: true });
|
|
340
|
+
}
|
|
271
341
|
|
|
272
|
-
|
|
342
|
+
/**
|
|
343
|
+
* Emit a lifecycle hook, running all subscribed callbacks.
|
|
344
|
+
*
|
|
345
|
+
* @param name - Name of the lifecycle hook to emit
|
|
346
|
+
* @param args - Arguments to pass to callbacks
|
|
347
|
+
* @returns EmitResult with final args, result, and earlyReturn flag
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* const result = await hooks.emit('cacheCheck', url);
|
|
351
|
+
*
|
|
352
|
+
* if (result.earlyReturn && result.result) {
|
|
353
|
+
* return result.result; // Use cached value
|
|
354
|
+
* }
|
|
355
|
+
*
|
|
356
|
+
* // Continue with modified args
|
|
357
|
+
* const [modifiedUrl] = result.args;
|
|
358
|
+
*/
|
|
359
|
+
async emit<K extends HookName<Lifecycle>>(
|
|
360
|
+
name: K,
|
|
361
|
+
...args: Parameters<FuncOrNever<Lifecycle[K]>>
|
|
362
|
+
): Promise<EmitResult<FuncOrNever<Lifecycle[K]>>> {
|
|
273
363
|
|
|
274
|
-
|
|
275
|
-
}
|
|
364
|
+
this.#assertRegistered(name, 'emit');
|
|
276
365
|
|
|
277
|
-
|
|
366
|
+
let earlyReturn = false;
|
|
278
367
|
|
|
279
|
-
|
|
368
|
+
const hooks = this.#hooks.get(name);
|
|
280
369
|
|
|
281
|
-
|
|
370
|
+
const context: HookContext<FuncOrNever<Lifecycle[K]>, FailArgs> = {
|
|
371
|
+
args,
|
|
372
|
+
removeHook() {},
|
|
373
|
+
returnEarly() {
|
|
282
374
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
removeHook() {},
|
|
287
|
-
returnEarly() {
|
|
288
|
-
returnEarly = true;
|
|
289
|
-
},
|
|
290
|
-
setArgs(next) {
|
|
375
|
+
earlyReturn = true;
|
|
376
|
+
},
|
|
377
|
+
setArgs: (next) => {
|
|
291
378
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
379
|
+
assert(
|
|
380
|
+
Array.isArray(next),
|
|
381
|
+
`setArgs: args for '${String(name)}' must be an array`
|
|
382
|
+
);
|
|
296
383
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
context.results = next;
|
|
301
|
-
},
|
|
302
|
-
fail(reason) {
|
|
384
|
+
context.args = next;
|
|
385
|
+
},
|
|
386
|
+
setResult: (next) => {
|
|
303
387
|
|
|
304
|
-
|
|
388
|
+
context.result = next;
|
|
389
|
+
},
|
|
390
|
+
fail: ((...failArgs: FailArgs) => {
|
|
305
391
|
|
|
306
|
-
|
|
392
|
+
const handler = this.#handleFail;
|
|
307
393
|
|
|
308
|
-
|
|
309
|
-
|
|
394
|
+
// Check if handler is a constructor (class or function with prototype)
|
|
395
|
+
const isConstructor = typeof handler === 'function' &&
|
|
396
|
+
handler.prototype?.constructor === handler;
|
|
310
397
|
|
|
311
|
-
|
|
312
|
-
error.hookName = name as string;
|
|
398
|
+
const [, error] = attemptSync(() => {
|
|
313
399
|
|
|
314
|
-
|
|
315
|
-
},
|
|
316
|
-
}
|
|
400
|
+
if (isConstructor) {
|
|
317
401
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const handleSet = async (
|
|
321
|
-
which: typeof before,
|
|
322
|
-
point: keyof typeof hook
|
|
323
|
-
) => {
|
|
324
|
-
|
|
325
|
-
context.point = point;
|
|
326
|
-
|
|
327
|
-
for (const fn of which) {
|
|
402
|
+
throw new (handler as new (...args: FailArgs) => Error)(...failArgs);
|
|
403
|
+
}
|
|
328
404
|
|
|
329
|
-
|
|
405
|
+
(handler as (...args: FailArgs) => never)(...failArgs);
|
|
406
|
+
});
|
|
330
407
|
|
|
331
|
-
|
|
332
|
-
const [, err] = await attempt(() => fn({ ...context }));
|
|
408
|
+
if (error) {
|
|
333
409
|
|
|
334
|
-
if (
|
|
410
|
+
if (error instanceof HookError) {
|
|
335
411
|
|
|
336
|
-
|
|
337
|
-
throw err;
|
|
412
|
+
error.hookName = String(name);
|
|
338
413
|
}
|
|
339
414
|
|
|
340
|
-
|
|
415
|
+
throw error;
|
|
341
416
|
}
|
|
342
|
-
}
|
|
343
417
|
|
|
344
|
-
|
|
418
|
+
// If handler didn't throw, we need to throw something
|
|
419
|
+
throw new HookError('ctx.fail() handler did not throw');
|
|
420
|
+
}) as (...args: FailArgs) => never
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
if (!hooks || hooks.size === 0) {
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
args: context.args,
|
|
427
|
+
result: context.result,
|
|
428
|
+
earlyReturn: false
|
|
429
|
+
};
|
|
430
|
+
}
|
|
345
431
|
|
|
346
|
-
|
|
432
|
+
for (const fn of hooks) {
|
|
347
433
|
|
|
348
|
-
|
|
434
|
+
context.removeHook = () => hooks.delete(fn as any);
|
|
349
435
|
|
|
350
|
-
|
|
351
|
-
|
|
436
|
+
const opts: HookOptions<any, any> = this.#hookOpts.get(fn) ?? { callback: fn };
|
|
437
|
+
const [, err] = await attempt(() => fn({ ...context } as any));
|
|
352
438
|
|
|
353
|
-
if (
|
|
354
|
-
context.point = 'error';
|
|
439
|
+
if (opts.once) context.removeHook();
|
|
355
440
|
|
|
356
|
-
|
|
441
|
+
if (err && opts.ignoreOnFail !== true) {
|
|
357
442
|
|
|
358
443
|
throw err;
|
|
359
444
|
}
|
|
360
445
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
return context.results!;
|
|
446
|
+
if (earlyReturn) break;
|
|
364
447
|
}
|
|
365
448
|
|
|
366
|
-
return
|
|
449
|
+
return {
|
|
450
|
+
args: context.args,
|
|
451
|
+
result: context.result,
|
|
452
|
+
earlyReturn
|
|
453
|
+
};
|
|
367
454
|
}
|
|
368
455
|
|
|
369
456
|
/**
|
|
370
|
-
*
|
|
457
|
+
* Clear all registered hooks.
|
|
371
458
|
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
459
|
+
* @example
|
|
460
|
+
* hooks.on('preRequest', validator);
|
|
461
|
+
* hooks.on('postRequest', logger);
|
|
375
462
|
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
|
|
463
|
+
* // Reset for testing
|
|
464
|
+
* hooks.clear();
|
|
465
|
+
*/
|
|
466
|
+
clear() {
|
|
467
|
+
|
|
468
|
+
this.#hooks.clear();
|
|
469
|
+
this.#hookOpts = new WeakMap();
|
|
470
|
+
this.#registered = null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Wrap a function with pre/post lifecycle hooks.
|
|
475
|
+
*
|
|
476
|
+
* - Pre hook: emitted with function args, can modify args or returnEarly with result
|
|
477
|
+
* - Post hook: emitted with [result, ...args], can modify result
|
|
478
|
+
*
|
|
479
|
+
* @param fn - The async function to wrap
|
|
480
|
+
* @param hooks - Object with optional pre and post hook names
|
|
481
|
+
* @returns Wrapped function with same signature
|
|
379
482
|
*
|
|
380
483
|
* @example
|
|
381
|
-
*
|
|
382
|
-
*
|
|
484
|
+
* interface Lifecycle {
|
|
485
|
+
* preRequest(url: string, opts: RequestInit): Promise<Response>;
|
|
486
|
+
* postRequest(result: Response, url: string, opts: RequestInit): Promise<Response>;
|
|
383
487
|
* }
|
|
384
488
|
*
|
|
385
|
-
* const
|
|
386
|
-
* const hooks = new HookEngine<UserService>();
|
|
489
|
+
* const hooks = new HookEngine<Lifecycle>();
|
|
387
490
|
*
|
|
388
|
-
*
|
|
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
|
+
* });
|
|
389
499
|
*
|
|
390
|
-
* //
|
|
391
|
-
* hooks.
|
|
392
|
-
*
|
|
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);
|
|
393
504
|
* });
|
|
505
|
+
*
|
|
506
|
+
* // Wrap the fetch function
|
|
507
|
+
* const wrappedFetch = hooks.wrap(
|
|
508
|
+
* async (url: string, opts: RequestInit) => fetch(url, opts),
|
|
509
|
+
* { pre: 'preRequest', post: 'postRequest' }
|
|
510
|
+
* );
|
|
394
511
|
*/
|
|
395
|
-
wrap<
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
512
|
+
wrap<F extends AsyncFunc>(
|
|
513
|
+
fn: F,
|
|
514
|
+
hooks:
|
|
515
|
+
| { pre: HookName<Lifecycle>; post?: HookName<Lifecycle> }
|
|
516
|
+
| { pre?: HookName<Lifecycle>; post: HookName<Lifecycle> }
|
|
517
|
+
): (...args: Parameters<F>) => Promise<Awaited<ReturnType<F>>> {
|
|
518
|
+
|
|
519
|
+
assert(
|
|
520
|
+
hooks.pre || hooks.post,
|
|
521
|
+
'wrap() requires at least one of "pre" or "post" hooks'
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
if (hooks.pre) this.#assertRegistered(hooks.pre, 'wrap');
|
|
525
|
+
if (hooks.post) this.#assertRegistered(hooks.post, 'wrap');
|
|
526
|
+
|
|
527
|
+
return async (...args: Parameters<F>): Promise<Awaited<ReturnType<F>>> => {
|
|
528
|
+
|
|
529
|
+
let currentArgs = args;
|
|
530
|
+
let result: Awaited<ReturnType<F>> | undefined;
|
|
400
531
|
|
|
401
|
-
|
|
532
|
+
// Pre hook
|
|
533
|
+
if (hooks.pre) {
|
|
402
534
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
535
|
+
const preResult = await this.emit(hooks.pre, ...currentArgs as any);
|
|
536
|
+
|
|
537
|
+
currentArgs = preResult.args as Parameters<F>;
|
|
538
|
+
|
|
539
|
+
if (preResult.earlyReturn && preResult.result !== undefined) {
|
|
540
|
+
|
|
541
|
+
return preResult.result as Awaited<ReturnType<F>>;
|
|
542
|
+
}
|
|
409
543
|
}
|
|
410
|
-
);
|
|
411
544
|
|
|
412
|
-
|
|
545
|
+
// Execute function
|
|
546
|
+
result = await fn(...currentArgs);
|
|
413
547
|
|
|
414
|
-
|
|
548
|
+
// Post hook
|
|
549
|
+
if (hooks.post) {
|
|
415
550
|
|
|
416
|
-
|
|
551
|
+
const postResult = await this.emit(
|
|
552
|
+
hooks.post,
|
|
553
|
+
...[result, ...currentArgs] as any
|
|
554
|
+
);
|
|
417
555
|
|
|
418
|
-
|
|
419
|
-
* Clear all registered hooks and extensions.
|
|
420
|
-
*
|
|
421
|
-
* After calling this method, all hooks are unregistered and all
|
|
422
|
-
* extensions are removed. Previously wrapped functions will continue
|
|
423
|
-
* to work but without any extensions.
|
|
424
|
-
*
|
|
425
|
-
* @example
|
|
426
|
-
* hooks.wrap(app, 'save');
|
|
427
|
-
* hooks.extend('save', 'before', validator);
|
|
428
|
-
*
|
|
429
|
-
* // Reset for testing
|
|
430
|
-
* hooks.clear();
|
|
431
|
-
*
|
|
432
|
-
* // app.save() still works, but validator no longer runs
|
|
433
|
-
*/
|
|
434
|
-
clear() {
|
|
556
|
+
if (postResult.result !== undefined) {
|
|
435
557
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
558
|
+
return postResult.result as Awaited<ReturnType<F>>;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return result as Awaited<ReturnType<F>>;
|
|
563
|
+
};
|
|
439
564
|
}
|
|
440
|
-
}
|
|
565
|
+
}
|