Skip to content

A simple and lightweight way to load event handlers from a directory and attach them to any EventEmitter-like instance. It's in its early stages, there may be lots of incoming breaking changes.

License

Notifications You must be signed in to change notification settings

xayanide/event-handler-loader

Repository files navigation

event-handler-loader

A simple and lightweight way load event handlers from a directory and attach them to any EventEmitter-like instance. It supports two ways of importing files, flexible event handler structures, and lets you customize how listeners are added.

Installation

npm install event-handler-loader

Intro

Working with events usually means manually setting up event handlers on an EventEmitter instance. It’s okhi for small projects, but as things grow, it can quickly become messy and repetitive.

This utility simplifies things by automatically loading event handlers from a directory and attaching them to an EventEmitter-like instance—basically, any object with the same event-handling methods as Node.js’ EventEmitter. The tradeoff? It follows a somewhat fixed structure and might not mesh well with more OOP-heavy approaches.

Note

An "EventEmitter-like instance" is any object that implements the same event-handling methods as Node.js' EventEmitter. This means it must have the following methods:

  • on(event, listener): Registers a listener for an event.
  • once(event, listener): Registers a listener that runs only once.
  • addListener(event, listener): Alias for on().
  • prependListener(event, listener): Adds a listener before any existing ones.
  • prependOnceListener(event, listener): Like once(), but added to the front of the listener queue.

Usage

Creating Event Handlers

Each event handler should be a JavaScript/TypeScript module with any file name that exports an object with the required properties: name, isOnce, execute.

Simplest Example with Node.js' process Events

With Node.js' process as the simplest example to demonstrate how it can be used.

Warning

This is for demonstration purposes only. However, if you do plan to use them like this, be sure to properly exit the process when listening to critical process event listeners such as unhandledRejection and uncaughtException where your process is supposed to shut down as soon as possible, otherwise your process can end up in a loop when an error occurs inside the listeners' callbacks because the errors can keep triggering the same event and listener callback indefinitely.

Loading Event Handlers

// src/index.ts
import * as nodePath from "node:path";
import * as nodeUrl from "node:url";
import { loadEventHandlers } from "event-handler-loader";

const eventHandlersFolder = nodePath.join(nodePath.dirname(nodeUrl.fileURLToPath(import.meta.url)), "eventHandlers");
const processEventsFolder = nodePath.join(eventHandlersFolder, "process");

// This is with all configuration options exposed and changed from defaults to demonstrate what can be configured.
await loadEventHandlers(processEventsFolder, process, {
    // Default value: default
    exportType: "named",
    // Default value: concurrent
    importMode: "sequential",
    // Default value: "eventHandler"
    preferredNamedExport: "handler",
    // Default value: { name: "name", isOnce: "isOnce", isPrepend: "isPrepend", execute: "execute" }
    preferredEventHandlerKeys: { name: "eventName", isOnce: "once", isPrepend: "prepend", execute: "run" },
    // Default value: []
    listenerPrependedArgs: ["myString", { number: 1 }],
    // Default value: false
    isRecursive: true
    },
    // Default value: undefined (Uses built-in bindEventListener)
    function (eventEmitterLike: EventEmitter, moduleExport: EventHandlerModuleExport, fileUrlHref: string, listenerPrependedArgs: unknown[]) {
    // do my own thing
    }
);

// Simulate an uncaughtException event.
process.emit("uncaughtException", new Error("MyDeadlyError"));

// Other way of simulating an uncaughtException in this case
// throw new Error("MyDeadlyError")

Creating Event Handlers

With exportType: named, it'll look for exports with a specific configured preferredNamedExport: handler in this example. It's eventHandler by default.

// src/eventHandlers/process/named.ts

// exportType: named
// preferredNamedExport: handler
export const handler = {
    // Keys follow as configured preferredEventHandlerKeys: { name: "eventName", isOnce: "once", isPrepend: "prepend", execute: "run" }
    eventName: "uncaughtException",
    // isOnce and isPrepend keys can be omitted. Only name, and execute keys are required.
    prepend: false,
    once: false,
    run: function (_myString: string, _object: unknown, error: Error) {
        // "myString"
        console.log("1st custom prepended parameter", _myString);
        // { number: 1 }
        console.log("2nd custom prepended parameter", _object);
        // Error: MyDeadlyError
        console.log(`3rd actual emitted parameter:\nEncountered an uncaught exception\n${error.message}\n${error.stack}`);
    },
};

