@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/CHANGELOG.md +15 -0
- package/LICENSE +12 -0
- package/dist/browser/bundle.js +2 -0
- package/dist/browser/bundle.js.map +1 -0
- package/dist/cjs/index.js +796 -0
- package/dist/esm/index.mjs +830 -0
- package/dist/types/index.d.ts +228 -0
- package/package.json +36 -0
- package/readme.md +88 -0
- package/src/index.ts +440 -0
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
|
+
}
|