@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/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 extension calls `fail()` or when hook execution fails.
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
- * engine.extend('save', 'before', async (ctx) => {
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(() => app.save(data));
24
+ * const [, err] = await attempt(() => engine.emit('validate', data));
21
25
  * if (isHookError(err)) {
22
- * console.log(err.hookName); // 'save'
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 [result, err] = await attempt(() => app.save(data));
51
- * if (isHookError(err)) {
52
- * console.log(`Hook "${err.hookName}" failed at "${err.extPoint}"`);
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
- interface HookShape<F extends AsyncFunc> {
61
- args: Parameters<F>,
62
- results?: Awaited<ReturnType<F>>
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 extension callbacks.
73
+ * Context object passed to hook callbacks.
67
74
  * Provides access to arguments, results, and control methods.
68
75
  *
69
76
  * @example
70
- * engine.extend('fetch', 'before', async (ctx) => {
71
- * // Read current arguments
72
- * const [url, options] = ctx.args;
77
+ * hooks.on('cacheCheck', async (ctx) => {
78
+ * const [url] = ctx.args;
79
+ * const cached = cache.get(url);
73
80
  *
74
- * // Modify arguments before the original function runs
75
- * ctx.setArgs([url, { ...options, cache: 'force-cache' }]);
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> extends HookShape<F> {
87
+ export interface HookContext<F extends AsyncFunc, FailArgs extends unknown[] = [string]> {
85
88
 
86
- /** Current extension point: 'before', 'after', or 'error' */
87
- point: keyof Hook<F>;
89
+ /** Current arguments passed to emit() */
90
+ args: Parameters<F>;
88
91
 
89
- /** Error from the original function (only set in 'error' extensions) */
90
- error?: unknown,
92
+ /** Result value (can be set by callbacks) */
93
+ result?: Awaited<ReturnType<F>>;
91
94
 
92
- /** Abort hook execution with an error. Throws a HookError. */
93
- fail: (error?: unknown) => never,
95
+ /** Abort hook execution with an error. */
96
+ fail: (...args: FailArgs) => never;
94
97
 
95
- /** Replace the arguments passed to the original function */
96
- setArgs: (next: Parameters<F>) => void,
98
+ /** Replace the arguments for subsequent callbacks */
99
+ setArgs: (next: Parameters<F>) => void;
97
100
 
98
- /** Replace the result returned from the hook chain */
99
- setResult: (next: Awaited<ReturnType<F>>) => void,
101
+ /** Set the result value */
102
+ setResult: (next: Awaited<ReturnType<F>>) => void;
100
103
 
101
- /** Skip the original function and return early with the current result */
104
+ /** Stop processing remaining callbacks and return early */
102
105
  returnEarly: () => void;
103
106
 
104
- /** Remove this extension from the hook (useful with `once` behavior) */
107
+ /** Remove this callback from the hook */
105
108
  removeHook: () => void;
106
109
  }
107
110
 
108
- export type HookFn<F extends AsyncFunc> = (ctx: HookContext<F>) => Promise<void>;
111
+ export type HookFn<F extends AsyncFunc, FailArgs extends unknown[] = [string]> =
112
+ (ctx: HookContext<F, FailArgs>) => Promise<void>;
109
113
 
110
- class Hook<F extends AsyncFunc> {
111
- before: Set<HookFn<F>> = new Set();
112
- after: Set<HookFn<F>> = new Set();
113
- error: Set<HookFn<F>> = new Set();
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
- const allowedExtPoints = new Set([
117
- 'before',
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 HookExtOptions<F extends AsyncFunc> = {
123
- callback: HookFn<F>,
124
- once?: true,
125
- ignoreOnFail?: true
126
- }
123
+ type FuncOrNever<T> = T extends AsyncFunc ? T : never;
127
124
 
128
- type HookExtOrOptions<F extends AsyncFunc> = HookFn<F> | HookExtOptions<F>
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
- type MakeHookOptions = {
131
- bindTo?: any
132
- }
133
+ /**
134
+ * Options for HookEngine constructor.
135
+ */
136
+ export interface HookEngineOptions<FailArgs extends unknown[] = [string]> {
133
137
 
134
- type FuncOrNever<T> = T extends AsyncFunc ? T : never;
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 for extending function behavior.
155
+ * A lightweight, type-safe lifecycle hook system.
138
156
  *
139
- * HookEngine allows you to wrap functions and add extensions that run
140
- * before, after, or on error. Extensions can modify arguments, change
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 MyApp {
145
- * save(data: Data): Promise<Result>;
146
- * load(id: string): Promise<Data>;
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 app = new MyAppImpl();
150
- * const hooks = new HookEngine<MyApp>();
167
+ * const hooks = new HookEngine<FetchLifecycle>();
151
168
  *
152
- * // Wrap a method to make it hookable
153
- * hooks.wrap(app, 'save');
154
- *
155
- * // Add a validation extension
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
- * // Add logging extension
163
- * hooks.extend('save', 'after', async (ctx) => {
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
- * @typeParam Shape - Interface defining the hookable functions
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 class HookEngine<Shape> {
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
- #registered = new Set<keyof Shape>();
172
- #hooks: Map<keyof Shape, Hook<FuncOrNever<Shape[keyof Shape]>>> = new Map();
173
- #hookFnOpts = new WeakMap();
174
- #wrapped = new WeakMap();
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
- * Add an extension to a registered hook.
237
+ * Register hook names for runtime validation.
238
+ * Once any hooks are registered, all hooks must be registered before use.
178
239
  *
179
- * Extensions run at specific points in the hook lifecycle:
180
- * - `before`: Runs before the original function. Can modify args or return early.
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
- * @param name - Name of the registered hook to extend
185
- * @param extensionPoint - When to run: 'before', 'after', or 'error'
186
- * @param cbOrOpts - Extension callback or options object
187
- * @returns Cleanup function to remove the extension
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.extend('save', 'before', async (ctx) => {
192
- * console.log('About to save:', ctx.args);
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.extend('save', 'after', {
197
- * callback: async (ctx) => { console.log('Saved!'); },
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 this extension fails
285
+ * ignoreOnFail: true // Don't throw if callback fails
200
286
  * });
201
287
  *
202
- * // Later: remove the extension
288
+ * // Remove subscription
203
289
  * cleanup();
204
290
  */
205
- extend<K extends FunctionProps<Shape>>(
291
+ on<K extends HookName<Lifecycle>>(
206
292
  name: K,
207
- extensionPoint: keyof Hook<FuncOrNever<Shape[K]>>,
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' ? {} as HookExtOptions<FuncOrNever<Shape[K]>> : cbOrOpts;
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(this.#registered.has(name), `'${name.toString()}' is not a registered hook`);
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
- const hook = this.#hooks.get(name) ?? new Hook<FuncOrNever<Shape[K]>>();
305
+ this.#assertRegistered(name, 'on');
306
+
307
+ const hooks = this.#hooks.get(name) ?? new Set();
221
308
 
222
- hook[extensionPoint].add(callback);
309
+ hooks.add(callback as HookFn<FuncOrNever<Lifecycle[keyof Lifecycle]>, FailArgs>);
223
310
 
224
- this.#hooks.set(name, hook);
225
- this.#hookFnOpts.set(callback, opts);
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
- hook[extensionPoint].delete(callback);
316
+ hooks.delete(callback as HookFn<FuncOrNever<Lifecycle[keyof Lifecycle]>, FailArgs>);
233
317
  }
234
318
  }
235
319
 
236
320
  /**
237
- * Register a function as a hookable and return the wrapped version.
321
+ * Subscribe to a lifecycle hook that fires only once.
322
+ * Sugar for `on(name, { callback, once: true })`.
238
323
  *
239
- * The wrapped function behaves identically to the original but allows
240
- * extensions to be added via `extend()`. Use `wrap()` for a simpler API
241
- * when working with object methods.
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
- * const hooks = new HookEngine<{ fetch: typeof fetch }>();
250
- *
251
- * const hookedFetch = hooks.make('fetch', fetch);
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
- make<K extends FunctionProps<Shape>>(
334
+ once<K extends HookName<Lifecycle>>(
260
335
  name: K,
261
- cb: FuncOrNever<Shape[K]>,
262
- opts: MakeHookOptions = {}
336
+ callback: HookFn<FuncOrNever<Lifecycle[K]>, FailArgs>
263
337
  ) {
264
338
 
265
- assert(typeof name === 'string', '"name" must be a string');
266
- assert(!this.#registered.has(name), `'${name.toString()}' hook is already registered`);
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
- if (this.#wrapped.has(cb)) {
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
- return this.#wrapped.get(cb) as FuncOrNever<Shape[K]>;
275
- }
364
+ this.#assertRegistered(name, 'emit');
276
365
 
277
- const callback = async (...origArgs: Parameters<FuncOrNever<Shape[K]>>) => {
366
+ let earlyReturn = false;
278
367
 
279
- let returnEarly = false;
368
+ const hooks = this.#hooks.get(name);
280
369
 
281
- const hook = this.#hooks.get(name)!;
370
+ const context: HookContext<FuncOrNever<Lifecycle[K]>, FailArgs> = {
371
+ args,
372
+ removeHook() {},
373
+ returnEarly() {
282
374
 
283
- const context: HookContext<FuncOrNever<Shape[K]>> = {
284
- args: origArgs,
285
- point: 'before',
286
- removeHook() {},
287
- returnEarly() {
288
- returnEarly = true;
289
- },
290
- setArgs(next) {
375
+ earlyReturn = true;
376
+ },
377
+ setArgs: (next) => {
291
378
 
292
- assert(
293
- Array.isArray(next),
294
- `setArgs: next args for '${context.point}' '${name.toString()}' must be an array of arguments`
295
- );
379
+ assert(
380
+ Array.isArray(next),
381
+ `setArgs: args for '${String(name)}' must be an array`
382
+ );
296
383
 
297
- context.args = next;
298
- },
299
- setResult(next) {
300
- context.results = next;
301
- },
302
- fail(reason) {
384
+ context.args = next;
385
+ },
386
+ setResult: (next) => {
303
387
 
304
- const error = new HookError(`Hook Aborted: ${reason ?? 'unknown'}`);
388
+ context.result = next;
389
+ },
390
+ fail: ((...failArgs: FailArgs) => {
305
391
 
306
- if (reason instanceof Error) {
392
+ const handler = this.#handleFail;
307
393
 
308
- error.originalError = reason;
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
- error.extPoint = context.point;
312
- error.hookName = name as string;
398
+ const [, error] = attemptSync(() => {
313
399
 
314
- throw error;
315
- },
316
- }
400
+ if (isConstructor) {
317
401
 
318
- const { before, after, error: errorFns } = hook ?? new Hook<FuncOrNever<Shape[K]>>();
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
- context.removeHook = () => which.delete(fn);
405
+ (handler as (...args: FailArgs) => never)(...failArgs);
406
+ });
330
407
 
331
- const opts: HookExtOptions<FuncOrNever<Shape[K]>> = this.#hookFnOpts.get(fn);
332
- const [, err] = await attempt(() => fn({ ...context }));
408
+ if (error) {
333
409
 
334
- if (opts.once) context.removeHook();
410
+ if (error instanceof HookError) {
335
411
 
336
- if (err && opts.ignoreOnFail !== true) {
337
- throw err;
412
+ error.hookName = String(name);
338
413
  }
339
414
 
340
- if (returnEarly) break;
415
+ throw error;
341
416
  }
342
- }
343
417
 
344
- await handleSet(before, 'before');
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
- if (returnEarly) return context.results!
432
+ for (const fn of hooks) {
347
433
 
348
- const [res, err] = await attempt(() => cb.apply(opts?.bindTo || cb, context.args));
434
+ context.removeHook = () => hooks.delete(fn as any);
349
435
 
350
- context.results = res;
351
- context.error = err;
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 (err) {
354
- context.point = 'error';
439
+ if (opts.once) context.removeHook();
355
440
 
356
- await handleSet(errorFns, 'error');
441
+ if (err && opts.ignoreOnFail !== true) {
357
442
 
358
443
  throw err;
359
444
  }
360
445
 
361
- await handleSet(after, 'after');
362
-
363
- return context.results!;
446
+ if (earlyReturn) break;
364
447
  }
365
448
 
366
- return callback as FuncOrNever<Shape[K]>;
449
+ return {
450
+ args: context.args,
451
+ result: context.result,
452
+ earlyReturn
453
+ };
367
454
  }
368
455
 
369
456
  /**
370
- * Wrap an object method in-place to make it hookable.
457
+ * Clear all registered hooks.
371
458
  *
372
- * This is a convenience method that combines `make()` with automatic
373
- * binding and reassignment. The method is replaced on the instance
374
- * with the wrapped version.
459
+ * @example
460
+ * hooks.on('preRequest', validator);
461
+ * hooks.on('postRequest', logger);
375
462
  *
376
- * @param instance - Object containing the method to wrap
377
- * @param name - Name of the method to wrap
378
- * @param opts - Additional options
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
- * class UserService {
382
- * async save(user: User) { ... }
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 service = new UserService();
386
- * const hooks = new HookEngine<UserService>();
489
+ * const hooks = new HookEngine<Lifecycle>();
387
490
  *
388
- * hooks.wrap(service, 'save');
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
- * // Now service.save() is hookable
391
- * hooks.extend('save', 'before', async (ctx) => {
392
- * console.log('Saving user:', ctx.args[0]);
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<K extends FunctionProps<Shape>>(
396
- instance: Shape,
397
- name: K,
398
- opts?: MakeHookOptions
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
- assert(isObject(instance), '"instance" must be an object');
532
+ // Pre hook
533
+ if (hooks.pre) {
402
534
 
403
- const wrapped = this.make(
404
- name,
405
- instance[name] as FuncOrNever<Shape[K]>,
406
- {
407
- bindTo: instance,
408
- ...opts
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
- this.#wrapped.set(wrapped, instance[name] as AsyncFunc);
545
+ // Execute function
546
+ result = await fn(...currentArgs);
413
547
 
414
- instance[name] = wrapped as Shape[K];
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
- this.#registered.clear();
437
- this.#hooks.clear();
438
- this.#hookFnOpts = new WeakMap();
558
+ return postResult.result as Awaited<ReturnType<F>>;
559
+ }
560
+ }
561
+
562
+ return result as Awaited<ReturnType<F>>;
563
+ };
439
564
  }
440
- }
565
+ }