@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.
@@ -1,228 +1,292 @@
1
1
  import { AsyncFunc, FunctionProps } from '@logosdx/utils';
2
2
  /**
3
- * Error thrown when a hook extension calls `fail()` or when hook execution fails.
3
+ * Error thrown when a hook calls `ctx.fail()`.
4
+ *
5
+ * This error is only created when using the default `handleFail` behavior.
6
+ * If a custom `handleFail` is provided, that error type is thrown instead.
4
7
  *
5
8
  * @example
6
- * engine.extend('save', 'before', async (ctx) => {
9
+ * hooks.on('validate', async (ctx) => {
7
10
  * if (!ctx.args[0].isValid) {
8
11
  * ctx.fail('Validation failed');
9
12
  * }
10
13
  * });
11
14
  *
12
- * const [, err] = await attempt(() => app.save(data));
15
+ * const [, err] = await attempt(() => engine.emit('validate', data));
13
16
  * if (isHookError(err)) {
14
- * console.log(err.hookName); // 'save'
15
- * console.log(err.extPoint); // 'before'
17
+ * console.log(err.hookName); // 'validate'
16
18
  * }
17
19
  */
18
20
  export declare class HookError extends Error {
19
21
  /** Name of the hook where the error occurred */
20
22
  hookName?: string;
21
- /** Extension point where the error occurred: 'before', 'after', or 'error' */
22
- extPoint?: string;
23
23
  /** Original error if `fail()` was called with an Error instance */
24
24
  originalError?: Error;
25
- /** Whether the hook was explicitly aborted via `fail()` */
26
- aborted: boolean;
27
25
  constructor(message: string);
28
26
  }
29
27
  /**
30
28
  * Type guard to check if an error is a HookError.
31
29
  *
32
30
  * @example
33
- * const [result, err] = await attempt(() => app.save(data));
34
- * if (isHookError(err)) {
35
- * console.log(`Hook "${err.hookName}" failed at "${err.extPoint}"`);
31
+ * const { error } = await engine.emit('validate', data);
32
+ * if (isHookError(error)) {
33
+ * console.log(`Hook "${error.hookName}" failed`);
36
34
  * }
37
35
  */
38
36
  export declare const isHookError: (error: unknown) => error is HookError;
