npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

inject-hooks

v1.4.1

Published

Combine EventEmitters and middleware to create a powerful hook system.

Downloads

33

Readme

Inject-Hooks

This is the framework for a plugin system that is similar to combining EventEmitter with middleware, but also provides the ability to dynamically change the order of execution. This allows plugins to work together without needing a user to install them in a specific order, or even for both plugins to know about each other. It provides the following features:

  • Listening for events and emitting events
  • Canceling an event
  • Changing the data passed to an event
  • Decoupling user intents and actions from the processes that need to happen
  • Asynchronous processing to allow for more complicated or intensive operations before allowing the event to be triggered
  • Ordering extra pieces of code, allowing two plugins to operate in harmony, consistently executing in the correct order

This would be useful when writing an application. It can coordinate actions between different teams, or you could allow extra functionality (eg. plugins or system extensions) to change how your software operates. For instance, let's pretend that the application needs to load a specific page on startup.

import { InjectHooks } from 'inject-hooks';
import { loadPage } from './your-code';

// Emit "load" when the window is loaded
const hooks = new InjectHooks();
window.addEventListener('load', () => hooks.emit('load'));

// On load, let's trigger the "show-page" action
hooks.on('load', () => hooks.emit('show-page', '/'));

// This action could load a page to be viewed in the browser
hooks.on('show-page', (pageUrl) => loadPage(pageUrl));

// Navigation in the page would likewise want to load a page
hooks.on('navigate', (url) => {
    hooks.emit('show-page', url);
});

This is a huge win because now you can trigger dozens of things on page load. Let's pretend that our page load routine can take an optional parameter for the page to load, and the navigation is hooked up to the system as well to save the last loaded page.

// Continuing the above example. We're going to name these interceptors
// "preserve-page-in-localstorage". Names are arbitrary, but you should
// follow a convention in your codebase. There's a section documenting
// suggested practices for names.
hooks.inject(
    'navigate',
    'preserve-page-in-localstorage',
    (url, next) => {
        next(localStorage.setItem('lastUrl', url));
    }
);
hooks.inject(
    'load',
    'preserve-page-in-localstorage',
    (defaultUrl, next) => {
        next(localStorage.getItem('lastUrl') || defaultUrl);
    }
);

This will work fine, but what if you'd want to do other alterations to the URL? What if there are plugins that need to execute in a specific order? Let's keep going with this example, but we need to make sure that the page exists in our list of pages; otherwise we should load the 404 page through a different event. This needs to happen before the code above.

// Continuing the example from before.
const allPages = new Map();
// Add all pages to allPages.
hooks.inject(
    'navigate',
    'redirect-to-404',
    (url, next) => {
        if (allPages.has(url)) {
            next(url);
        } else {
            hooks.emit('404', url);
            // Do not call next() so the event ends.
        }
    },
    {
        before: ['preserve-page-in-localstorage']
    }
);

// Handle the 404 page with a special event handler
import { load404Page } from './your-code';

hooks.on('404', (url) => load404Page(url));

Error Handling

People familiar with typical Node-style callbacks and middleware will see similarities, but please note how errors are not forwarded. It is expected that you will emit a different event or handle the error yourself in another way. In the following example, one event is changed into another event to signify a handled error has happened and that the action should be prevented.

hooks.on('login:submit', (config) => {
    document.getElementById('myForm').submit();
});
hooks.on('login:submit:require-password', (err) => {
    document.getElementById('missing-password').addClass('visible');
});
hooks.inject('login:submit', 'require-password-during-login', (payload, next) => {
    if (!payload.password) {
        hooks.emit('login:submit:require-password');
    } else {
        next(payload);
    }
});
hooks.emit('login:submit', {
    username: 'example01',
    password: '',
});

For this example, imagine a login screen and clicking on the login button triggers login:submit. The interceptor checks the payload and sees that the password field is empty. A new event is fired and the original event does not get triggered because next() was not called. The new event triggers the display of an error message that was hidden in the HTML by adding a class to make it visible.

Naming Conventions

There is no required naming convention. All of the examples here use strings for easier tracability. It is highly recommended that you do adopt a naming standard for your software, such as this:

  • Name user intention hooks with the page, section, or component first, then use the element or target, and finally the action.
    • login:form:submit
    • browser:navigation:change
    • users:row:hover
  • Name internal processing hooks with the service, library, or code focus first. Include the type of data next when applicable, then add the action.
    • email:list:update
    • page-data:markdown:convert-to-html
    • application:loaded
  • Name interceptors after the plugin's vendor (a company name, the username, or some way to distinguish the author), followed by the name of the plugin.
    • wiki-core:external-assets-in-web-cache
    • fancy-company:hover-panel
    • raphael26:instant-download

Skipping Processing

There are times when you would like a plugin to be able to override chunks of what your software normally does. For instance, let's pretend that we are writing a tool that needs to fetch a page from your site.

