# Hook System The Hook System is a comprehensive interceptor framework for API function calls. It enables you to intercept, modify, and observe function execution with minimal overhead and maximum flexibility. ## Overview Hooks work across all loading modes (eager/lazy) and runtime types (async/live), providing a unified API for function interception regardless of your slothlet configuration. **Key capabilities:** - Four hook types: `before`, `after`, `always`, and `error` - Pattern matching with wildcards, brace expansion, and negation - Priority-based execution ordering within three-phase subsets - Runtime enable/disable globally or by filter - Short-circuit execution support - Synchronous hooks with correct async function handling - Zero-overhead when disabled ## Table of Contents - [Hook Configuration](#hook-configuration) - [Hook Types](#hook-types) - [Basic Usage](#basic-usage) - [Hook Handler Context](#hook-handler-context) - [Short-Circuit Execution](#short-circuit-execution) - [Pattern Matching](#pattern-matching) - [Priority and Chaining](#priority-and-chaining) - [Hook Subsets](#hook-subsets) - [Runtime Control](#runtime-control) - [Hook Management](#hook-management) - [Error Handling](#error-handling) - [Error Source Tracking](#error-source-tracking) - [Sync and Async Function Behavior](#sync-and-async-function-behavior) - [Caller Identity in Callbacks](#caller-identity-in-callbacks) --- ## Hook Configuration Hooks are configured when creating a slothlet instance: ```javascript // Enable hooks (simple boolean) const api = await slothlet({ dir: "./api", hook: true }); // Enable with default pattern filter const api = await slothlet({ dir: "./api", hook: "database.*" // Only intercept database functions by default }); // Full configuration object const api = await slothlet({ dir: "./api", hook: { enabled: true, pattern: "**", suppressErrors: false } }); ``` ### Configuration Options - **`enabled`** (boolean): Enable or disable hook execution - **`pattern`** (string): Default pattern filter for which functions hooks apply to - **`suppressErrors`** (boolean): Control error throwing behavior - `false` (default): Errors are sent to error hooks AND then re-thrown - `true`: Errors are sent to error hooks but NOT thrown (returns `undefined`) ### Error Suppression Error hooks **always receive errors** regardless of this setting. `suppressErrors` only controls whether the error is thrown after all error hooks have executed. > Hooks (including error hooks) only execute when `hook.enabled: true`. If hooks are disabled entirely, errors throw normally with no hook execution. ```javascript const api = await slothlet({ dir: "./api", hook: { enabled: true, suppressErrors: true } }); api.slothlet.hook.on("**:error", ({ path, error }) => { console.error(`Error in ${path}:`, error.message); // Send to monitoring service }); const result = await api.riskyOperation(); if (result === undefined) { // Operation failed silently - error hook was called } ``` ### Error Flow 1. Error occurs (in before hook, function, or after hook) 2. Error hooks execute with full source context 3. If `suppressErrors: false` → error is re-thrown 4. If `suppressErrors: true` → error is NOT thrown; function returns `undefined` --- ## Hook Types ### before Executes before the target function. Can: - Modify the arguments passed to the function - Cancel execution and return a custom value (short-circuit) - Execute validation or authorization logic **Must be synchronous.** Returning a Promise throws an error. ### after Executes after successful function completion. Can: - Transform the function's return value - Access the original args alongside the result Runs only if the function executes (skipped on short-circuit). Attaches to the Promise chain for async functions. ### always Executes after the function completes regardless of success or failure. Receives full execution context including error information. **Return value is ignored** - read-only observer. ### error Executes only when an error occurs. Receives the error with detailed source tracking (where the error originated - before hook, the function itself, after hook, etc.). --- ## Basic Usage ```javascript import slothlet from "@cldmv/slothlet"; const api = await slothlet({ dir: "./api", hook: true }); // Before hook: Modify arguments api.slothlet.hook.on( "math.add:before", ({ path, args }) => { console.log(`Calling ${path} with:`, args); return [args[0] * 2, args[1] * 2]; // Return array to modify args }, { id: "double-args", priority: 100 } ); // After hook: Transform result api.slothlet.hook.on( "math.*:after", ({ path, result }) => { console.log(`${path} returned:`, result); return result * 10; // Return value to replace result }, { id: "multiply-result", priority: 100 } ); // Always hook: Observe final result (read-only) api.slothlet.hook.on( "**:always", ({ path, result, hasError, errors }) => { if (hasError) { console.log(`${path} failed:`, errors); } else { console.log(`${path} succeeded:`, result); } // Return value ignored }, { id: "logger" } ); const result = await api.math.add(2, 3); // Hooks execute: args doubled → 4+6=10 → result×10 → 100 // result === 100 ``` --- ## Hook Handler Context Each hook type receives a context object: ### Before hook context ```javascript api.slothlet.hook.on("math.*:before", ({ path, args, api, ctx }) => { // path: string - API path being called (e.g. "math.add") // args: Array - current arguments // api: object - the live API object (self) // ctx: object - the current context object }); ``` **Return values:** - Return an `Array` → replaces arguments - Return any other non-`undefined` value → short-circuits (function not called, returned value becomes result) - Return `undefined` / no return → continue with existing args ### After hook context ```javascript api.slothlet.hook.on("math.*:after", ({ path, args, result, api, ctx }) => { // path: string - API path // args: Array - original arguments // result: * - current result value (may have been modified by earlier after hooks) // api, ctx - as above }); ``` **Return values:** - Return any non-`undefined` value → replaces result - Return `undefined` / no return → result unchanged ### Always hook context ```javascript api.slothlet.hook.on("**:always", ({ path, args, result, hasError, errors, api, ctx }) => { // path: string - API path // args: Array - original arguments // result: * - final result (undefined if hasError) // hasError: boolean - whether an error occurred // errors: Array - array of errors that occurred // api, ctx - as above // Return value is ignored }); ``` ### Error hook context ```javascript api.slothlet.hook.on("**:error", ({ path, args, error, errorType, source, timestamp, api, ctx }) => { // path: string - API path // args: Array - function arguments // error: Error - the error object // errorType: string - error constructor name // timestamp: Date - when the error occurred // source: object - error source details // source.type: "before" | "after" | "always" | "function" | "unknown" // source.subset: hook subset (if hook error) // source.hookId: hook ID (if hook error) // source.hookTag: hook tag/name (if hook error) // source.timestamp: epoch ms // source.stack: full stack trace // api, ctx - as above }); ``` --- ## Short-Circuit Execution Before hooks can cancel function execution entirely: ```javascript const cache = new Map(); // Cache check - short-circuit on hit api.slothlet.hook.on( "**:before", ({ path, args }) => { const key = JSON.stringify({ path, args }); if (cache.has(key)) { return cache.get(key); // Non-array, non-undefined → short-circuit } // Return undefined → continue to function }, { id: "cache-check", priority: 1000 } ); // Cache store - save result after function runs api.slothlet.hook.on( "**:after", ({ path, args, result }) => { const key = JSON.stringify({ path, args }); cache.set(key, result); return result; }, { id: "cache-store", priority: 100 } ); ``` When a before hook short-circuits: - The function is not called - After hooks are skipped - Always hooks still execute with the short-circuit result --- ## Pattern Matching The typePattern argument to `hook.on()` has the format `"pattern:type"` (path-first, type as trailing suffix). The old type-first form `"type:pattern"` (e.g. `"before:math.add"`) is deprecated — it still works but emits a deprecation warning and will be removed in v4. ```javascript // pattern = "math.add", type = "before" api.slothlet.hook.on("math.add:before", handler); // pattern = "math.*" (all math functions), type = "after" api.slothlet.hook.on("math.*:after", handler); // pattern = "**" (all functions), type = "always" api.slothlet.hook.on("**:always", handler); // pattern = "database.*", type = "error" api.slothlet.hook.on("database.*:error", handler); ``` ### Supported Pattern Syntax | Syntax | Description | Example | | ------------- | ------------------------------------------ | ------------------ | | `exact.path` | Exact match | `"math.add"` | | `namespace.*` | All functions in namespace | `"math.*"` | | `*.funcName` | Function name in any namespace | `"*.add"` | | `**` | All functions | `"**"` | | `{a,b,c}` | Brace expansion - matches "a", "b", or "c" | `"{math,utils}.*"` | | `!pattern` | Negation - matches anything except pattern | `"!internal.*"` | ### Pattern Examples ```javascript // Match all functions except internal ones api.slothlet.hook.on("!internal.*:before", handler); // Match math or database functions api.slothlet.hook.on("{math,database}.*:before", handler); // Match specific methods across all namespaces api.slothlet.hook.on("*.{add,update,delete}:after", handler); ``` ### Diagnostic: Test a Pattern ```javascript // Compile a pattern to test it const matcher = api.slothlet.diag.hook.compilePattern("math.*"); console.log(matcher("math.add")); // true console.log(matcher("other.func")); // false ``` --- ## Priority and Chaining Within each subset, hooks execute in priority order (highest first): ```javascript // High priority - runs first api.slothlet.hook.on( "math.*:before", ({ args }) => { if (args[0] < 0) throw new Error("Negative not allowed"); return args; }, { id: "validate", priority: 1000 } ); // Medium priority - runs second api.slothlet.hook.on("math.*:before", ({ args }) => [args[0] * 2, args[1] * 2], { id: "double", priority: 500 }); // Low priority - runs last api.slothlet.hook.on( "math.*:before", ({ path, args }) => { console.log(`Final args for ${path}:`, args); return args; }, { id: "log", priority: 100 } ); ``` --- ## Hook Subsets Each hook type supports three ordered execution phases (`subset`): | Subset | Order | Typical use | | ----------- | ---------------- | ------------------------------------------------ | | `"before"` | First | Auth checks, security validation, initialization | | `"primary"` | Middle (default) | Main hook logic, business rules | | `"after"` | Last | Cleanup, audit trails, notifications | Within each subset, hooks still sort by priority (highest first), then registration order. ```javascript // Auth (before subset) - must run before any other before-hooks api.slothlet.hook.on( "protected.*:before", ({ ctx }) => { if (!ctx.user) throw new Error("Unauthorized"); }, { id: "auth", subset: "before", priority: 2000 } ); // Business validation (primary subset) - default api.slothlet.hook.on( "math.*:before", ({ args }) => { if (args[0] < 0) throw new Error("Invalid input"); return args; }, { id: "validate", subset: "primary", priority: 1000 } ); // Audit log (after subset) - runs after all other before-hooks api.slothlet.hook.on( "**:before", ({ path, args }) => { console.log(`[AUDIT] Executing ${path} with args:`, args); return args; }, { id: "audit", subset: "after", priority: 100 } ); ``` **Complete execution order** for a function call: ```text before hooks: [subset=before, ↓priority] → [subset=primary, ↓priority] → [subset=after, ↓priority] ↓ function executes after hooks: [subset=before, ↓priority] → [subset=primary, ↓priority] → [subset=after, ↓priority] always hooks: [subset=before, ↓priority] → [subset=primary, ↓priority] → [subset=after, ↓priority] ``` --- ## Permissions and Pinning When a `permissions` block is configured, registering and firing a hook on a path is permission-gated: the rule target uses the same `pattern:type` suffix form (e.g. `"db.*:error"`), and `:hook` as the type matches any hook type on that path. Module-registered hooks are force-pinned to their owner module by default — this prevents permission bypass through the bound `api`; opt out per-instance via `hook: { pin: false }` in the slothlet init config, or at runtime with `api.slothlet.hook.pin.disable()` (`.enable()` re-enables, `.enabled` reads the current state). --- ## Runtime Control Enable and disable hooks at runtime without unregistering them: ```javascript const api = await slothlet({ dir: "./api", hook: true }); // Disable all hooks api.slothlet.hook.disable(); // Re-enable all hooks api.slothlet.hook.enable(); // Disable only a specific pattern api.slothlet.hook.disable({ pattern: "math.*" }); // Re-enable by type api.slothlet.hook.enable({ type: "before" }); // Disable a specific hook by ID api.slothlet.hook.disable({ id: "my-expensive-hook" }); ``` ### Global path filter `enable()` / `disable()` toggle individual registered hooks (by ID, type, or registration pattern). The **global path filter** is a separate axis: it restricts _which API paths the hook system applies to at all_, regardless of which hooks are registered. It is the runtime counterpart of the `hook.pattern` config (and the string `hook: "database.*"` form). When the filter is active a hook fires only if the called path matches at least one enabled pattern. ```javascript // Only intercept database.* paths from now on — calls to other paths run no hooks. api.slothlet.hook.enablePattern("database.*"); // Widen the filter — both database.* and cache.* paths are now intercepted. api.slothlet.hook.enablePattern("cache.*"); // Remove one pattern. When the last pattern is removed the filter deactivates, // so every path is intercepted again. api.slothlet.hook.disablePattern("database.*"); // Reset back to the pattern configured via hook.pattern (or fully unrestricted if that was "**"). api.slothlet.hook.resetPatternFilter(); ``` The catch-all pattern `"**"` matches every path, so a `hook.pattern` of `"**"` (the default) imposes no restriction. --- ## Hook Management ### Registering ```javascript // Returns the hook ID (auto-generated if not specified in options) const hookId = api.slothlet.hook.on("math.*:before", ({ args }) => args, { id: "my-hook", priority: 100, subset: "primary" }); ``` ### Removing ```javascript // Remove by ID api.slothlet.hook.remove({ id: hookId }); // Remove all before hooks matching a pattern api.slothlet.hook.remove({ type: "before", pattern: "math.*" }); // Remove all hooks of a type api.slothlet.hook.remove({ type: "error" }); // off() is an alias for remove - accepts ID string or filter object api.slothlet.hook.off(hookId); api.slothlet.hook.off({ pattern: "math.*" }); // clear() is also an alias for remove api.slothlet.hook.clear({ type: "before" }); api.slothlet.hook.clear(); // Remove all hooks ``` ### Listing ```javascript // List all hooks const all = api.slothlet.hook.list(); // List by type const beforeHooks = api.slothlet.hook.list({ type: "before" }); // List only enabled hooks const active = api.slothlet.hook.list({ enabled: true }); // List by pattern const mathHooks = api.slothlet.hook.list({ pattern: "math.*" }); ``` --- ## Error Handling The `error` hook type receives detailed context about any error in the execution chain: ```javascript api.slothlet.hook.on( "**:error", ({ path, error, source }) => { console.error(`Error in ${path}:`, error.message); console.error(`Source type: ${source.type}`); // "before" | "function" | "after" | "always" | "unknown" if (source.type === "function") { console.error("Error in function body"); } else { console.error(`Error in ${source.type} hook: ${source.hookTag}`); } }, { id: "error-monitor" } ); ``` **Important notes:** - Errors from `before` and `after` hooks are re-thrown after error hooks run (unless `suppressErrors: true`) - Errors from `always` hooks are caught and passed to error hooks, but do NOT re-throw (never crash execution) - Error hooks do not receive errors thrown by other error hooks (no recursion) --- ## Error Source Tracking ### Source Types | `source.type` | Description | | ------------- | --------------------------------- | | `"function"` | Error in the target function body | | `"before"` | Error in a before hook | | `"after"` | Error in an after hook | | `"always"` | Error in an always hook | | `"unknown"` | Source could not be determined | ### Source Properties - `source.type` - source type (see above) - `source.subset` - hook subset where error occurred (for hook errors) - `source.hookId` - ID of the hook that failed (for hook errors) - `source.hookTag` - name/tag of the hook that failed (for hook errors) - `source.timestamp` - epoch millisecond when error occurred - `source.stack` - full stack trace string ### Comprehensive Error Monitoring Example ```javascript const errorStats = { function: 0, before: 0, after: 0, always: 0, byHook: {} }; api.slothlet.hook.on( "**:error", ({ path, error, source }) => { errorStats[source.type] = (errorStats[source.type] || 0) + 1; if (source.hookTag) { errorStats.byHook[source.hookTag] = (errorStats.byHook[source.hookTag] || 0) + 1; } sendToMonitoring({ timestamp: source.timestamp, path, errorType: source.type, hookId: source.hookId, hookTag: source.hookTag, message: error.message, stack: source.stack }); }, { id: "error-analytics" } ); ``` --- ## Sync and Async Function Behavior Hook handlers are **synchronous functions**. Returning a Promise from a before hook throws. Returning a Promise from after/always/error hooks is silently ignored (the return value is unused or treated as the non-async value). The hook system intelligently handles both sync and async _target functions_: **For synchronous functions:** ```text executeBeforeHooks() → fn() → executeAfterHooks() → executeAlwaysHooks() ``` All steps run synchronously in sequence. **For async functions:** ```text executeBeforeHooks() → fn() returns Promise → .then(executeAfterHooks, executeErrorHooks) → executeAlwaysHooks() ``` After, error, and always hooks attach to the Promise chain - they do not block the event loop. This design ensures: - Synchronous functions return synchronous values (no unwanted Promise wrapping) - Async functions process hooks without blocking the event loop - The fundamental contract of all modes is preserved --- ## Caller Identity in Callbacks Slothlet's hook system above governs slothlet's own `before`/`after`/`always`/`error` phases. A related concern is **callbacks you register with a third-party framework** — most commonly a web framework's request hook (e.g. Fastify's `server.addHook("onRequest", …)`). ### Hooks auto-pin caller identity Slothlet's **own** hooks need no manual pinning. A hook handler fires during the API call it intercepts, which belongs to whichever caller triggered that call — not to the module that registered the hook. So by default `hook.on()` pins the **registering module's** caller identity onto the handler: its `self.*` calls and permission checks are attributed to the module that registered the hook, exactly as if the body had been wrapped in `lockCaller`. ```javascript // Registered inside module B's init() — the handler runs as module B, // no matter which caller's API call triggers `math.*`. self.slothlet.hook.on("math.*:before", ({ args }) => { self.audit.record(args); // permission rules keyed to B match return args; }); ``` Pass `{ lockCaller: false }` to opt a hook out — the handler then runs without a pinned identity (and, like any un-pinned callback, may have no slothlet context to resolve `self` against). Opt out only for handlers that do not touch `self`: ```javascript self.slothlet.hook.on("math.*:before", logArgs, { lockCaller: false }); ``` Auto-pinning applies only when the hook is registered from inside a module (there is a caller identity to capture) and the handler is not already `lockCaller`-wrapped. The remaining sections below cover the harder case slothlet **cannot** intercept: callbacks handed to a third-party framework. ### Why array-stored callbacks lose caller identity Slothlet wraps `EventEmitter` listeners with `AsyncResource` so a listener registered by one module always runs with that module's async context. Callbacks stored in **plain arrays**, however, never pass through that patch — `addHook` handlers and similar third-party registries keep their callbacks in ordinary arrays. When such a callback fires it inherits whatever async context is ambient at that moment, which may belong to a **different** module. The failure mode: module B registers an `onRequest` hook. A later request restores module A's async context first (A registered an `upgrade` listener that slothlet pinned to A), the framework then runs the `onRequest` chain, and B's hook executes with the caller identity set to **module A**. A `self.*` call inside B's hook with a permission rule keyed to module B is then denied — the permission system sees module A as the caller. This is not something slothlet can fix generically: `addHook` is a framework mechanism with no interception point. Instead, two opt-in utilities on `self.slothlet` let a module pin its identity onto a callback. ### `self.slothlet.lockCaller(fn)` — pin caller identity, keep context live `lockCaller` captures the registering module's caller identity at call time and, on every invocation of the returned wrapper, overrides **only** the caller identity. The request-scoped context set later in the lifecycle stays live and visible; `self` stays the live proxy. ```javascript import { self } from "@cldmv/slothlet/runtime"; // Inside module B's setup: server.addHook( "onRequest", self.slothlet.lockCaller(async (req, reply) => { // Runs as module B regardless of which module's context is ambient. const ctx = self.slothlet.context.get(); // permission rules keyed to B match // ... }) ); ``` - The locked callback's caller identity is frozen, but the request context stays **live** — context set later in the request lifecycle is visible inside it. - `this` is forwarded, so framework-supplied `this` (the request/reply or the framework instance) is preserved. - Called with no active context (no module wrapper to capture), `lockCaller` is a no-op passthrough — it is meaningful only when called from inside a module. - **Runtime mode matters for async callbacks.** In **async** runtime mode the pinned identity propagates through `AsyncLocalStorage`, so `self.*` calls after an `await` still resolve to the registering module. In **live** runtime mode the caller is pinned only for the **synchronous** portion of the callback — the live context manager restores the previous wrapper as soon as the callback returns its promise. Once the synchronous `runInContext()` stack has unwound there may be **no slothlet caller at all**, so an `async` callback that `await`s before calling `self.*` resumes with whatever context is then active — typically none, and an identity probe sees `unknown`. Live mode keeps no per-async-task context; use **async** runtime mode for `async` hooks (like the example above) that must keep locked identity past their first `await`. `bind` is **not** an escape hatch here — it has the same live-mode limitation (see below); async runtime mode is the only fix. ### `self.slothlet.bind(fn)` — freeze the whole async context `bind` is a convenience re-export of Node's `AsyncResource.bind`. Unlike `lockCaller`, it freezes the **entire** async context captured at registration time — every `AsyncLocalStorage`, including slothlet's caller and request context: ```javascript server.addHook("onRequest", self.slothlet.bind(handler)); ``` Reach for `lockCaller` when you want the caller pinned but request-scoped context live; reach for `bind` when you want the whole context snapshot frozen. Both utilities share the same live-mode limitation: `AsyncResource.bind` only meaningfully captures slothlet's caller/context in **async** runtime mode. In live mode the slothlet store is kept off the `AsyncLocalStorage`, so `bind` degrades to binding whatever other async context exists and does **not** preserve slothlet caller identity past an `await`. `bind` is therefore not a workaround for the live-mode `lockCaller` caveat above — if an `async` callback must keep slothlet identity across awaits, run the instance in **async** runtime mode. > `slothlet.lockCaller` and `slothlet.bind` are permission-gated routes like every other `slothlet.*` member — see [Permissions](PERMISSIONS.md#other-slothlet-routes-are-gated-too). --- ## API Reference ### api.slothlet.hook.on(typePattern, handler, options?) Register a hook. **Parameters:** - `typePattern` (string) - Combined pattern and type, format: `"pattern:type"` (e.g. `"math.*:before"`). The legacy type-first form `"type:pattern"` (e.g. `"before:math.*"`) is deprecated — it still works but emits a deprecation warning and will be removed in v4. - `handler` (Function) - Synchronous hook handler - `options.id` (string, optional) - Unique identifier (auto-generated if omitted) - `options.priority` (number, optional) - Execution priority; higher executes first (default: `0`) - `options.subset` (string, optional) - Execution phase: `"before"`, `"primary"` (default), or `"after"` - `options.lockCaller` (boolean, optional) - Pin the registering module's caller identity onto the handler (default: `true`). See [Hooks auto-pin caller identity](#hooks-auto-pin-caller-identity). **Returns:** string - The hook ID ### api.slothlet.hook.remove(filter?) Remove hooks matching filter criteria. **Parameters:** - `filter.id` (string) - Remove exact hook by ID - `filter.type` (string) - Remove all hooks of this type - `filter.pattern` (string) - Remove all hooks matching this pattern **Returns:** number - Count of hooks removed ### api.slothlet.hook.off(idOrFilter) Alias for `remove()`. Accepts a bare ID string or a filter object. ### api.slothlet.hook.clear(filter?) Alias for `remove()`. ### api.slothlet.hook.enable(filter?) Enable hooks. Empty filter enables all. **Parameters:** Same filter object as `remove()`. **Returns:** number - Count of hooks enabled ### api.slothlet.hook.disable(filter?) Disable hooks without unregistering them. Empty filter disables all. **Returns:** number - Count of hooks disabled ### api.slothlet.hook.enablePattern(pattern) Restrict hook execution to an API path pattern at runtime (the global path filter). Distinct from `enable()`/`disable()`, which toggle individual registered hooks — this narrows which API paths the hook system applies to at all. Once any pattern is enabled the filter is active, and a hook fires only when the called path matches at least one enabled pattern. **Parameters:** - `pattern` (string) - Glob path pattern to restrict execution to (e.g. `"database.*"`) **Returns:** number - Count of patterns now in the active filter ### api.slothlet.hook.disablePattern(pattern) Remove a path pattern from the runtime global path filter. When the last pattern is removed the filter deactivates and hooks apply to every path again. **Returns:** number - Count of patterns remaining in the filter ### api.slothlet.hook.resetPatternFilter() Reset the runtime global path filter back to the configured `hook.pattern` default (fully unrestricted if that default was `"**"`). **Returns:** void ### api.slothlet.hook.list(filter?) List registered hooks matching filter. **Parameters:** - `filter.id`, `filter.type`, `filter.pattern` - As above - `filter.enabled` (boolean) - Filter by enabled state **Returns:** Array of hook objects --- ## See Also - [Context Propagation](CONTEXT-PROPAGATION.md) - `ctx` object available in hook handlers - [README](../README.md) - Main project documentation