39
- interface HookShape<F extends AsyncFunc> {
37
+ /**
38
+ * Result returned from `emit()` after running all hook callbacks.
39
+ */
40
+ export interface EmitResult<F extends AsyncFunc> {
41
+ /** Current arguments (possibly modified by callbacks) */
40
42
  args: Parameters<F>;
41
- results?: Awaited<ReturnType<F>>;
43
+ /** Result value (if set by a callback) */
44
+ result?: Awaited<ReturnType<F>> | undefined;
45
+ /** Whether a callback called `returnEarly()` */
46
+ earlyReturn: boolean;
42
47
  }
43
48
  /**
44
- * Context object passed to hook extension callbacks.
49
+ * Context object passed to hook callbacks.
45
50
  * Provides access to arguments, results, and control methods.
46
51
  *
47
52
  * @example
48
- * engine.extend('fetch', 'before', async (ctx) => {
49
- * // Read current arguments
50
- * const [url, options] = ctx.args;
53
+ * hooks.on('cacheCheck', async (ctx) => {
54
+ * const [url] = ctx.args;
55
+ * const cached = cache.get(url);
51
56
  *
52
- * // Modify arguments before the original function runs
53
- * ctx.setArgs([url, { ...options, cache: 'force-cache' }]);
54
- *
55
- * // Or skip the original function entirely
56
- * if (isCached(url)) {
57
- * ctx.setResult(getCached(url));
57
+ * if (cached) {
58
+ * ctx.setResult(cached);
58
59
  * ctx.returnEarly();
59
60
  * }
60
61
  * });
61
62
  */
62
- export interface HookContext<F extends AsyncFunc> extends HookShape<F> {
63
- /** Current extension point: 'before', 'after', or 'error' */
64
- point: keyof Hook<F>;
65
- /** Error from the original function (only set in 'error' extensions) */
66
- error?: unknown;
67
- /** Abort hook execution with an error. Throws a HookError. */
68
- fail: (error?: unknown) => never;
69
- /** Replace the arguments passed to the original function */
63
+ export interface HookContext<F extends AsyncFunc, FailArgs extends unknown[] = [string]> {
64
+ /** Current arguments passed to emit() */
65
+ args: Parameters<F>;
66
+ /** Result value (can be set by callbacks) */
67
+ result?: Awaited<ReturnType<F>>;
68
+ /** Abort hook execution with an error. */
69
+ fail: (...args: FailArgs) => never;
70
+ /** Replace the arguments for subsequent callbacks */
70
71
  setArgs: (next: Parameters<F>) => void;
71
- /** Replace the result returned from the hook chain */
72
+ /** Set the result value */
72
73
  setResult: (next: Awaited<ReturnType<F>>) => void;
73
- /** Skip the original function and return early with the current result */
74
+ /** Stop processing remaining callbacks and return early */
74
75
  returnEarly: () => void;
75
- /** Remove this extension from the hook (useful with `once` behavior) */
76
+ /** Remove this callback from the hook */
76
77
  removeHook: () => void;
77
78
  }
78
- export type HookFn<F extends AsyncFunc> = (ctx: HookContext<F>) => Promise<void>;
79
- declare class Hook<F extends AsyncFunc> {
80
- before: Set<HookFn<F>>;
81
- after: Set<HookFn<F>>;
82
- error: Set<HookFn<F>>;
83
- }
84
- type HookExtOptions<F extends AsyncFunc> = {
85
- callback: HookFn<F>;
79
+ export type HookFn<F extends AsyncFunc, FailArgs extends unknown[] = [string]> = (ctx: HookContext<F, FailArgs>) => Promise<void>;
80
+ type HookOptions<F extends AsyncFunc, FailArgs extends unknown[] = [string]> = {
81
+ callback: HookFn<F, FailArgs>;
86
82
  once?: true;
87
83
  ignoreOnFail?: true;
88
84
  };
89
- type HookExtOrOptions<F extends AsyncFunc> = HookFn<F> | HookExtOptions<F>;
90
- type MakeHookOptions = {
91
- bindTo?: any;
92
- };
85
+ type HookOrOptions<F extends AsyncFunc, FailArgs extends unknown[] = [string]> = HookFn<F, FailArgs> | HookOptions<F, FailArgs>;
93
86
  type FuncOrNever<T> = T extends AsyncFunc ? T : never;
94
87
  /**
95
- * A lightweight, type-safe hook system for extending function behavior.
88
+ * Custom error handler for `ctx.fail()`.
89
+ * Can be an Error constructor or a function that throws.
90
+ */
91
+ export type HandleFail<Args extends unknown[] = [string]> = (new (...args: Args) => Error) | ((...args: Args) => never);
92
+ /**
93
+ * Options for HookEngine constructor.
94
+ */
95
+ export interface HookEngineOptions<FailArgs extends unknown[] = [string]> {
96
+ /**
97
+ * Custom handler for `ctx.fail()`.
98
+ * Can be an Error constructor or a function that throws.
99
+ *
100
+ * @example
101
+ * // Use Firebase HttpsError
102
+ * new HookEngine({ handleFail: HttpsError });
103
+ *
104
+ * // Use custom function
105
+ * new HookEngine({
106
+ * handleFail: (msg, data) => { throw Boom.badRequest(msg, data); }
107
+ * });
108
+ */
109
+ handleFail?: HandleFail<FailArgs>;
110
+ }
111
+ /**
112
+ * A lightweight, type-safe lifecycle hook system.
96
113
  *
97
- * HookEngine allows you to wrap functions and add extensions that run
98
- * before, after, or on error. Extensions can modify arguments, change
99
- * results, or abort execution entirely.
114
+ * HookEngine allows you to define lifecycle events and subscribe to them.
115
+ * Callbacks can modify arguments, set results, or abort execution.
100
116
  *
101
117
  * @example
102
- * interface MyApp {
103
- * save(data: Data): Promise<Result>;
104
- * load(id: string): Promise<Data>;
118
+ * interface FetchLifecycle {
119
+ * preRequest(url: string, options: RequestInit): Promise<Response>;
120
+ * rateLimit(error: Error, attempt: number): Promise<void>;
121
+ * cacheHit(url: string, data: unknown): Promise<unknown>;
105
122
  * }
106
123
  *
107
- * const app = new MyAppImpl();
108
- * const hooks = new HookEngine<MyApp>();
124
+ * const hooks = new HookEngine<FetchLifecycle>();
109
125
  *
110
- * // Wrap a method to make it hookable
111
- * hooks.wrap(app, 'save');
112
- *
113
- * // Add a validation extension
114
- * hooks.extend('save', 'before', async (ctx) => {
115
- * if (!ctx.args[0].isValid) {
116
- * ctx.fail('Validation failed');
117
- * }
126
+ * hooks.on('rateLimit', async (ctx) => {
127
+ * const [error, attempt] = ctx.args;
128
+ * if (attempt > 3) ctx.fail('Max retries exceeded');
129
+ * await sleep(error.retryAfter * 1000);
118
130
  * });
119
131
  *
120
- * // Add logging extension
121
- * hooks.extend('save', 'after', async (ctx) => {
122
- * console.log('Saved:', ctx.results);
132
+ * hooks.on('cacheHit', async (ctx) => {
133
+ * console.log('Cache hit for:', ctx.args[0]);
123
134
  * });
124
135
  *
125
- * @typeParam Shape - Interface defining the hookable functions
136
+ * // In your implementation
137
+ * const result = await hooks.emit('cacheHit', url, cachedData);
138
+ *
139
+ * @typeParam Lifecycle - Interface defining the lifecycle hooks
140
+ * @typeParam FailArgs - Arguments type for ctx.fail() (default: [string])
141
+ */
142
+ /**
143
+ * Default permissive lifecycle type when no type parameter is provided.
126
144
  */
127
- export declare class HookEngine<Shape> {
128
- #private;
145
+ type DefaultLifecycle = Record<string, AsyncFunc>;
146
+ /**
147
+ * Extract only function property keys from a type.
148
+ * This ensures only methods are available as hook names, not data properties.
149
+ *
150
+ * @example
151
+ * interface Doc {
152
+ * id: string;
153
+ * save(): Promise<void>;
154
+ * delete(): Promise<void>;
155
+ * }
156
+ *
157
+ * type DocHooks = HookName<Doc>; // 'save' | 'delete' (excludes 'id')
158
+ */
159
+ export type HookName<T> = FunctionProps<T>;
160
+ export declare class HookEngine<Lifecycle = DefaultLifecycle, FailArgs extends unknown[] = [string]> {
161
+ constructor(options?: HookEngineOptions<FailArgs>);
129
162
  /**
130
- * Add an extension to a registered hook.
163
+ * Register hook names for runtime validation.
164
+ * Once any hooks are registered, all hooks must be registered before use.
165
+ *
166
+ * @param names - Hook names to register
167
+ * @returns this (for chaining)
168
+ *
169
+ * @example
170
+ * const hooks = new HookEngine<FetchLifecycle>()
171
+ * .register('preRequest', 'postRequest', 'rateLimit');
131
172
  *
132
- * Extensions run at specific points in the hook lifecycle:
133
- * - `before`: Runs before the original function. Can modify args or return early.
134
- * - `after`: Runs after successful execution. Can modify the result.
135
- * - `error`: Runs when the original function throws. Can handle or transform errors.
173
+ * hooks.on('preRequest', cb); // OK
174
+ * hooks.on('preRequset', cb); // Error: not registered (typo caught!)
175
+ */
176
+ register(...names: HookName<Lifecycle>[]): this;
177
+ /**
178
+ * Subscribe to a lifecycle hook.
136
179
  *
137
- * @param name - Name of the registered hook to extend
138
- * @param extensionPoint - When to run: 'before', 'after', or 'error'
139
- * @param cbOrOpts - Extension callback or options object
140
- * @returns Cleanup function to remove the extension
180
+ * @param name - Name of the lifecycle hook
181
+ * @param cbOrOpts - Callback function or options object
182
+ * @returns Cleanup function to remove the subscription
141
183
  *
142
184
  * @example
143
185
  * // Simple callback
144
- * const cleanup = hooks.extend('save', 'before', async (ctx) => {
145
- * console.log('About to save:', ctx.args);
186
+ * const cleanup = hooks.on('preRequest', async (ctx) => {
187
+ * console.log('Request:', ctx.args[0]);
146
188
  * });
147
189
  *
148
190
  * // With options
149
- * hooks.extend('save', 'after', {
150
- * callback: async (ctx) => { console.log('Saved!'); },
191
+ * hooks.on('analytics', {
192
+ * callback: async (ctx) => { track(ctx.args); },
151
193
  * once: true, // Remove after first run
152
- * ignoreOnFail: true // Don't throw if this extension fails
194
+ * ignoreOnFail: true // Don't throw if callback fails
153
195
  * });
154
196
  *
155
- * // Later: remove the extension
197
+ * // Remove subscription
156
198
  * cleanup();
157
199
  */
158
- extend<K extends FunctionProps<Shape>>(name: K, extensionPoint: keyof Hook<FuncOrNever<Shape[K]>>, cbOrOpts: HookExtOrOptions<FuncOrNever<Shape[K]>>): () => void;
200
+ on<K extends HookName<Lifecycle>>(name: K, cbOrOpts: HookOrOptions<FuncOrNever<Lifecycle[K]>, FailArgs>): () => void;
159
201
  /**
160
- * Register a function as a hookable and return the wrapped version.
161
- *
162
- * The wrapped function behaves identically to the original but allows
163
- * extensions to be added via `extend()`. Use `wrap()` for a simpler API
164
- * when working with object methods.
202
+ * Subscribe to a lifecycle hook that fires only once.
203
+ * Sugar for `on(name, { callback, once: true })`.
165
204
  *
166
- * @param name - Unique name for this hook (must match a key in Shape)
167
- * @param cb - The original function to wrap
168
- * @param opts - Options for the wrapped function
169
- * @returns Wrapped function with hook support
205
+ * @param name - Name of the lifecycle hook
206
+ * @param callback - Callback function
207
+ * @returns Cleanup function to remove the subscription
170
208
  *
171
209
  * @example
172
- * const hooks = new HookEngine<{ fetch: typeof fetch }>();
173
- *
174
- * const hookedFetch = hooks.make('fetch', fetch);
175
- *
176
- * hooks.extend('fetch', 'before', async (ctx) => {
177
- * console.log('Fetching:', ctx.args[0]);
210
+ * // Log only the first request
211
+ * hooks.once('preRequest', async (ctx) => {
212
+ * console.log('First request:', ctx.args[0]);
178
213
  * });
179
- *
180
- * await hookedFetch('/api/data');
181
214
  */
182
- make<K extends FunctionProps<Shape>>(name: K, cb: FuncOrNever<Shape[K]>, opts?: MakeHookOptions): FuncOrNever<Shape[K]>;
215
+ once<K extends HookName<Lifecycle>>(name: K, callback: HookFn<FuncOrNever<Lifecycle[K]>, FailArgs>): () => void;
183
216
  /**
184
- * Wrap an object method in-place to make it hookable.
185
- *
186
- * This is a convenience method that combines `make()` with automatic
187
- * binding and reassignment. The method is replaced on the instance
188
- * with the wrapped version.
217
+ * Emit a lifecycle hook, running all subscribed callbacks.
189
218
  *
190
- * @param instance - Object containing the method to wrap
191
- * @param name - Name of the method to wrap
192
- * @param opts - Additional options
219
+ * @param name - Name of the lifecycle hook to emit
220
+ * @param args - Arguments to pass to callbacks
221
+ * @returns EmitResult with final args, result, and earlyReturn flag
193
222
  *
194
223
  * @example
195
- * class UserService {
196
- * async save(user: User) { ... }
224
+ * const result = await hooks.emit('cacheCheck', url);
225
+ *
226
+ * if (result.earlyReturn && result.result) {
227
+ * return result.result; // Use cached value
197
228
  * }
198
229
  *
199
- * const service = new UserService();
200
- * const hooks = new HookEngine<UserService>();
230
+ * // Continue with modified args
231
+ * const [modifiedUrl] = result.args;
232
+ */
233
+ emit<K extends HookName<Lifecycle>>(name: K, ...args: Parameters<FuncOrNever<Lifecycle[K]>>): Promise<EmitResult<FuncOrNever<Lifecycle[K]>>>;
234
+ /**
235
+ * Clear all registered hooks.
201
236
  *
202
- * hooks.wrap(service, 'save');
237
+ * @example
238
+ * hooks.on('preRequest', validator);
239
+ * hooks.on('postRequest', logger);
203
240
  *
204
- * // Now service.save() is hookable
205
- * hooks.extend('save', 'before', async (ctx) => {
206
- * console.log('Saving user:', ctx.args[0]);
207
- * });
241
+ * // Reset for testing
242
+ * hooks.clear();
208
243
  */
209
- wrap<K extends FunctionProps<Shape>>(instance: Shape, name: K, opts?: MakeHookOptions): void;
244
+ clear(): void;
210
245
  /**
211
- * Clear all registered hooks and extensions.
246
+ * Wrap a function with pre/post lifecycle hooks.
247
+ *
248
+ * - Pre hook: emitted with function args, can modify args or returnEarly with result
249
+ * - Post hook: emitted with [result, ...args], can modify result
212
250
  *
213
- * After calling this method, all hooks are unregistered and all
214
- * extensions are removed. Previously wrapped functions will continue
215
- * to work but without any extensions.
251
+ * @param fn - The async function to wrap
252
+ * @param hooks - Object with optional pre and post hook names
253
+ * @returns Wrapped function with same signature
216
254
  *
217
255
  * @example
218
- * hooks.wrap(app, 'save');
219
- * hooks.extend('save', 'before', validator);
256
+ * interface Lifecycle {
257
+ * preRequest(url: string, opts: RequestInit): Promise<Response>;
258
+ * postRequest(result: Response, url: string, opts: RequestInit): Promise<Response>;
259
+ * }
220
260
  *
221
- * // Reset for testing
222
- * hooks.clear();
261
+ * const hooks = new HookEngine<Lifecycle>();
262
+ *
263
+ * // Add cache check in pre hook
264
+ * hooks.on('preRequest', async (ctx) => {
265
+ * const cached = cache.get(ctx.args[0]);
266
+ * if (cached) {
267
+ * ctx.setResult(cached);
268
+ * ctx.returnEarly();
269
+ * }
270
+ * });
223
271
  *
224
- * // app.save() still works, but validator no longer runs
272
+ * // Log result in post hook
273
+ * hooks.on('postRequest', async (ctx) => {
274
+ * const [result, url] = ctx.args;
275
+ * console.log(`Fetched ${url}:`, result.status);
276
+ * });
277
+ *
278
+ * // Wrap the fetch function
279
+ * const wrappedFetch = hooks.wrap(
280
+ * async (url: string, opts: RequestInit) => fetch(url, opts),
281
+ * { pre: 'preRequest', post: 'postRequest' }
282
+ * );
225
283
  */
226
- clear(): void;
284
+ wrap<F extends AsyncFunc>(fn: F, hooks: {
285
+ pre: HookName<Lifecycle>;
286
+ post?: HookName<Lifecycle>;
287
+ } | {
288
+ pre?: HookName<Lifecycle>;
289
+ post: HookName<Lifecycle>;
290
+ }): (...args: Parameters<F>) => Promise<Awaited<ReturnType<F>>>;
227
291
  }
228
292
  export {};
package/package.json CHANGED
@@ -1,4 +1,7 @@
1
1
  {
2
+ "name": "@logosdx/hooks",
3
+ "version": "1.0.0-beta.2",
4
+ "description": "A lightweight, type-safe hook system for extending function behavior",
2
5
  "license": "BSD-3-Clause",
3
6
  "homepage": "https://logosdx.dev/",
4
7
  "bugs": {
@@ -6,11 +9,10 @@
6
9
  "email": "danilo@alonso.network"
7
10
  },
8
11
  "author": "Danilo Alonso <danilo@alonso.network>",
9
- "name": "@logosdx/hooks",
10
- "description": "A lightweight, type-safe hook system for extending function behavior",
11
- "version": "1.0.0-beta.0",
12
- "dependencies": {
13
- "@logosdx/utils": "^5.0.0"
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/logosdx/monorepo",
15
+ "directory": "packages/hooks"
14
16
  },
15
17
  "keywords": [
16
18
  "hooks",
@@ -18,6 +20,7 @@
18
20
  "extensions",
19
21
  "before after"
20
22
  ],
23
+ "sideEffects": false,
21
24
  "files": [
22
25
  "dist",
23
26
  "src",
@@ -27,10 +30,14 @@
27
30
  ],
28
31
  "exports": {
29
32
  ".": {
30
- "require": "./dist/cjs/index.js",
31
- "import": "./dist/esm/index.mjs",
32
33
  "types": "./dist/types/index.d.ts",
33
- "browser": "./dist/browser/bundle.js"
34
+ "require": "./dist/cjs/index.js",
35
+ "import": "./dist/esm/index.mjs"
34
36
  }
37
+ },
38
+ "unpkg": "./dist/browser/bundle.js",
39
+ "jsdelivr": "./dist/browser/bundle.js",
40
+ "dependencies": {
41
+ "@logosdx/utils": "^6.1.0-beta.0"
35
42
  }
36
43
  }
package/readme.md CHANGED
@@ -38,15 +38,11 @@
38
38
  - `@logosdx/storage`: One API for your many key-value stores.
39
39
  - `@logosdx/localize`: Localization utilities for everything from languages to customer-specific strings.
40
40
  - `@logosdx/dom`: For those who like to raw-dawg the DOM.
41
+ - `@logosdx/react`: The above, but for React. Use it in Next.js, React Native, or anywhere else.
41
42
 
42
43
  ## Under-construction
43
44
 
44
45
  - `@logosdx/state-machine`: State management as streams, not stores.
45
- - `@logosdx/kit`: Bootstrap your app. Type-safe from day one. All the packages in one place.
46
-
47
- ## Roadmap
48
-
49
- - `@logosdx/react`: All of the above, but for React. Use it in Next.js, React Native, or anywhere else.
50
46
 
51
47
  ## LLM Helpers
52
48