An error will be thrown if you chose to have both modules in a directory with default exports and named exports because it doesn't align with the loader's configuration.

With exportType: default, it'll look for default exports exclusively, so you'll have to rewrite it like so:

// src/eventHandlers/process/default.ts

// exportType: default
// (ignored) preferredNamedExport: handler (preferredNamedExport is ignored because it's configured to find default exports only)
export default {
    // Keys follow as configured preferredEventHandlerKeys: { name: "eventName", isOnce: "once", isPrepend: "prepend", execute: "run" }
    eventName: "uncaughtException",
    once: false,
    // isOnce and isPrepend keys can be omitted. Only name, and execute keys are required.
    prepend: false,
    // Method emits the prepended values as configured: listenerPrependedArgs: ["myString", { number: 1 }],
    run: function (_myString: string, _object: unknown, error: Error) {
        // "myString"
        console.log("1st custom prepended parameter", _myString);
        // { number: 1 }
        console.log("2nd custom prepended parameter", _object);
        // Error: MyDeadlyError
        console.log(`3rd actual emitted parameter:\nEncountered an uncaught exception\n${error.message}\n${error.stack}`);
    },
};

Example with Discord.js' Client Events

With Discord.js' Client as an example.

Loading Event Handlers

// src/index.ts
import * as nodePath from "node:path";
import * as nodeUrl from "node:url";
import * as discordJs from "discord.js";
import { loadEventHandlers } from "event-handler-loader";

const eventHandlersFolder = nodePath.join(nodePath.dirname(nodeUrl.fileURLToPath(import.meta.url)), "eventHandlers");
const discordClientEventsFolder = nodePath.join(eventHandlersFolder, "discordClient");

const discordClient = new discordJs.Client({ intents: [discordJs.GatewayIntentBits.Guilds] });
await loadEventHandlers(discordClientEventsFolder, discordClient);

try {
    await discordClient.login("VERY_SECURE_TOKEN_INDEED");
} catch (loginErr) {
    console.error(`DiscordClient#login\n${loginErr}`);
}

Creating Event Handlers

// src/eventHandlers/discordClient/clientReady.ts
import { Events } from "discord.js";
import type { Client } from "discord.js";

export default {
    name: Events.ClientReady,
    // isOnce and isPrepend keys can be omitted. Only name, and execute keys are required.
    isOnce: false,
    isPrepend: false,
    execute: function (readyClient: Client<true>) {
        console.log(`Ready! Logged in as ${readyClient.user.tag}`);
    },
};

You can also use named exports:

// src/eventHandlers/discordClient/clientReady.ts
import { Events } from "discord.js";
import type { Client } from "discord.js";

// The module will look for any export named 'eventHandler' (case-sensitive) by default. The preferred export name can be configured.
export const eventHandler = {
    name: Events.ClientReady,
    isOnce: false,
    // isOnce and isPrepend keys can be omitted. Only name, and execute keys are required.
    isPrepend: false,
    execute: function (readyClient: Client<true>) {
        console.log(`Ready! Logged in as ${readyClient.user.tag}`);
    },
};

Optional Configuration

You can customize how event handlers are loaded using an options object:

await loadEventHandlers("./path/to/eventHandlers", objectWithEventEmitterMethods, {
    // Default value: concurrent
    // Options: "concurrent" or "sequential"
    // How the module imports event handlers
    importMode: "sequential",
    // Default value: default
    // Options: "default" or "named"
    // The type of export the module should look for in a directory. One export type per directory.
    exportType: "named",
    // Default value: eventHandler (Case-sensitive!)
    // Options: non-empty string or "*"
    // Setting preferredExportName: "*" with
    // exportType: named will make the module import all named exports from a module that
    // follows the event handler structure regardless of how they're named.
    // Preferred export name to look for inside a module.
    // Setting exportType: "default" will ignore this option as "default"
    preferredNamedExport: "myCustomEventHandler",
    // Default value: { name: "name", isOnce: "isOnce", isPrepend: "isPrepend", execute: "execute" }
    // Preferred key names to look for within the exported object
    preferredEventHandlerKeys: {
        name: "eventName",
        isOnce: "once",
        isPrepend: "prepend",
        execute: "run",
    },
    // Default value: []
    // Prepended (first) extra arguments passed to event handlers' listener callbacks
    listenerPrependedArgs: ["IAmAvailableAsAParameterToAllEmittedEvents"],
    // Whether to recursively look for modules inside the provided directory path
    // Default value: false
    isRecursive: true
    },
    },
    // Override the default built-in behavior for when binding event listeners.
    // Default value: undefined (Uses built-in bindEventListener)
    function (eventEmitterLike: EventEmitter, moduleExport: EventHandlerModuleExport, fileUrlHref: string, listenerPrependedArgs: unknown[]) {
    // do my own thing
    }
);

