@logosdx/hooks 1.0.0-beta.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/src/index.ts ADDED
@@ -0,0 +1,440 @@
1
+ import {
2
+ assert,
3
+ AsyncFunc,
4
+ attempt,
5
+ isFunction,
6
+ isObject,
7
+ FunctionProps
8
+ } from '@logosdx/utils';
9
+
10
+ /**
11
+ * Error thrown when a hook extension calls `fail()` or when hook execution fails.
12
+ *
13
+ * @example
14
+ * engine.extend('save', 'before', async (ctx) => {
15
+ * if (!ctx.args[0].isValid) {
16
+ * ctx.fail('Validation failed');
17
+ * }
18
+ * });
19
+ *
20
+ * const [, err] = await attempt(() => app.save(data));
21
+ * if (isHookError(err)) {
22
+ * console.log(err.hookName); // 'save'
23
+ * console.log(err.extPoint); // 'before'
24
+ * }
25
+ */
26
+ export class HookError extends Error {
27
+
28
+ /** Name of the hook where the error occurred */
29
+ hookName?: string;
30
+
31
+ /** Extension point where the error occurred: 'before', 'after', or 'error' */
32
+ extPoint?: string;
33
+
34
+ /** Original error if `fail()` was called with an Error instance */
35
+ originalError?: Error;
36
+
37
+ /** Whether the hook was explicitly aborted via `fail()` */
38
+ aborted = false;
39
+
40
+ constructor(message: string) {
41
+
42
+ super(message)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Type guard to check if an error is a HookError.
48
+ *
49
+ * @example
50
+ * const [result, err] = await attempt(() => app.save(data));
51
+ * if (isHookError(err)) {
52
+ * console.log(`Hook "${err.hookName}" failed at "${err.extPoint}"`);
53
+ * }
54
+ */
55
+ export const isHookError = (error: unknown): error is HookError => {
56
+
57
+ return (error as HookError)?.constructor?.name === HookError.name
58
+ }
59
+
60
+ interface HookShape<F extends AsyncFunc> {
61
+ args: Parameters<F>,
62
+ results?: Awaited<ReturnType<F>>
63
+ }
64
+
65
+ /**
66
+ * Context object passed to hook extension callbacks.
67
+ * Provides access to arguments, results, and control methods.
68
+ *
69
+ * @example
70
+ * engine.extend('fetch', 'before', async (ctx) => {
71
+ * // Read current arguments
72
+ * const [url, options] = ctx.args;
73
+ *
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));
80
+ * ctx.returnEarly();
81
+ * }
82
+ * });
83
+ */
84
+ export interface HookContext<F extends AsyncFunc> extends HookShape<F> {
85
+
86
+ /** Current extension point: 'before', 'after', or 'error' */
87
+ point: keyof Hook<F>;
88
+
89
+ /** Error from the original function (only set in 'error' extensions) */
90
+ error?: unknown,
91
+
92
+ /** Abort hook execution with an error. Throws a HookError. */
93
+ fail: (error?: unknown) => never,
94
+
95
+ /** Replace the arguments passed to the original function */
96
+ setArgs: (next: Parameters<F>) => void,
97
+
98
+ /** Replace the result returned from the hook chain */
99
+ setResult: (next: Awaited<ReturnType<F>>) => void,
100
+
101
+ /** Skip the original function and return early with the current result */
102
+ returnEarly: () => void;
103
+
104
+ /** Remove this extension from the hook (useful with `once` behavior) */
105
+ removeHook: () => void;
106
+ }
107
+
108
+ export type HookFn<F extends AsyncFunc> = (ctx: HookContext<F>) => Promise<void>;
109
+
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
+ }
115
+
116
+ const allowedExtPoints = new Set([
117
+ 'before',
118
+ 'after',
119
+ 'error'
120
+ ]);
121
+
122
+ type HookExtOptions<F extends AsyncFunc> = {
123
+ callback: HookFn<F>,
124
+ once?: true,
125
+ ignoreOnFail?: true
126
+ }
127
+
128
+ type HookExtOrOptions<F extends AsyncFunc> = HookFn<F> | HookExtOptions<F>
129
+
130
+ type MakeHookOptions = {
131
+ bindTo?: any
132
+ }
133
+
134
+ type FuncOrNever<T> = T extends AsyncFunc ? T : never;
135
+
136
+ /**
137
+ * A lightweight, type-safe hook system for extending function behavior.
138
+ *
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.
142
+ *
143
+ * @example
144
+ * interface MyApp {
145
+ * save(data: Data): Promise<Result>;
146
+ * load(id: string): Promise<Data>;
147
+ * }
148
+ *
149
+ * const app = new MyAppImpl();
150
+ * const hooks = new HookEngine<MyApp>();
151
+ *
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
+ * }
160
+ * });
161
+ *
162
+ * // Add logging extension
163
+ * hooks.extend('save', 'after', async (ctx) => {
164
+ * console.log('Saved:', ctx.results);
165
+ * });
166
+ *
167
+ * @typeParam Shape - Interface defining the hookable functions
168
+ */
169
+ export class HookEngine<Shape> {
170
+
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();
175
+
176
+ /**
177
+ * Add an extension to a registered hook.
178
+ *
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.
183
+ *
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
188
+ *
189
+ * @example
190
+ * // Simple callback
191
+ * const cleanup = hooks.extend('save', 'before', async (ctx) => {
192
+ * console.log('About to save:', ctx.args);
193
+ * });
194
+ *
195
+ * // With options
196
+ * hooks.extend('save', 'after', {
197
+ * callback: async (ctx) => { console.log('Saved!'); },
198
+ * once: true, // Remove after first run
199
+ * ignoreOnFail: true // Don't throw if this extension fails
200
+ * });
201
+ *
202
+ * // Later: remove the extension
203
+ * cleanup();
204
+ */
205
+ extend<K extends FunctionProps<Shape>>(
206
+ name: K,
207
+ extensionPoint: keyof Hook<FuncOrNever<Shape[K]>>,
208
+ cbOrOpts: HookExtOrOptions<FuncOrNever<Shape[K]>>
209
+ ) {
210
+ const callback = typeof cbOrOpts === 'function' ? cbOrOpts : cbOrOpts?.callback;
211
+ const opts = typeof cbOrOpts === 'function' ? {} as HookExtOptions<FuncOrNever<Shape[K]>> : cbOrOpts;
212
+
213
+ 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');
218
+ assert(isFunction(callback), 'callback must be a function');
219
+
220
+ const hook = this.#hooks.get(name) ?? new Hook<FuncOrNever<Shape[K]>>();
221
+
222
+ hook[extensionPoint].add(callback);
223
+
224
+ this.#hooks.set(name, hook);
225
+ this.#hookFnOpts.set(callback, opts);
226
+
227
+ /**
228
+ * Removes the registered hook extension
229
+ */
230
+ return () => {
231
+
232
+ hook[extensionPoint].delete(callback);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Register a function as a hookable and return the wrapped version.
238
+ *
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
247
+ *
248
+ * @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]);
255
+ * });
256
+ *
257
+ * await hookedFetch('/api/data');
258
+ */
259
+ make<K extends FunctionProps<Shape>>(
260
+ name: K,
261
+ cb: FuncOrNever<Shape[K]>,
262
+ opts: MakeHookOptions = {}
263
+ ) {
264
+
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);
271
+
272
+ if (this.#wrapped.has(cb)) {
273
+
274
+ return this.#wrapped.get(cb) as FuncOrNever<Shape[K]>;
275
+ }
276
+
277
+ const callback = async (...origArgs: Parameters<FuncOrNever<Shape[K]>>) => {
278
+
279
+ let returnEarly = false;
280
+
281
+ const hook = this.#hooks.get(name)!;
282
+
283
+ const context: HookContext<FuncOrNever<Shape[K]>> = {
284
+ args: origArgs,
285
+ point: 'before',
286
+ removeHook() {},
287
+ returnEarly() {
288
+ returnEarly = true;
289
+ },
290
+ setArgs(next) {
291
+
292
+ assert(
293
+ Array.isArray(next),
294
+ `setArgs: next args for '${context.point}' '${name.toString()}' must be an array of arguments`
295
+ );
296
+
297
+ context.args = next;
298
+ },
299
+ setResult(next) {
300
+ context.results = next;
301
+ },
302
+ fail(reason) {
303
+
304
+ const error = new HookError(`Hook Aborted: ${reason ?? 'unknown'}`);
305
+
306
+ if (reason instanceof Error) {
307
+
308
+ error.originalError = reason;
309
+ }
310
+
311
+ error.extPoint = context.point;
312
+ error.hookName = name as string;
313
+
314
+ throw error;
315
+ },
316
+ }
317
+
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) {
328
+
329
+ context.removeHook = () => which.delete(fn);
330
+
331
+ const opts: HookExtOptions<FuncOrNever<Shape[K]>> = this.#hookFnOpts.get(fn);
332
+ const [, err] = await attempt(() => fn({ ...context }));
333
+
334
+ if (opts.once) context.removeHook();
335
+
336
+ if (err && opts.ignoreOnFail !== true) {
337
+ throw err;
338
+ }
339
+
340
+ if (returnEarly) break;
341
+ }
342
+ }
343
+
344
+ await handleSet(before, 'before');
345
+
346
+ if (returnEarly) return context.results!
347
+
348
+ const [res, err] = await attempt(() => cb.apply(opts?.bindTo || cb, context.args));
349
+
350
+ context.results = res;
351
+ context.error = err;
352
+
353
+ if (err) {
354
+ context.point = 'error';
355
+
356
+ await handleSet(errorFns, 'error');
357
+
358
+ throw err;
359
+ }
360
+
361
+ await handleSet(after, 'after');
362
+
363
+ return context.results!;
364
+ }
365
+
366
+ return callback as FuncOrNever<Shape[K]>;
367
+ }
368
+
369
+ /**
370
+ * Wrap an object method in-place to make it hookable.
371
+ *
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.
375
+ *
376
+ * @param instance - Object containing the method to wrap
377
+ * @param name - Name of the method to wrap
378
+ * @param opts - Additional options
379
+ *
380
+ * @example
381
+ * class UserService {
382
+ * async save(user: User) { ... }
383
+ * }
384
+ *
385
+ * const service = new UserService();
386
+ * const hooks = new HookEngine<UserService>();
387
+ *
388
+ * hooks.wrap(service, 'save');
389
+ *
390
+ * // Now service.save() is hookable
391
+ * hooks.extend('save', 'before', async (ctx) => {
392
+ * console.log('Saving user:', ctx.args[0]);
393
+ * });
394
+ */
395
+ wrap<K extends FunctionProps<Shape>>(
396
+ instance: Shape,
397
+ name: K,
398
+ opts?: MakeHookOptions
399
+ ) {
400
+
401
+ assert(isObject(instance), '"instance" must be an object');
402
+
403
+ const wrapped = this.make(
404
+ name,
405
+ instance[name] as FuncOrNever<Shape[K]>,
406
+ {
407
+ bindTo: instance,
408
+ ...opts
409
+ }
410
+ );
411
+
412
+ this.#wrapped.set(wrapped, instance[name] as AsyncFunc);
413
+
414
+ instance[name] = wrapped as Shape[K];
415
+
416
+ }
417
+
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() {
435
+
436
+ this.#registered.clear();
437
+ this.#hooks.clear();
438
+ this.#hookFnOpts = new WeakMap();
439
+ }
440
+ }