Skip to content

Repository files navigation

Hookified

Event Emitting and Middleware Hooks

testsGitHub licensecodecovnpmjsDelivrnpm

Features

  • Simple replacement for EventEmitter
  • Async / Sync Middleware Hooks for Your Methods
  • ESM / CJS with Types
  • Browser Support and Delivered via CDN
  • Ability to throw errors in hooks
  • Ability to pass in a logger (such as Pino) for errors
  • Enforce consistent hook naming conventions with enforceBeforeAfter
  • Deprecation warnings for hooks with deprecatedHooks
  • Control deprecated hook execution with allowDeprecated
  • WaterfallHook for sequential data transformation pipelines
  • ParallelHook for concurrent fan-out execution with collected results
  • No package dependencies and only 250KB in size
  • Fast and Efficient with Benchmarks
  • Maintained on a regular basis!

Table of Contents

Installation

npm install hookified --save

Usage

This was built because we constantly wanted hooks and events extended on libraires we are building such as Keyv and Cacheable. This is a simple way to add hooks and events to your classes.

import{Hookified}from'hookified';classMyClassextendsHookified{constructor(){super();}asyncmyMethodEmittingEvent(){this.emit('message','Hello World');}//with hooks you can pass data in and if they are subscribed via onHook they can modify the dataasyncmyMethodWithHooks(): Promise<any>{letdata={some: 'data'};// do somethingawaitthis.hook('before:myMethod2',data);returndata;}}

You can even pass in multiple arguments to the hooks:

import{Hookified}from'hookified';classMyClassextendsHookified{constructor(){super();}asyncmyMethodWithHooks(): Promise<any>{letdata={some: 'data'};letdata2={some: 'data2'};// do somethingawaitthis.hook('before:myMethod2',data,data2);returndata;}}

Using it in the Browser

<scripttype="module">import{Hookified}from'https://cdn.jsdelivr.net/npm/hookified/dist/browser/index.js';classMyClassextendsHookified{constructor(){super();}asyncmyMethodEmittingEvent(){this.emit('message','Hello World');}//with hooks you can pass data in and if they are subscribed via onHook they can modify the dataasyncmyMethodWithHooks(): Promise<any>{letdata={some: 'data'};// do somethingawaitthis.hook('before:myMethod2',data);returndata;}}</script>

if you are not using ESM modules, you can use the following:

<scriptsrc="https://cdn.jsdelivr.net/npm/hookified/dist/browser/index.global.js"></script><script>classMyClassextendsHookified{constructor(){super();}asyncmyMethodEmittingEvent(){this.emit('message','Hello World');}//with hooks you can pass data in and if they are subscribed via onHook they can modify the dataasyncmyMethodWithHooks(): Promise<any>{letdata={some: 'data'};// do somethingawaitthis.hook('before:myMethod2',data);returndata;}}</script>

Hooks

Standard Hook

The Hook class provides a convenient way to create hook entries. It implements the IHook interface.

The IHook interface has the following properties:

PropertyTypeRequiredDescription
idstringNoUnique identifier for the hook. Auto-generated via crypto.randomUUID() if not provided.
eventstringYesThe event name for the hook.
handlerHookFnYesThe handler function for the hook.

When a hook is registered, it is assigned an id (auto-generated if not provided). The id can be used to look up or remove hooks via getHook and removeHookById. If you register a hook with the same id on the same event, it will replace the existing hook in-place (preserving its position).

Using the Hook class:

import{Hook,Hookified}from'hookified';classMyClassextendsHookified{constructor(){super();}}constmyClass=newMyClass();// Without id (auto-generated)consthook=newHook('before:save',async(data)=>{data.validated=true;});// With idconsthook2=newHook('after:save',async(data)=>{console.log('saved');},'my-after-save-hook');// Register with onHookmyClass.onHook(hook);// Or register multiple hooks with onHooksconsthooks=[newHook('before:save',async(data)=>{data.validated=true;}),newHook('after:save',async(data)=>{console.log('saved');}),];myClass.onHooks(hooks);// Remove hooksmyClass.removeHooks(hooks);

Using plain TypeScript with the IHook interface:

import{Hookified,typeIHook}from'hookified';classMyClassextendsHookified{constructor(){super();}}constmyClass=newMyClass();consthook: IHook={id: 'my-validation-hook',// optional β€” auto-generated if omittedevent: 'before:save',handler: async(data)=>{data.validated=true;},};conststored=myClass.onHook(hook);console.log(stored?.id);// 'my-validation-hook'// Later, remove by idmyClass.removeHookById('my-validation-hook');

Waterfall Hook

The WaterfallHook class chains multiple hook functions sequentially in a waterfall pipeline. Each hook receives a context containing the original arguments and the accumulated results from all previous hooks. It implements the IHook interface, so it integrates directly with Hookified.onHook().

The WaterfallHookContext has the following properties:

PropertyTypeDescription
initialArgsanyThe original arguments passed to the waterfall execution.
resultsWaterfallHookResult[]Array of { hook, result } entries from previous hooks. Empty for the first hook.

Basic usage:

import{WaterfallHook}from'hookified';constwh=newWaterfallHook('process',({ results, initialArgs })=>{// Final handler receives all accumulated resultsconstlastResult=results[results.length-1].result;console.log('Final:',lastResult);});// Add transformation hooks to the pipelinewh.addHook(({ initialArgs })=>{returninitialArgs+1;// 5 -> 6});wh.addHook(({ results })=>{returnresults[results.length-1].result*2;// 6 -> 12});// Execute the waterfall by calling handler directlyawaitwh.handler(5);// Final: 12

Integrating with Hookified via onHook():

import{Hookified,WaterfallHook}from'hookified';classMyClassextendsHookified{constructor(){super();}}constmyClass=newMyClass();constwh=newWaterfallHook('save',({ results })=>{constdata=results[results.length-1].result;console.log('Saved:',data);});wh.addHook(({ initialArgs })=>{return{ ...initialArgs,validated: true};});wh.addHook(({ results })=>{return{ ...results[results.length-1].result,timestamp: Date.now()};});// Register with Hookified β€” works because WaterfallHook implements IHookmyClass.onHook(wh);// When hook() fires, the full waterfall pipeline executesawaitmyClass.hook('save',{name: 'test'});// Saved: { name: 'test', validated: true, timestamp: ... }

Managing hooks:

constwh=newWaterfallHook('process',({ results })=>results);constmyHook=({ initialArgs })=>initialArgs+1;wh.addHook(myHook);// Remove a hook by referencewh.removeHook(myHook);// returns true// Access the hooks arrayconsole.log(wh.hooks.length);// 0

Parallel Hook

The ParallelHook class fans a single invocation out to many registered hook functions concurrently via Promise.allSettled, then calls a final handler with the aggregated outcomes β€” including failures. Unlike WaterfallHook, hooks do not see each other's results: every hook receives the same initialArgs and runs in parallel. It implements the IHook interface, so it integrates directly with Hookified.onHook(), and the final handler still fires whether the hook is invoked directly or through Hookified.hook().

Per-hook functions receive a ParallelHookContext:

PropertyTypeDescription
initialArgsanyThe original arguments passed to handler(). Single argument stays as-is; multiple arguments become an array.

The final handler receives a ParallelHookFinalContext:

PropertyTypeDescription
initialArgsanySame value passed to every hook.
resultsMap<ParallelHookFn, ParallelHookResult>One entry per registered hook, keyed by the hook function reference for direct lookup. Iteration order matches registration order.

Each ParallelHookResult value is a discriminated union β€” failures are reported, not thrown:

FieldTypeDescription
status"fulfilled" | "rejected"Discriminator.
resultTResultPresent when status === "fulfilled". The value the hook returned. Defaults to any; tighten via the ParallelHook<TArgs, TResult> generic.
reasonunknownPresent when status === "rejected". The error or value the hook threw. Stays unknown regardless of the result generic, since errors in JS aren't typed.

Basic usage with Hookified:

import{Hookified,ParallelHook}from'hookified';classMyClassextendsHookified{constructor(){super();}}constmyClass=newMyClass();constsendEmailHook=async({ initialArgs })=>sendEmail(initialArgs);constsendSlackHook=async({ initialArgs })=>sendSlack(initialArgs);constsendWebhookHook=async({ initialArgs })=>sendWebhook(initialArgs);constph=newParallelHook('notify',({ results })=>{// Look up a specific hook's outcome by referenceconstemailOutcome=results.get(sendEmailHook);if(emailOutcome?.status==='rejected'){console.error('email failed:',emailOutcome.reason);}// Or iterate every result in registration orderfor(const[hook,r]ofresults){if(r.status==='fulfilled'){console.log('ok:',r.result);}else{console.error('failed:',r.reason);}}});ph.addHook(sendEmailHook);ph.addHook(sendSlackHook);ph.addHook(sendWebhookHook);// Register with Hookified β€” works because ParallelHook implements IHookmyClass.onHook(ph);// All three notification hooks fire concurrently, then the final handler runsawaitmyClass.hook('notify',{user: 'alice',message: 'hi'});

Tightening the result type:

When every hook returns the same shape, pass generics so result is fully typed instead of any:

import{ParallelHook}from'hookified';typeNotifyArgs={user: string;message: string};typeNotifyResult={channel: string;messageId: string};constph=newParallelHook<NotifyArgs,NotifyResult>('notify',({ results })=>{for(const[,r]ofresults){if(r.status==='fulfilled'){console.log(`${r.result.channel}: ${r.result.messageId}`);}else{console.error(r.reason);// still `unknown` β€” errors aren't typed}}});ph.addHook(async({ initialArgs })=>({channel: 'email',messageId: '1'}));

Managing hooks:

constph=newParallelHook('process',({ results })=>{console.log(results.size);// number of hooks that ran});constmyHook=({ initialArgs })=>initialArgs+1;ph.addHook(myHook);// Remove a hook by referenceph.removeHook(myHook);// returns true// Access the hooks arrayconsole.log(ph.hooks.length);// 0

API - Hooks

All examples below assume the following setup unless otherwise noted:

import{Hookified}from'hookified';classMyClassextendsHookified{constructor(options){super(options);}}constmyClass=newMyClass();

.allowDeprecated

Controls whether deprecated hooks are allowed to be registered and executed. Default is true. When set to false, deprecated hooks will still emit warnings but will be prevented from registration and execution.

import{Hookified}from'hookified';constdeprecatedHooks=newMap([['oldHook','Use newHook instead']]);classMyClassextendsHookified{constructor(){super({ deprecatedHooks,allowDeprecated: false});}}constmyClass=newMyClass();console.log(myClass.allowDeprecated);// false// Listen for deprecation warnings (still emitted even when blocked)myClass.on('warn',(event)=>{console.log(`Warning: ${event.message}`);});// Try to register a deprecated hook - will emit warning but not registermyClass.onHook({event: 'oldHook',handler: ()=>{console.log('This will never execute');}});// Output: Warning: Hook "oldHook" is deprecated: Use newHook instead// Verify hook was not registeredconsole.log(myClass.getHooks('oldHook'));// undefined// Try to execute a deprecated hook - will emit warning but not executeawaitmyClass.hook('oldHook');// Output: Warning: Hook "oldHook" is deprecated: Use newHook instead// (but no handlers execute)// Non-deprecated hooks work normallymyClass.onHook({event: 'validHook',handler: ()=>{console.log('This works fine');}});console.log(myClass.getHooks('validHook'));// [handler function]// You can dynamically change the settingmyClass.allowDeprecated=true;// Now deprecated hooks can be registered and executedmyClass.onHook({event: 'oldHook',handler: ()=>{console.log('Now this works');}});console.log(myClass.getHooks('oldHook'));// [handler function]

Behavior when allowDeprecated is false:

  • Registration: All hook registration methods (onHook, addHook, prependHook, etc.) will emit warnings but skip registration
  • Execution: Hook execution methods (hook, callHook) will emit warnings but skip execution
  • Removal/Reading: removeHook, removeHooks, and getHooks always work regardless of deprecation status
  • Warnings: Deprecation warnings are always emitted regardless of allowDeprecated setting

Use cases:

  • Development: Keep allowDeprecated: true to maintain functionality while seeing warnings
  • Testing: Set allowDeprecated: false to ensure no deprecated hooks are accidentally used
  • Migration: Gradually disable deprecated hooks during API transitions
  • Production: Disable deprecated hooks to prevent legacy code execution

.deprecatedHooks

A Map of deprecated hook names to deprecation messages. When a deprecated hook is used, a warning will be emitted via the 'warn' event and logged to the logger (if available). Default is an empty Map.

import{Hookified}from'hookified';// Define deprecated hooks with custom messagesconstdeprecatedHooks=newMap([['oldHook','Use newHook instead'],['legacyMethod','This hook will be removed in v2.0'],['deprecatedFeature','']// Empty message - will just say "deprecated"]);classMyClassextendsHookified{constructor(){super({ deprecatedHooks });}}constmyClass=newMyClass();console.log(myClass.deprecatedHooks);// Map with deprecated hooks// Listen for deprecation warningsmyClass.on('warn',(event)=>{console.log(`Deprecation warning: ${event.message}`);// event.hook contains the hook name// event.message contains the full warning message});// Using a deprecated hook will emit warningsmyClass.onHook({event: 'oldHook',handler: ()=>{console.log('This hook is deprecated');}});// Output: Hook "oldHook" is deprecated: Use newHook instead// Using a deprecated hook with empty messagemyClass.onHook({event: 'deprecatedFeature',handler: ()=>{console.log('This hook is deprecated');}});// Output: Hook "deprecatedFeature" is deprecated// You can also set deprecated hooks dynamicallymyClass.deprecatedHooks.set('anotherOldHook','Please migrate to the new API');// Works with logger if providedimportpinofrom'pino';constlogger=pino();constmyClassWithLogger=newHookified({
deprecatedHooks,eventLogger: logger});// Deprecation warnings will be logged to logger.warn

The deprecation warning system applies to the following hook-related methods:

  • Registration: onHook(), addHook(), onHooks(), prependHook(), onceHook(), prependOnceHook()
  • Execution: hook(), callHook()

Note: getHooks(), removeHook(), and removeHooks() do not check for deprecated hooks and always operate normally.

Deprecation warnings are emitted in two ways:

  1. Event: A 'warn' event is emitted with { hook: string, message: string }
  2. Logger: Logged to eventLogger.warn() if an eventLogger is configured and has a warn method

.enforceBeforeAfter

If set to true, enforces that all hook names must start with 'before' or 'after'. This is useful for maintaining consistent hook naming conventions in your application. Default is false.

import{Hookified}from'hookified';classMyClassextendsHookified{constructor(){super({enforceBeforeAfter: true});}}constmyClass=newMyClass();console.log(myClass.enforceBeforeAfter);// true// These will work finemyClass.onHook({event: 'beforeSave',handler: async()=>{console.log('Before save hook');}});myClass.onHook({event: 'afterSave',handler: async()=>{console.log('After save hook');}});myClass.onHook({event: 'before:validation',handler: async()=>{console.log('Before validation hook');}});// This will throw an errortry{myClass.onHook({event: 'customEvent',handler: async()=>{console.log('This will not work');}});}catch(error){console.log(error.message);// Hook event "customEvent" must start with "before" or "after" when enforceBeforeAfter is enabled}// You can also change it dynamicallymyClass.enforceBeforeAfter=false;myClass.onHook({event: 'customEvent',handler: async()=>{console.log('This will work now');}});

The validation applies to all hook-related methods:

  • onHook(), addHook(), onHooks()
  • prependHook(), onceHook(), prependOnceHook()
  • hook(), callHook()
  • getHooks(), removeHook(), removeHooks()

Note: The beforeHook() and afterHook() helper methods automatically generate proper hook names and work regardless of the enforceBeforeAfter setting.

.eventLogger

If set, errors thrown in hooks will be logged to the logger. If not set, errors will be only emitted.

importpinofrom'pino';constmyClass=newMyClass({eventLogger: pino()});myClass.onHook({event: 'before:myMethod2',handler: async()=>{thrownewError('error');}});// when you call before:myMethod2 it will log the error to the loggerawaitmyClass.hook('before:myMethod2');

.hooks

Get all hooks. Returns a Map<string, IHook[]> where each key is an event name and the value is an array of IHook objects.

myClass.onHook({event: 'before:myMethod2',handler: async(data)=>{data.some='new data';}});console.log(myClass.hooks);// Map { 'before:myMethod2' => [{ event: 'before:myMethod2', handler: [Function] }] }

.throwOnHookError

If set to true, errors thrown in hooks will be thrown. If set to false, errors will be only emitted.

constmyClass=newMyClass({throwOnHookError: true});console.log(myClass.throwOnHookError);// truetry{myClass.onHook({event: 'error-event',handler: async()=>{thrownewError('error');}});awaitmyClass.hook('error-event');}catch(error){console.log(error.message);// error}myClass.throwOnHookError=false;console.log(myClass.throwOnHookError);// false

.useHookClone

Controls whether hook objects are cloned before storing internally. Default is true. When true, a shallow copy of the IHook object is stored, preventing external mutation from affecting registered hooks. When false, the original reference is stored directly.

constmyClass=newMyClass({useHookClone: false});consthook={event: 'before:save',handler: async(data)=>{}};myClass.onHook(hook);// With useHookClone: false, the stored hook is the same referenceconststoredHooks=myClass.getHooks('before:save');console.log(storedHooks[0]===hook);// true// You can dynamically change the settingmyClass.useHookClone=true;

.addHook(event, handler)

This is an alias for .onHook() that takes an event name and handler function directly.

myClass.addHook('before:myMethod2',async(data)=>{data.some='new data';});

.afterHook(eventName, ...args)

This is a helper function that will prepend a hook name with after:.

// Inside your class method β€” the event name will be `after:myMethod2`awaitthis.afterHook('myMethod2',data);

.beforeHook(eventName, ...args)

This is a helper function that will prepend a hook name with before:.

// Inside your class method β€” the event name will be `before:myMethod2`awaitthis.beforeHook('myMethod2',data);

.callHook(eventName, ...args)

This is an alias for .hook(eventName, ...args) for backwards compatibility.

.clearHooks()

Clear all hooks across all events.

myClass.onHook({event: 'before:myMethod2',handler: async(data)=>{data.some='new data';}});myClass.clearHooks();

.getHook(id)

Get a specific hook by id, searching across all events. Returns the IHook if found, or undefined.

constmyClass=newMyClass();myClass.onHook({id: 'my-hook',event: 'before:save',handler: async(data)=>{data.validated=true;},});consthook=myClass.getHook('my-hook');console.log(hook?.id);// 'my-hook'console.log(hook?.event);// 'before:save'console.log(hook?.handler);// [Function]

.getHooks(eventName)

Get all hooks for an event. Returns an IHook[] array, or undefined if no hooks are registered for the event.

myClass.onHook({event: 'before:myMethod2',handler: async(data)=>{data.some='new data';}});console.log(myClass.getHooks('before:myMethod2'));// [{ event: 'before:myMethod2', handler: [Function] }]

.hook(eventName, ...args)

Run a hook event.

// Inside your class methodawaitthis.hook('before:myMethod2',data);

You can pass multiple arguments to the hook:

// Inside your class methodawaitthis.hook('before:myMethod2',data,data2);// The handler receives all argumentsmyClass.onHook({event: 'before:myMethod2',handler: async(data,data2)=>{data.some='new data';data2.some='new data2';}});

.hookSync(eventName, ...args)

Run a hook event synchronously. Async handlers (functions declared with async keyword) are silently skipped and only synchronous handlers are executed.

Note: The .hook() method is preferred as it executes both sync and async functions. Use .hookSync() only when you specifically need synchronous execution.

// This sync handler will executemyClass.onHook({event: 'before:myMethod',handler: (data)=>{data.some='modified';}});// This async handler will be silently skippedmyClass.onHook({event: 'before:myMethod',handler: async(data)=>{data.some='will not run';}});// Inside your class methodthis.hookSync('before:myMethod',data);// Only sync handler runs

.onHook(hook, options?) / .onHook(event, handler)

Subscribe to a hook event. Supports two calling styles:

  • Object form: onHook(hook, options?) β€” takes an IHook object and an optional OnHookOptions object.
  • Shorthand form: onHook(event, handler) β€” takes an event name string and a handler function (v1-compatible).

Returns the stored IHook (with id assigned), or undefined if the hook was blocked by deprecation. The returned reference is the exact object stored internally, which is useful for later removal with .removeHook() or .removeHookById(). To register multiple hooks at once, use .onHooks().

If the hook has an id, it will be used as-is. If not, a UUID is auto-generated via crypto.randomUUID(). If a hook with the same id already exists on the same event, it will be replaced in-place (preserving its position in the array).

Options (OnHookOptions):

  • useHookClone (boolean, optional) β€” Per-call override for the instance-level useHookClone setting. When true, the hook object is cloned before storing. When false, the original reference is stored directly. When omitted, falls back to the instance-level setting.
  • position ("Top" | "Bottom" | number, optional) β€” Controls where the hook is inserted in the handlers array. "Top" inserts at the beginning, "Bottom" appends to the end (default). A number inserts at that index, clamped to the array bounds.
// Single hook β€” returns the stored IHook with idconststored=myClass.onHook({event: 'before:myMethod2',handler: async(data)=>{data.some='new data';},});console.log(stored.id);// auto-generated UUID// With a custom idconststored2=myClass.onHook({id: 'my-validation',event: 'before:save',handler: async(data)=>{data.validated=true;},});// Replace hook by registering with the same idmyClass.onHook({id: 'my-validation',event: 'before:save',handler: async(data)=>{data.validated=true;data.extra=true;},});// Only one hook with id 'my-validation' exists, at the same position// Remove by idmyClass.removeHookById('my-validation');// Use the returned reference to remove the hook latermyClass.removeHook(stored);// Override useHookClone per-call β€” store original reference even though instance default is trueconsthook={event: 'before:save',handler: async(data)=>{}};myClass.onHook(hook,{useHookClone: false});console.log(myClass.getHooks('before:save')[0]===hook);// true// Insert at the top of the handlers arraymyClass.onHook({event: 'before:save',handler: async(data)=>{}},{position: 'Top'});// Insert at a specific indexmyClass.onHook({event: 'before:save',handler: async(data)=>{}},{position: 1});// Shorthand form β€” pass event name and handler directly (v1-compatible)myClass.onHook('before:save',async(data)=>{data.validated=true;});

.onHooks(Array, options?)

Subscribe to multiple hook events at once. Takes an array of IHook objects and an optional OnHookOptions object that is applied to each hook.

consthooks=[{event: 'before:myMethodWithHooks',handler: async(data)=>{data.some='new data1';},},{event: 'after:myMethodWithHooks',handler: async(data)=>{data.some='new data2';},},];myClass.onHooks(hooks);// With options β€” insert all hooks at the topmyClass.onHooks(hooks,{position: 'Top'});// With options β€” skip cloning for all hooks in this batchmyClass.onHooks(hooks,{useHookClone: false});

.onceHook(hook)

Subscribe to a hook event once. Takes an IHook object with event and handler properties. After the handler is called once, it is automatically removed.

myClass.onceHook({event: 'before:myMethod2',handler: async(data)=>{data.some='new data';}});awaitmyClass.hook('before:myMethod2',data);// handler runs once then is removedconsole.log(myClass.hooks.size);// 0

.prependHook(hook, options?)

Subscribe to a hook event before all other hooks. Takes an IHook object with event and handler properties. Returns the stored IHook (with generated id), or undefined if blocked by deprecation. Equivalent to calling onHook(hook, { position: "Top" }).

An optional PrependHookOptions object can be passed with:

  • useHookClone (boolean) β€” per-call override for hook cloning behavior
myClass.onHook({event: 'before:myMethod2',handler: async(data)=>{data.some='new data';}});myClass.prependHook({event: 'before:myMethod2',handler: async(data)=>{data.some='will run before new data';}});

.prependOnceHook(hook, options?)

Subscribe to a hook event before all other hooks. Takes an IHook object with event and handler properties. After the handler is called once, it is automatically removed. Returns the stored IHook (with generated id), or undefined if blocked by deprecation.

An optional PrependHookOptions object can be passed with:

  • useHookClone (boolean) β€” per-call override for hook cloning behavior
myClass.onHook({event: 'before:myMethod2',handler: async(data)=>{data.some='new data';}});myClass.prependOnceHook({event: 'before:myMethod2',handler: async(data)=>{data.some='will run before new data';}});

.removeEventHooks(eventName)

Removes all hooks for a specific event and returns the removed hooks as an IHook[] array. Returns an empty array if no hooks are registered for the event.

myClass.onHook({event: 'before:myMethod2',handler: async(data)=>{data.some='new data';}});myClass.onHook({event: 'before:myMethod2',handler: async(data)=>{data.some='more data';}});// Remove all hooks for a specific eventconstremoved=myClass.removeEventHooks('before:myMethod2');console.log(removed.length);// 2

.removeHook(hook)

Unsubscribe a handler from a hook event. Takes an IHook object with event and handler properties. Returns the removed hook as an IHook object, or undefined if the handler was not found.

consthandler=async(data)=>{data.some='new data';};myClass.onHook({event: 'before:myMethod2', handler });constremoved=myClass.removeHook({event: 'before:myMethod2', handler });console.log(removed);// { event: 'before:myMethod2', handler: [Function] }

.removeHookById(id)

Remove one or more hooks by id, searching across all events. Accepts a single string or an array of string ids.

  • Single id: Returns the removed IHook, or undefined if not found.
  • Array of ids: Returns an IHook[] array of the hooks that were successfully removed.

When the last hook for an event is removed, the event key is cleaned up.

constmyClass=newMyClass();myClass.onHook({id: 'hook-a',event: 'before:save',handler: async()=>{}});myClass.onHook({id: 'hook-b',event: 'after:save',handler: async()=>{}});myClass.onHook({id: 'hook-c',event: 'before:save',handler: async()=>{}});// Remove a single hook by idconstremoved=myClass.removeHookById('hook-a');console.log(removed?.id);// 'hook-a'// Remove multiple hooks by idsconstremovedMany=myClass.removeHookById(['hook-b','hook-c']);console.log(removedMany.length);// 2

.removeHooks(Array)

Unsubscribe from multiple hooks. Returns an array of the hooks that were successfully removed.

consthooks=[{event: 'before:save',handler: async(data)=>{data.some='new data1';}},{event: 'after:save',handler: async(data)=>{data.some='new data2';}},];myClass.onHooks(hooks);constremoved=myClass.removeHooks(hooks);console.log(removed.length);// 2

API - Events

All examples below assume the following setup unless otherwise noted:

import{Hookified}from'hookified';classMyClassextendsHookified{constructor(options){super(options);}}constmyClass=newMyClass();

.throwOnEmitError

If set to true, errors emitted as error will always be thrown, even if there are listeners. If set to false (default), errors will only be emitted to listeners.

constmyClass=newMyClass({throwOnEmitError: true});myClass.on('error',(err)=>{console.log('listener received:',err.message);});try{myClass.emit('error',newError('This will throw despite having a listener'));}catch(error){console.log(error.message);// This will throw despite having a listener}

.throwOnEmptyListeners

If set to true, errors will be thrown when emitting an error event with no listeners. This follows the standard Node.js EventEmitter behavior. Default is true.

constmyClass=newMyClass({throwOnEmptyListeners: true});console.log(myClass.throwOnEmptyListeners);// true (default)// This will throw because there are no error listenerstry{myClass.emit('error',newError('Something went wrong'));}catch(error){console.log(error.message);// Something went wrong}// Add an error listener - now it won't throwmyClass.on('error',(error)=>{console.log('Error caught:',error.message);});myClass.emit('error',newError('This will be caught'));// No throw, listener handles it// You can also change it dynamicallymyClass.throwOnEmptyListeners=false;console.log(myClass.throwOnEmptyListeners);// false

Difference between throwOnEmitError and throwOnEmptyListeners:

  • throwOnEmitError: Throws when emitting 'error' event every time.
  • throwOnEmptyListeners: Throws only when there are NO error listeners registered

When both are set to true, throwOnEmitError takes precedence.

.on(eventName, handler)

Subscribe to an event.

myClass.on('message',(message)=>{console.log(message);});

.off(eventName, handler)

Unsubscribe from an event.

consthandler=(message)=>{console.log(message);};myClass.on('message',handler);myClass.off('message',handler);

.emit(eventName, ...args)

Emit an event.

myClass.emit('message','Hello World');

.listeners(eventName)

Get all listeners for an event.

myClass.on('message',(message)=>{console.log(message);});console.log(myClass.listeners('message'));

.removeAllListeners(eventName)

Remove all listeners for an event.

myClass.on('message',(message)=>{console.log(message);});myClass.removeAllListeners('message');

.setMaxListeners(maxListeners: number)

Set the maximum number of listeners for a single event. Default is 0 (unlimited). Negative values are treated as 0. Setting to 0 disables the limit and the warning. When the limit is exceeded, a MaxListenersExceededWarning is emitted via console.warn but the listener is still added. This matches standard Node.js EventEmitter behavior.

myClass.setMaxListeners(1);myClass.on('message',(message)=>{console.log(message);});myClass.on('message',(message)=>{console.log(message);});// warning emitted but listener is still addedconsole.log(myClass.listenerCount('message'));// 2

.once(eventName, handler)

Subscribe to an event once.

myClass.once('message',(message)=>{console.log(message);});myClass.emit('message','Hello World');// handler runsmyClass.emit('message','Hello World');// handler does not run

.prependListener(eventName, handler)

Prepend a listener to an event. This will be called before any other listeners.

myClass.prependListener('message',(message)=>{console.log(message);});

.prependOnceListener(eventName, handler)

Prepend a listener to an event once. This will be called before any other listeners.

myClass.prependOnceListener('message',(message)=>{console.log(message);});myClass.emit('message','Hello World');

.eventNames()

Get all event names.

myClass.on('message',(message)=>{console.log(message);});console.log(myClass.eventNames());// ['message']

.listenerCount(eventName?)

Get the count of listeners for an event or all events if eventName not provided.

myClass.on('message',(message)=>{console.log(message);});console.log(myClass.listenerCount('message'));// 1

.rawListeners(eventName?)

Get all listeners for an event or all events if eventName not provided.

myClass.on('message',(message)=>{console.log(message);});console.log(myClass.rawListeners('message'));

Logging

Hookified integrates logging directly into the event system. When an eventLogger is configured, all emitted events are automatically logged to the appropriate log level based on the event name.

How It Works

When you emit an event, Hookified automatically sends the event data to the configured eventLogger using the appropriate log method:

Event NameLogger Method
erroreventLogger.error()
warneventLogger.warn()
debugeventLogger.debug()
traceeventLogger.trace()
fataleventLogger.fatal()
Any othereventLogger.info()

The logger receives two arguments:

  1. message: A string extracted from the event data (error messages, object messages, or JSON stringified data)
  2. context: An object containing { event: eventName, data: originalData }

Setting Up a Logger

Any logger that implements the Logger interface is compatible. This includes popular loggers like Pino, Winston, Bunyan, and others.

typeLogger={trace: (message: string, ...args: unknown[])=>void;debug: (message: string, ...args: unknown[])=>void;info: (message: string, ...args: unknown[])=>void;warn: (message: string, ...args: unknown[])=>void;error: (message: string, ...args: unknown[])=>void;fatal: (message: string, ...args: unknown[])=>void;};

Usage Example with Pino

import{Hookified}from'hookified';importpinofrom'pino';constlogger=pino();classMyServiceextendsHookified{constructor(){super({eventLogger: logger});}asyncprocessData(data){// This will log to logger.info with the datathis.emit('info',{action: 'processing', data });try{// ... process datathis.emit('debug',{action: 'completed',result: 'success'});}catch(err){// This will log to logger.error with the error messagethis.emit('error',err);}}}constservice=newMyService();// All events are automatically loggedservice.emit('info','Service started');// -> logger.info()service.emit('warn',{message: 'Low memory'});// -> logger.warn()service.emit('error',newError('Failed'));// -> logger.error()service.emit('custom-event',{foo: 'bar'});// -> logger.info() (default)

You can also set or change the eventLogger after instantiation:

constservice=newMyService();service.eventLogger=pino({level: 'debug'});// Or remove the eventLoggerservice.eventLogger=undefined;

Benchmarks

We are doing very simple benchmarking to see how this compares to other libraries using tinybench. This is not a full benchmark but just a simple way to see how it performs.

Hooks

namesummaryops/sectime/opmarginsamples
Hookified (v2.0.1)πŸ₯‡5M221nsΒ±0.01%5M
Hookable (v6.0.1)-59%2M569nsΒ±0.01%2M

Emits

This shows how on par hookified is to the native EventEmitter and popular eventemitter3. These are simple emitting benchmarks to see how it performs. Our goal is to be as close or better than the other libraries including native (EventEmitter).

namesummaryops/sectime/opmarginsamples
Hookified (v2.1.0)πŸ₯‡17M73nsΒ±0.02%14M
EventEmitter3 (v5.0.4)-2.2%17M70nsΒ±0.02%14M
EventEmitter (v24.14.0)-4.5%16M70nsΒ±0.02%14M
Emittery (v2.0.0)-92%1M792nsΒ±0.01%1M

Note: the EventEmitter version is Nodejs versioning.

Migrating from v2 to v3

v3 has no API changes. The only breaking change is the minimum Node.js version requirement.

Breaking Changes

ChangeSummary
Node.js versionMinimum required version is now >=22.18.0 (previously no engines constraint)

Node.js >=22.18.0 required

The engines field in package.json now requires Node.js 22.18.0 or later. Node.js 20 reached end-of-life in April 2026.

Migration: Upgrade to Node.js 22 LTS or Node.js 24+. No code changes are required β€” the library API is identical to v2.

Migrating from v1 to v2

Quick Guide

v2 overhauls hook storage to use IHook objects instead of raw functions. This enables hook IDs, ordering via position, cloning control, and new hook types like WaterfallHook. onHook now supports both the v1 positional (event, handler) form and the new IHook object form, so this is not a breaking change:

// v1 style β€” still workshookified.onHook('before:save',async(data)=>{});// v2 style β€” IHook object with options supporthookified.onHook({event: 'before:save',handler: async(data)=>{}});// addHook also works as an alias for the positional formhookified.addHook('before:save',async(data)=>{});

Other common changes:

v1v2
throwHookErrorsthrowOnHookError
loggereventLogger
onHookEntry(hook)onHook(hook)
HookEntry typeIHook interface
Hook type (fn)HookFn type
getHooks() returns HookFn[]getHooks() returns IHook[]
removeHook(event, handler)removeHook({ event, handler })

See below for full details on each change.

Breaking Changes

New Features

Breaking Changes

ChangeSummary
throwHookErrorsRenamed to throwOnHookError
throwOnEmptyListenersDefault changed from false to true
loggerRenamed to eventLogger
maxListenersDefault changed from 100 to 0 (unlimited), no longer truncates
onHookEntryRemoved β€” use onHook instead
onHook signatureNow takes IHook object or(event, handler) β€” both supported (not breaking)
HookEntry / Hook typesReplaced with IHook / HookFn
removeHook / removeHooksNow return removed hooks; no longer check deprecated status
Internal hook storageUses IHook objects instead of raw functions
onceHook, prependHook, etc.Now take IHook instead of (event, handler)
onHook returnNow returns stored IHook (was void)

throwHookErrors removed β€” use throwOnHookError instead

The deprecated throwHookErrors option and property has been removed. Use throwOnHookError instead.

Before (v1):

super({throwHookErrors: true});myClass.throwHookErrors=false;

After (v2):

super({throwOnHookError: true});myClass.throwOnHookError=false;

throwOnEmptyListeners now defaults to true

The throwOnEmptyListeners option now defaults to true, matching standard Node.js EventEmitter behavior. Previously it defaulted to false. If you emit an error event with no listeners registered, an error will now be thrown by default.

Before (v1):

constmyClass=newMyClass();// throwOnEmptyListeners defaults to falsemyClass.emit('error',newError('No throw'));// silently ignored

After (v2):

constmyClass=newMyClass();// throwOnEmptyListeners defaults to truemyClass.emit('error',newError('This will throw'));// throws!// To restore v1 behavior:constmyClass2=newMyClass({throwOnEmptyListeners: false});

logger renamed to eventLogger

The logger option and property has been renamed to eventLogger to avoid conflicts with other logger properties in your classes.

Before (v1):

super({ logger });myClass.logger=pino({level: 'debug'});

After (v2):

super({eventLogger: logger});myClass.eventLogger=pino({level: 'debug'});

maxListeners default changed from 100 to 0 (unlimited) and no longer truncates

The default maximum number of listeners has changed from 100 to 0 (unlimited). The MaxListenersExceededWarning will no longer be emitted unless you explicitly set a limit via setMaxListeners(). Additionally, setMaxListeners() no longer truncates existing listeners β€” it only sets the warning threshold, matching standard Node.js EventEmitter behavior.

Before (v1):

constmyClass=newMyClass();// maxListeners defaults to 100// Warning emitted after adding 100+ listeners to the same event// setMaxListeners() would truncate existing listeners exceeding the limit

After (v2):

constmyClass=newMyClass();// maxListeners defaults to 0 (unlimited)// No warning β€” unlimited listeners allowed// setMaxListeners() only sets warning threshold, never removes listeners// To restore v1 warning behavior:myClass.setMaxListeners(100);

onHookEntry removed β€” use onHook instead

The onHookEntry method has been removed. Use onHook which now accepts an IHook object (or array of IHook) directly.

Before (v1):

hookified.onHookEntry({event: 'before:save',handler: async(data)=>{}});

After (v2):

hookified.onHook({event: 'before:save',handler: async(data)=>{}});

onHook signature updated

onHook now supports both the v1 positional (event, handler) form and the new IHook object form. This is not a breaking change β€” existing v1 code continues to work. The IHook object form is recommended for new code as it supports hook IDs, positioning, and cloning options. You can also use addHook(event, handler) as an alias or onHooks() for bulk registration.

// v1 style β€” still workshookified.onHook('before:save',async(data)=>{});// v2 style β€” IHook object with full options supporthookified.onHook({event: 'before:save',handler: async(data)=>{}});// For multiple hooks, use onHookshookified.onHooks([{event: 'before:save',handler: async(data)=>{}},{event: 'after:save',handler: async(data)=>{}},]);// addHook also works as an alias for positional argshookified.addHook('before:save',async(data)=>{});

HookEntry type and Hook type removed

The HookEntry type has been removed and replaced with the IHook interface. The Hook type (function type) has been renamed to HookFn.

Before (v1):

importtype{HookEntry,Hook}from'hookified';consthook: HookEntry={event: 'before:save',handler: async()=>{}};constmyHook: Hook=async(data)=>{};

After (v2):

importtype{IHook,HookFn}from'hookified';consthook: IHook={event: 'before:save',handler: async()=>{}};constmyHook: HookFn=async(data)=>{};

removeHook and removeHooks now return removed hooks

removeHook now returns the removed hook as an IHook object (or undefined if not found). removeHooks now returns an IHook[] array of the hooks that were successfully removed. Previously both returned void.

Before (v1):

hookified.removeHook('before:save',handler);// voidhookified.removeHooks(hooks);// void

After (v2):

constremoved=hookified.removeHook({event: 'before:save', handler });// IHook | undefinedconstremovedHooks=hookified.removeHooks(hooks);// IHook[]

removeHook, removeHooks, and getHooks no longer check for deprecated hooks

Previously, removeHook, removeHooks, and getHooks would skip their operation and emit a deprecation warning when called with a deprecated hook name and allowDeprecated was false. This made it impossible to clean up or inspect deprecated hooks. These methods now always operate regardless of deprecation status.

Internal hook storage now uses IHook objects

The internal _hooks map now stores full IHook objects (Map<string, IHook[]>) instead of raw handler functions (Map<string, HookFn[]>). This means .hooks returns Map<string, IHook[]> and .getHooks() returns IHook[] | undefined.

Before (v1):

consthooks=myClass.getHooks('before:save');// HookFn[]hooks[0](data);// direct function call

After (v2):

consthooks=myClass.getHooks('before:save');// IHook[]hooks[0].handler(data);// access .handler propertyhooks[0].event;// 'before:save'

onceHook, prependHook, prependOnceHook, and removeHook now take IHook

These methods now accept an IHook object instead of separate (event, handler) arguments.

Before (v1):

hookified.onceHook('before:save',async(data)=>{});hookified.prependHook('before:save',async(data)=>{});hookified.prependOnceHook('before:save',async(data)=>{});hookified.removeHook('before:save',handler);

After (v2):

hookified.onceHook({event: 'before:save',handler: async(data)=>{}});hookified.prependHook({event: 'before:save',handler: async(data)=>{}});hookified.prependOnceHook({event: 'before:save',handler: async(data)=>{}});hookified.removeHook({event: 'before:save', handler });

onHook now returns the stored hook

onHook now returns the stored IHook object (or undefined if blocked by deprecation). Previously it returned void. The returned reference is the exact object stored internally, making it easy to later remove with removeHook().

Before (v1):

hookified.onHook({event: 'before:save', handler });// void

After (v2):

conststored=hookified.onHook({event: 'before:save', handler });// IHook | undefinedhookified.removeHook(stored);// exact reference match

New Features

Hook class

A new Hook class is available for creating hook entries. It implements the IHook interface and can be used anywhere IHook is accepted.

import{Hook}from'hookified';consthook=newHook('before:save',async(data)=>{data.validated=true;});myClass.onHook(hook);

WaterfallHook class

A new WaterfallHook class is available for creating sequential data transformation pipelines. It implements the IHook interface and integrates directly with Hookified.onHook(). Each hook in the chain receives a WaterfallHookContext with initialArgs (the original arguments) and results (an array of { hook, result } entries from all previous hooks).

import{Hookified,WaterfallHook}from'hookified';classMyClassextendsHookified{constructor(){super();}}constmyClass=newMyClass();constwh=newWaterfallHook('save',({ results })=>{constdata=results[results.length-1].result;console.log('Saved:',data);});wh.addHook(({ initialArgs })=>{return{ ...initialArgs,validated: true};});wh.addHook(({ results })=>{return{ ...results[results.length-1].result,timestamp: Date.now()};});myClass.onHook(wh);awaitmyClass.hook('save',{name: 'test'});// Saved: { name: 'test', validated: true, timestamp: ... }

See the Waterfall Hook section for full documentation.

useHookClone option

A new useHookClone option (default true) controls whether hook objects are shallow-cloned before storing. When enabled, external mutation of a registered hook object won't affect the internal state. Set to false to store the original reference for performance or when you need reference equality.

classMyClassextendsHookified{constructor(){super({useHookClone: false});}}

onHook now accepts OnHookOptions

onHook now accepts an optional second parameter of type OnHookOptions. This allows you to override the instance-level useHookClone setting and control hook positioning on a per-call basis.

// Override useHookClone for this specific callhookified.onHook({event: 'before:save', handler },{useHookClone: false});// Insert at the top of the handlers array instead of the endhookified.onHook({event: 'before:save', handler },{position: 'Top'});// Insert at a specific indexhookified.onHook({event: 'before:save', handler },{position: 1});

IHook now has an id property

Every hook now has an optional id property. If not provided, a UUID is auto-generated via crypto.randomUUID(). The id enables easier lookups and removal via the new getHook(id) and removeHookById(id) methods, which search across all events.

Registering a hook with the same id on the same event replaces the existing hook in-place (preserving its position).

// With custom idconststored=hookified.onHook({id: 'my-validation',event: 'before:save',handler: async(data)=>{data.validated=true;},});// Without id β€” auto-generatedconststored2=hookified.onHook({event: 'before:save',handler: async(data)=>{},});console.log(stored2.id);// e.g. '550e8400-e29b-41d4-a716-446655440000'// Look up by id (searches all events)consthook=hookified.getHook('my-validation');// Remove by id (searches all events)hookified.removeHookById('my-validation');// Remove multiple by idshookified.removeHookById(['hook-a','hook-b']);

The Hook class also accepts an optional id parameter:

consthook=newHook('before:save',handler,'my-custom-id');

removeEventHooks method

A new removeEventHooks(event) method removes all hooks for a specific event and returns the removed hooks as an IHook[] array.

constremoved=hookified.removeEventHooks('before:save');console.log(removed.length);// number of hooks removed

How to Contribute

Hookified is written in TypeScript and tests are written with vitest. To setup the environment and run the tests:

pnpm i && pnpm test

Note that we are using pnpm as our package manager. If you don't have it installed, you can install it globally with:

npm install -g pnpm

To contribute follow the Contributing Guidelines and Code of Conduct.

License and Copyright

MIT & Β© Jared Wray

About

Event Emitting and Async Middleware Hooks πŸͺ

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

10 stars

Watchers

1 watching

Forks

Releases

Used by

Contributors

Languages