You can also choose to omit the options object entirely and stick to the default configuration

await loadEventHandlers("./path/to/eventHandlers", objectWithEventEmitterMethods);

Exporting Manually and Dynamically Created Event Handlers in One File

// src/index.ts
import * as nodePath from "node:path";
import * as nodeUrl from "node:url";
import { loadEventHandlers } from "event-handler-loader";

const eventHandlersFolder = nodePath.join(nodePath.dirname(nodeUrl.fileURLToPath(import.meta.url)), "eventHandlers");
const processEvents = nodePath.join(eventHandlersFolder, "process");

await loadEventHandlers(processEvents, process, {
    exportType: "named",
    importMode: "concurrent",
    // Import all named exports from all modules in a directory together with exportType: "named"
    preferredNamedExport: "*",
    listenerPrependedArgs: ["myString", { number: 1 }],
});

process.emit("uncaughtException", new Error("MyDeadlyError"));

// src/eventHandlers/process/named.ts
type EventHandler = {
    name: string;
    execute: (myString: string, object: {}, ...args: unknown[]) => void;
};

type EventHandlers = {
    [eventName: string]: EventHandler;
};

// The exit event is manually defined and exported and named separately with a unique behavior: console.log("I am built different"), console.log("Event: 'exit'")
export const exit = {
    name: "exit",
    execute: function (_myString: string, _object: {}, ...args: unknown[]) {
        console.log("I am built different");
        console.log("Event: 'exit'");
        console.log("1st custom prepended parameter:", _myString);
        console.log("2nd custom prepended parameter:", _object);
        if (args.length) {
            console.log("Emitted parameters:", ...args);
        }
    },
};

// Dynamically generating and exporting event handlers
const processEventNames = ["SIGINT", "SIGUSR1", "SIGUSR2", "SIGTERM", "uncaughtException", "unhandledRejection"];

function createProcessEventHandlers() {
    return processEventNames.reduce(function (handlers: EventHandlers, eventName) {
        handlers[eventName] = {
            name: eventName,
            // All callback listeners of "SIGINT", "SIGUSR1", "SIGUSR2", "SIGTERM", "uncaughtException", "unhandledRejection" except "exit" do the same thing:
            execute: function (_myString: string, _object: {}, ...args: unknown[]) {
                console.log("We all do the same thing")
                console.log(`Event: '${eventName}'`);
                console.log("1st custom prepended parameter:", _myString);
                console.log("2nd custom prepended parameter:", _object);
                if (args.length) {
                    console.log("Emitted parameters:", ...args);
                }
            },
        };
        return handlers;
    }, {});
}

// An object of process event handlers all with similar callback operations
// Each have their own event name as key names, and they all do the same thing
const eventHandlers = createProcessEventHandlers();
// Destructure eventHandlers and export each named event handler as a named export.
export const { SIGINT, SIGUSR1, SIGUSR2, SIGTERM, uncaughtException, unhandledRejection } = eventHandlers;

Handling Async Event Handlers

These are also handled internally.

export default {
    name: "fetchData",
    isOnce: true,
    execute: async function () {
        const response = await fetch("https://dog.ceo/api/breeds/image/random")
        const data = await response.json()
        console.log("Fetched data:", data);
    }
};

Error Handling

If an event handler is missing required keys or receives invalid types and values with those keys, an error will be thrown appropriately to let you know.

For instance:

exportType: "default" (default by default) will look for event handler modules with default exports only.

await loadEventHandlers("DirectoryToModulesWithNamedExports", eventEmitter, { exportType: "default", });

However, when it encounters named exports when it's expecting default exports:

Error: Invalid event handler module. Must be a default export.

Make sure your event handlers follow the required and configured structure.

Use Cases

  • Discord Bots: Load event handlers dynamically for events.
  • Modular Applications: Load user-defined events dynamically.

The project's core tech stack

The project incorporates the following tools

About

A simple and lightweight way to load event handlers from a directory and attach them to any EventEmitter-like instance. It's in its early stages, there may be lots of incoming breaking changes.

Resources

License

Stars

Watchers

Forks

Contributors 3

  •  
  •  
  •