// One way to do it
hooks.on('load-page-okay', (pageContent) => {
    document.getElementById('page').innerHTML = pageContent;
});
hooks.inject('load-page-okay', 'core:load-page', (name, next) => {
    fetch(`https://example.com/page/${name}.txt`)
        .then((response) => response.text())
        .then((text) => next(text));
}
hooks.emit('load-page-okay', name);

Doing it this way will not make it easy for plugins to override your functionality. Instead, you should be allowed to skip processing under a specific condition. Here's an updated example illustrating this technique, and we are assuming all of this code is part of your core software.

// This version allows plugins to handle retrieval of the page content
// and still modify the page content as needed.
hooks.on('load-page-better', (data) => {
    document.getElementById('page').innerHTML = data.content;
});

hooks.inject('load-page-better', 'core:load-page:fetch', (data, next) => {
    if (data.content) {
        return next(data);
    }

    fetch(`https://example.com/page/${data.name}.txt`)
        .then((response) => response.text())
        .then((text) => next({
            ...data,
            content: text
        }));
}

hooks.emit('load-page-better', { name });

How is this better? Let's add three plugins. The first will load the page from localStorage if it is available. The second plugin caches the page into localStorage if it isn't there already, and the last one will search and replace content.

hooks.inject('load-page-better', 'local-storage:fetch', (data, next) => {
    // Allow for other plugins to come before this one
    if (!data.content) {
        const content = localStorage.getItem(data.name);

        if (content) {
            return next({
                ...data,
                content
            });
        }
    }

    next(data);
}, { order: 'pre' });

hooks.inject('load-page-better', 'local-storage:save', (data, next) => {
    if (data.content) {
        localStorage.setItem(data.name, data.content);
    }

    next(data);
}, { order: 'post' });

hooks.inject('load-page-better', 'alter-content', (data, next) => {
    if (data.content) {
        data.content = data.content.replace(/##VERSION##/g, '1.2.3');
    }
}, { order: 'post', after: 'local-storage:save' });

With the "load-page-okay" version of the code, we couldn't skip the loading of the page from the server.

API

First, you need to load InjectHooks somehow. Pick a method that works best for you.

// CommonJS
const InjectHooks = require('inject-hooks').InjectHooks;
// Modules
import { InjectHooks } from 'inject-hooks';
<!-- Browser, HTML, loaded as window.InjectHooks -->
<script src="https://unpkg.com/inject-hooks"></script>
<!-- Browser, loaded as a module -->
<script>
import { InjectHooks } from 'https://unpkg.com/inject-hooks?module';
</script>

new InjectHooks()

Create a new instance.

All methods return this as the result for chaining.

import { InjectHooks } from 'inject-hooks';

const hooks = new InjectHooks();

hooks.emit(name, data, done)

// hooks.emit(name: string, data?: any, done?: (data?: any) => void): this

Sends an event with an optional data payload to any listeners.

const elem = document.querySelector('a').addEventListener('click', (e) => {
    hooks.emit('link clicked', e.target);
});

You can also pass a callback as an optional third parameter. This will be called after all of the injectors have completed their transformations of the data, assuming they have all let the event pass through. It's similar to using hooks.once() but will only be called for this particular transformation of data and will not accidentally get the wrong event's data.

const pageData = '<html><head>...';
hooks.emit('page-loaded', pageData, (result) => {
    console.log('Result:', result);
});

This method can throw if the list of interceptors is unable to be resolved; see hooks.validate() for further information. Also, if any interceptor does not continue the event, then the hooks.on() handlers will not be called and the optional callback to hooks.emit() will not be called.

hooks.on(name, handler)

// hooks.on(
//     name: string | ((name: string) => boolean),
//     handler: (data: any, name: string) => void
// ): this

Attach an event handler. Data passed along with the event is included as the first argument.

// Add the event handler
hooks
    .on('init', (data) => {
        console.log('Init hook called', data);
    })
    .emit('init', ['test']); // Init hook called ['test']

When using a function as a filter, you can attach a single handler to multiple events.

// Listen for all core events
hooks.on(
    (name: string) => name.substr(0, 5) === 'core:',
    (data: any, name: string) => {
        console.log(`${name} completed`, data);
    }
);

hooks.off(name, handler?)

// hooks.off(
//     name: string | ((name: string) => boolean),
//     handler: (data: any, name: string) => void
// ): this

Removes an event handler. This must be the same function object as what was passed to hooks.on().

// Add the event handler
const handler = (data) => {
    console.log('Debug:', data);
};
hooks
    .on('debug', handler)
    .emit('debug', 'DATA') // Debug: DATA
    .off('debug', handler);

Can also remove the hooks attached with a function.

const filter = () => true;
const handler = (data, name) => console.log(name, data);
hooks.on(filter, handler);
hooks.off(filter, handler);

hooks.once(name, handler)

// hooks.once(
//     name: string | ((name: string) => boolean),
//     handler: (data: any, name: string) => void
// ): this

Calls an event handler once when the event is triggered, then removes the event handler.

The handler can still be removed using hooks.off() until it has been activated.

hooks
    .once('save', (filename) => {
        console.log('Saving', filename);
    })
    .emit('save', 'a') // "Saving a"
    .emit('save', 'b'); // Nothing.

As with hooks.on(), a filter function can be used. The filter might match multiple event names and register the handler several times, but the handler will only be called once. Once called, it is removed from all of the registered events.

hooks.once(
    (name: string) => name.match(/:debug$/),
    (data, name) => console.log(name, data)
);

hooks.inject(name, id, interceptor, conditions = {})

// hooks.inject(
//     name: string | ((name: string) => boolean),
//     id: any,
//     interceptor: (
//         data: any,
//         next: (data?: any) => void,
//         name: string
//     ) => void
//     conditions?: {
//         after?: any[] | any;
//         before?: any[] | any;
//         conficts?: any[] | any;
//         depends?: any[] | any;
//         order?: 'pre' | 'mid' | 'post'; // "mid" is default
//     }
// ): this

Add an interceptor to the list for a specific hook name. If there's already an interceptor with the same id and name, then this method will throw. Just like how hooks.on() works, a filter function can be passed instead of the name and the injector could be added to multiple event streams.

hooks.inject('abort', 'log-to-console', (reason, next) => {
    console.log(reason);
    next(reason);
});

Because plugins for applications can conflict or augment each other, the conditions specified are checked to make sure the order is correct. Whenever any interceptor is added, the list of interceptors may need to be erased or checked for conflicts. This is done on-demand so plugins can all be added in a batch, without any order, and they won't cause problems if loaded out of order. The list of plugins is calculated and cached when the hook is called. It can also be done on demand after all plugins are loaded by using hooks.validate().

hooks.inject('test', 'one', () => {}, {
    after: ['four']
});

// Order: one

hooks.inject('test', 'two', () => {}, {
    before: ['three']
});

// Order: (one two) or (two one)

hooks.inject('test', 'three', () => {}, {
    after: ['one']
});

// Order: (one two three) or (two one three)

Plugins may also conflict with each other.

hooks.inject('test', 'four', () => {}, {
    conflicts: ['five']
});

// The above would work if validated

hooks.inject('test', 'five', () => {}, {
    depends: ['six']
});

If the above examples were all used, there would be validation errors. Because "five" was added, "four" now conflicts with "five" and "five" requires "six" but "six" is not available.

Finally, you may wish to have some set of plugins happen before or after the "main point" of the hook. Take, for example, forwarding something to a user. If you'd like to allow usernames (without the hostname portion of an email) to be used and automatically apply @fancy-company.com to them, a plugin could do this. Similarly, another plugin could happen after the username processing is done and to ensure the user isn't on a blacklist.

// Main code
hooks.inject('forward', 'main-code:verify-username', (username, next) => {
    fetch(`https://my-api/verify-user?username`)
        .then((response) => response.json())
        .then(() => next(username), hooks.emit('forward:bad-username'));
});
hooks.on('forward', (username) => {
    console.log('Forwarding to', username);
});

// Plugins
hooks.inject('forward', 'default-to-org-email', (username, next) => {
    if (username.indexOf('@') < 0) {
        next(username + '@fancy-company.com');
    } else {
        next(username);
    }
}, { order: 'pre' });
const blacklist = ['[email protected]', ...];
hooks.inject('forward', 'disallow-from-blacklist', (username, next) => {
    if (blacklist.contains(username)) {
        hooks.emit('forward:bad-username');
    } else {
        next(username);
    }
}, { order: 'post' });

By default, the value for "order" is "mid". You can think of them as separating interceptors into three buckets. "before" and "after" will order interceptors within a bucket. "depends" and "conflicts" will scan plugins across all buckets.

hooks.remove(name, id)

// hooks.remove(
//     name: string | ((name: string) => boolean),
//     id: any
// );

Eliminates an interceptor. Also clears the calculated list. For more information, see hooks.inject().

hooks.remove('abort', 'log-to-console');

hooks.validate()

hooks.validate(name?: string): boolean

Calculate the order for all interceptors or for a specific hook name. If there are problems, this throws an Error.

// First, load all of the interceptors.
// When done, you can check for issues.
try {
    hooks.validate();
} catch (err) {
    console.error(err);
}

When not passed an event name, this will not validate all possibilities of events. When you use name filter functions this method does not attempt to guess event names. Only registered event names (as strings) are verified that they would build correctly.

Special Thanks

This is a combination of techniques seen in other projects. Without their ideas, this would not have been made.

* [SquirrelMail](https://www.squirrelmail.org/)'s hooks, to allow plugins to alter/update data at specific points.
* [systemd](https://systemd.io/) organizing of plugins for hooks.
* [EventEmitter](https://nodejs.org/api/events.html#class-eventemitter) to decouple intents from effects.
* [events-intercept](https://github.com/brandonhorst/events-intercept), which can change event data or prevent the event from being emitted.
* [Express](https://expressjs.com/) middleware, allowing infinite flexibility for appropriately structured applications.
* [Rollup](https://rollupjs.org/) plugins allow extra code before and after a specific point.