Skip to main content

Middleware

The eridu-tech/middleware module provides a flexible middleware system for intercepting and composing function calls. It enables you to wrap functions with pre-processing and post-processing logic, similar to middleware patterns found in web frameworks like Express.js.

Middleware basics​

Creating a simple middleware​

A middleware is a function that receives middleware arguments (containing the original arguments, a next function, and the name of the function) and returns the result:

./samples/simple-middleware.ts
import {
type MiddlewareArgs,
type MiddlewareFn,
} from "eridu-tech/middleware/contracts";

const createLoggingMiddleware = <TParameters extends Array<unknown>, TReturn>(
prefix: string = "LOG",
): MiddlewareFn<TParameters, TReturn> => {
return ({ args, next }: MiddlewareArgs<TParameters, TReturn>) => {
console.log(`${prefix} - Before invocation with args:`, args);
const result = next(args);
console.log(`${prefix} - After invocation, result:`, result);
return result;
};
};

const loggingMiddleware = createLoggingMiddleware();

Applying middleware to a function​

Use the use function to apply one or more middlewares to a function:

./samples/use-function.ts
import { use } from "eridu-tech/middleware";
import {
type MiddlewareArgs,
type MiddlewareFn,
} from "eridu-tech/middleware/contracts";

const originalFn = (name: string, age: number): string => {
return `${name} is ${age} years old`;
};

const createLoggingMiddleware = (): MiddlewareFn<[string, number], string> => {
return ({ args, next }: MiddlewareArgs<[string, number], string>) => {
console.log("Before invocation with args:", args);
const result = next(args);
console.log("After invocation, result:", result);
return result;
};
};

const loggingMiddleware = createLoggingMiddleware();

const wrappedFn = use(originalFn, loggingMiddleware);

// Call the wrapped function
const result = wrappedFn("Alice", 30);
// Logs: "Before invocation with args: ["Alice", 30]"
// Logs: "After invocation, result: Alice is 30 years old"

Applying multiple middlewares​

You can apply multiple middlewares, which are executed in order of their priority:

./samples/multiple-middlewares.ts
import { use } from "eridu-tech/middleware";
import {
type MiddlewareArgs,
type MiddlewareFn,
} from "eridu-tech/middleware/contracts";

const originalFn = (name: string, age: number): string => {
return `${name} is ${age} years old`;
};

const createLoggingMiddleware = (): MiddlewareFn<[string, number], string> => {
return ({ args, next }: MiddlewareArgs<[string, number], string>) => {
console.log("Before invocation with args:", args);
const result = next(args);
console.log("After invocation, result:", result);
return result;
};
};

const loggingMiddleware = createLoggingMiddleware();

const createValidationMiddleware = (): MiddlewareFn<
[string, number],
string
> => {
return ({ args, next }: MiddlewareArgs<[string, number], string>) => {
const [name, age] = args;
if (age < 0) throw new Error("Age cannot be negative");
return next(args);
};
};

const createAuthMiddleware = (): MiddlewareFn<[string, number], string> => {
return ({ args, next }: MiddlewareArgs<[string, number], string>) => {
console.log("Checking authorization...");
return next(args);
};
};

const validationMiddleware = createValidationMiddleware();
const authMiddleware = createAuthMiddleware();

const wrappedFn = use(originalFn, [
loggingMiddleware,
validationMiddleware,
authMiddleware,
]);

Middleware types​

MiddlewareFn​

A function that receives middleware arguments and returns a result:

./samples/fn-type.ts
import type { MiddlewareArgs } from "eridu-tech/middleware/contracts";

type MiddlewareFn<TParameters extends Array<unknown>, TReturn> = (
args: MiddlewareArgs<TParameters, TReturn>,
) => TReturn;

IMiddlewareObject​

A middleware object with an optional priority property:

./samples/object-type.ts
import { use } from "eridu-tech/middleware";
import {
type IMiddlewareObject,
type MiddlewareArgs,
} from "eridu-tech/middleware/contracts";

const originalFn = (name: string, age: number): string => {
return `${name} is ${age} years old`;
};

class AuthMiddleware implements IMiddlewareObject<[string, number], string> {
constructor(public readonly priority: number = 100) {}

invoke({ args, next }: MiddlewareArgs<[string, number], string>): string {
// Authentication logic
return next(args);
}
}

const authMiddleware = new AuthMiddleware(100);
const wrappedFn = use(originalFn, authMiddleware);

MiddlewareArgs​

The argument passed to each middleware:

./samples/args-type.ts
import type { NextFn } from "eridu-tech/middleware/contracts";

type MiddlewareArgs<TParameters extends Array<unknown>, TReturn> = {
// Original function arguments
args: TParameters;
// Function to invoke next middleware or original function
next: NextFn<TParameters, TReturn>;
// Name of the function/method
name: string;
};

defineMiddleware​

A helper function for defining middleware with accurate type inference. It ensures the provided handler conforms to the MiddlewareFn signature while preserving exact parameter and return types, without needing explicit generic annotations:

./samples/define-middleware.ts
import { defineMiddleware } from "eridu-tech/middleware/contracts";
import type { MiddlewareArgs } from "eridu-tech/middleware/contracts";

const loggingMiddleware = defineMiddleware(
<T extends unknown[], R>({ args, next }: MiddlewareArgs<T, R>): R => {
console.log("Before:", args);
const result = next(args);
console.log("After:", result);
return result;
},
);

Patterns​

Priority-based ordering​

Set priority on middleware objects to control execution order (lower numbers execute first):

./samples/priority-ordering.ts
import { use } from "eridu-tech/middleware";
import {
type IMiddlewareObject,
type MiddlewareArgs,
} from "eridu-tech/middleware/contracts";

const createPriorityMiddleware = (
name: string,
priority: number,
): IMiddlewareObject<[string], string> => ({
priority,
invoke: ({ args, next }: MiddlewareArgs<[string], string>): string => {
console.log(`${priority}. ${name}`);
return next(args);
},
});

const authMiddleware = createPriorityMiddleware("Auth", 10);
const validationMiddleware = createPriorityMiddleware("Validation", 20);
const loggingMiddleware = createPriorityMiddleware("Logging", 30);

const wrappedFn = use(
(value: string): string => value.toUpperCase(),
[loggingMiddleware, validationMiddleware, authMiddleware],
);

// Executes in order: Auth -> Validation -> Logging -> Original function

Async middleware​

Middleware can be asynchronous:

./samples/async-middleware.ts
import { use } from "eridu-tech/middleware";
import {
type MiddlewareArgs,
type MiddlewareFn,
} from "eridu-tech/middleware/contracts";

// Async validator used by the middleware
const validateAsync = async (args: [string, number]): Promise<boolean> => {
const [, age] = args;
return age >= 0;
};

const createAsyncValidationMiddleware = (
validator: (args: [string, number]) => Promise<boolean>,
): MiddlewareFn<[string, number], Promise<string>> => {
return async ({
args,
next,
}: MiddlewareArgs<[string, number], Promise<string>>): Promise<string> => {
// Perform async validation
const isValid = await validator(args);
if (!isValid) throw new Error("Validation failed");
return await next(args);
};
};

// Async function being wrapped
const originalFn = async (name: string, age: number): Promise<string> => {
return `${name} is ${age} years old`;
};

const asyncValidationMiddleware =
createAsyncValidationMiddleware(validateAsync);
const wrappedFn = use(originalFn, asyncValidationMiddleware);

Short-circuiting middleware​

Skip calling next() to bypass subsequent middleware and the original function:

./samples/caching-middleware.ts
import {
type MiddlewareArgs,
type MiddlewareFn,
} from "eridu-tech/middleware/contracts";

const createCachingMiddleware = <T extends unknown[]>(
cacheStore: Map<string, unknown> = new Map(),
): MiddlewareFn<T, unknown> => {
return ({ args, next }: MiddlewareArgs<T, unknown>) => {
const cacheKey: string = JSON.stringify(args);

if (cacheStore.has(cacheKey)) {
console.log("Cache hit!");
return cacheStore.get(cacheKey); // Skip next()
}

const result = next(args);
cacheStore.set(cacheKey, result);
return result;
};
};

const cache = new Map<string, unknown>();
const cachingMiddleware = createCachingMiddleware(cache);

Error handling middleware​

Catch and handle errors in middleware:

./samples/error-handling-middleware.ts
import {
type MiddlewareArgs,
type MiddlewareFn,
} from "eridu-tech/middleware/contracts";

const createErrorHandlingMiddleware = (
errorHandler?: (error: unknown) => void,
): MiddlewareFn<[string, number], Promise<string>> => {
return async ({
args,
next,
}: MiddlewareArgs<[string, number], Promise<string>>): Promise<string> => {
try {
return await next(args);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
console.error("Error occurred:", message);
if (errorHandler) errorHandler(error);
throw error;
}
};
};

const errorHandlingMiddleware = createErrorHandlingMiddleware((error) =>
console.log("Error handled gracefully"),
);

Enhancing Methods with enhance​

The enhance function provides a convenient way to apply middleware to methods of class instances, enabling interception and augmentation of method calls without manually wrapping each function.

Usage Example​

./samples/enhance-greeter.ts
import { enhance } from "eridu-tech/middleware";
import type { MiddlewareFn } from "eridu-tech/middleware/contracts";

class Greeter {
greet(name: string): string {
return `Hello, ${name}!`;
}
}

const greeter = new Greeter();

// Example middleware that logs calls
export function loggingMiddleware<
TParameters extends Array<unknown>,
TReturn,
>(): MiddlewareFn<TParameters, TReturn> {
return ({ args, next }) => {
console.log("Calling greet with:", args);
const result = next(args);
console.log("Result:", result);
return result;
};
}

// Enhance the 'greet' method with middleware
enhance(greeter, "greet", loggingMiddleware());

greeter.greet("Alice");
// Logs:
// Calling greet with: ["Alice"]
// Result: Hello, Alice!

Enhancing Object Literal Methods​

You can enhance methods on plain object literals as well:

./samples/enhance-object-literal.ts
import { enhance } from "eridu-tech/middleware";
import { loggingMiddleware } from "./enhance-greeter.js";

const obj = {
add(a: number, b: number) {
return a + b;
},
};

enhance(obj, "add", loggingMiddleware());
obj.add(2, 3);
// Logs:
// Calling greet with: [2, 3]
// Result: 5

Enhancing Static Methods​

Static methods on classes can also be enhanced:

./samples/enhance-static.ts
import { enhance } from "eridu-tech/middleware";
import { loggingMiddleware } from "./enhance-greeter.js";

class MathUtils {
static multiply(a: number, b: number) {
return a * b;
}
}

enhance(MathUtils, "multiply", loggingMiddleware());
MathUtils.multiply(4, 5);
// Logs:
// Calling greet with: [4, 5]
// Result: 20

Enhancing Class Prototype Methods​

You can enhance all instances of a class by enhancing its prototype:

./samples/enhance-prototype.ts
import { enhance } from "eridu-tech/middleware";
import { loggingMiddleware } from "./enhance-greeter.js";

class Person {
say(message: string) {
return `Person says: ${message}`;
}
}

enhance(Person.prototype, "say", loggingMiddleware());

const alice = new Person();
alice.say("Hello!");
// Logs:
// Calling greet with: ["Hello!"]
// Result: Person says: Hello!

How it Works​

  • The enhance function replaces the specified method on the object with a wrapped version that runs the provided middleware pipeline.
  • If the target property is not a function, a TypeError is thrown.
  • Multiple middlewares can be provided (as an array or single value).
danger

Because enhance mutates the object in-place, when one enhanced method internally calls another enhanced method via this, the internal call goes through the already-enhanced wrapper again, causing the middleware to apply twice. Be mindful of inter-method calls when using enhance on multiple methods of the same instance.

This pattern is useful for adding cross-cutting concerns (logging, validation, authorization, etc.) to class methods in a reusable and declarative way.

Applying Plugins with withPlugin​

The withPlugin function provides a way to apply one or more plugins to a class instance or object literal, where each plugin can use the enhance function to wrap methods with middleware. This is useful for encapsulating cross-cutting concerns into reusable plugin units.

Usage with Class Instances​

./samples/with-plugin-class.ts
import { withPlugin } from "eridu-tech/middleware";
import {
type MiddlewareFn,
type PluginFn,
} from "eridu-tech/middleware/contracts";

export class UserService {
async getUser(id: string): Promise<{ name: string }> {
return { name: "Alice" };
}

async deleteUser(id: string): Promise<void> {
// Deletion logic
}
}

function withPerformanceLogging<
TParameters extends Array<unknown>,
TReturn,
>(): MiddlewareFn<TParameters, Promise<TReturn>> {
return async ({ args, next, name }) => {
const start = performance.now();
const returnValue = await next(args);
const end = performance.now();
const timeInMs = end - start;
console.log(`function/method ${name} took ${timeInMs}ms`);
return returnValue;
};
}

// Define a logging plugin
export const loggingPlugin: PluginFn<UserService> = (service, enhance) => {
enhance(service, "getUser", withPerformanceLogging());

enhance(service, "deleteUser", withPerformanceLogging());
};

// Apply the plugin to a class instance
const service = new UserService();
const enhancedService = withPlugin(service, loggingPlugin);

await enhancedService.getUser("123");
// Logs:
// getUser called with: ["123"]
// getUser returned: { name: "Alice" }

// The original service is NOT modified — a copy is returned instead

Usage with Object Literals​

withPlugin also works with plain object literals:

./samples/with-plugin-object-literal.ts
import { withPlugin } from "eridu-tech/middleware";
import {
type MiddlewareFn,
type PluginFn,
} from "eridu-tech/middleware/contracts";

// Timing middleware for synchronous methods
function withPerformanceLogging<
TParameters extends Array<unknown>,
TReturn,
>(): MiddlewareFn<TParameters, TReturn> {
return ({ args, next, name }) => {
const start = performance.now();
const returnValue = next(args);
const timeInMs = performance.now() - start;
console.log(`function/method ${name} took ${timeInMs}ms`);
return returnValue;
};
}

const calculator = {
add(a: number, b: number): number {
return a + b;
},
subtract(a: number, b: number): number {
return a - b;
},
};

const loggingPlugin: PluginFn<typeof calculator> = (obj, enhance) => {
enhance(obj, "add", withPerformanceLogging());

enhance(obj, "subtract", withPerformanceLogging());
};

const enhancedCalc = withPlugin(calculator, loggingPlugin);

enhancedCalc.add(2, 3);
// Logs: add called with: [2, 3]

// The original calculator object is NOT modified — a copy is returned instead

Applying Multiple Plugins​

You can apply multiple plugins at once by passing an array:

./samples/with-plugin-multiple.ts
import { withPlugin } from "eridu-tech/middleware";
import type { PluginFn } from "eridu-tech/middleware/contracts";
import { loggingPlugin, UserService } from "./with-plugin-class.js";

const monitoringPlugin: PluginFn<UserService> = (service, enhance) => {
// Monitor methods...
};

const validationPlugin: PluginFn<UserService> = (service, enhance) => {
// Validate methods...
};

const service = new UserService();
const enhancedService = withPlugin(service, [
loggingPlugin,
monitoringPlugin,
validationPlugin,
]);

Object-based Plugins​

For plugins with state or configuration, use the object form:

./samples/with-plugin-object-based.ts
import { withPlugin } from "eridu-tech/middleware";
import {
type Enhance,
type IPluginObject,
type MiddlewareFn,
} from "eridu-tech/middleware/contracts";
import { UserService } from "./with-plugin-class.js";

class MetricsClient {
record(method: string, durationMs: number): void {
console.log(`${method} took ${durationMs}ms`);
}
}

const client = new MetricsClient();

class MetricsPlugin implements IPluginObject<UserService> {
constructor(private readonly metricsClient: MetricsClient) {}

invoke(service: UserService, enhance: Enhance): void {
const metricsMiddleware: MiddlewareFn<
[string],
Promise<{ name: string }>
> = async ({ args, next }) => {
const start = performance.now();
const result = await next(args);
const duration = performance.now() - start;
this.metricsClient.record("getUser", duration);
return result;
};

enhance(service, "getUser", metricsMiddleware);
}
}

const service = new UserService();
const enhancedService = withPlugin(service, new MetricsPlugin(client));

How it Works​

  • withPlugin always creates a copy of the target (whether a class instance or object literal), preserving the original unchanged.
  • Each plugin is invoked in order, receiving the copied target and the enhance function.
  • The enhance function wraps the specified method with a middleware pipeline in-place on the copy.
  • The enhanced copy is returned, leaving the original untouched.
danger

Because withPlugin uses enhance under the hood, the same edge case applies: if one enhanced method internally calls another enhanced method via this, the middleware will apply twice. Be mindful of inter-method calls when applying plugins that enhance multiple methods on the same instance.

info

This pattern is ideal for building reusable feature packs (logging, monitoring) that can be composed and applied to any class instance or object literal.

Hooks​

The withBeforeHook, withAfterHook and withOnError middlewares run a callback at the three points of interest of a function call — before it runs, after it resolves and when it throws — so that common logic does not have to be written as a full middleware. Each one accepts a callback (a function or an invocable object) and an optional detach flag that fires the callback without awaiting it.

withBeforeHook​

Runs a BeforeHook before the wrapped function and lets it replace the arguments by returning a new argument tuple, while returning nothing keeps the original arguments. The hook may be async and is awaited, unless detach is set to true, in which case its return value is ignored.

The synchronous counterpart is withBeforeHookSync, which takes a hook that must be synchronous, has no detach option and always applies the tuple it returns.

./samples/with-before-hook.ts
import { use, withBeforeHook } from "eridu-tech/middleware";

interface User {
id: string;
name: string;
}

const saveUser = async (name: string): Promise<User> => {
return { id: "1", name };
};

const auditName = async (name: string): Promise<void> => {
console.log(`Saving user with name: ${name}`);
};

// The hook may be async and can replace the arguments of the wrapped function.
const createUser = use(
saveUser,
withBeforeHook<[name: string], User>(async ([name]) => {
await auditName(name);
return [name.trim()];
}),
);

// With `detach` set to `true` the hook is fired without being awaited and its
// return value is ignored, so the wrapped function keeps the original arguments.
const createUserDetached = use(
saveUser,
withBeforeHook<[name: string], User>(async ([name]) => {
await auditName(name);
}, true),
);

withAfterHook​

Runs an AfterHook after the wrapped function resolves successfully, receiving the arguments and the result, and can replace the result by returning a value, while returning nothing keeps the original result. The hook may be async and is awaited, unless detach is set to true, in which case it is fired without being awaited and cannot change the result.

The synchronous counterpart is withAfterHookSync, which takes a hook that must be synchronous, has no detach option and can still replace the result.

./samples/with-after-hook.ts
import { use, withAfterHook } from "eridu-tech/middleware";

interface User {
id: string;
name: string;
}

const saveUser = async (name: string): Promise<User> => {
return { id: "1", name };
};

const trackUser = async (user: User): Promise<void> => {
console.log(`Tracking user: ${user.id}`);
};

// The hook may be async and can replace the result by returning a value.
const createUser = use(
saveUser,
withAfterHook<[name: string], User>(async ([name], user) => {
await trackUser(user);
return { ...user, name: name.trim() };
}),
);

// Returning nothing keeps the original result, which pairs with `detach` for
// fire-and-forget side effects that should not delay the caller.
const createUserWithSideEffect = use(
saveUser,
withAfterHook<[name: string], User>((_args, user) => {
console.log(`Saved user: ${user.id}`);
}, true),
);

withOnError​

Runs an OnErrorHook when the wrapped function throws, receiving the arguments and the error for side effects only, because the error is always re-thrown unchanged. The hook may be async and is awaited before the error propagates, unless detach is set to true.

The synchronous counterpart is withOnErrorSync, which takes a hook that must be synchronous, has no detach option and is always invoked before the error is re-thrown.

./samples/with-on-error-hook.ts
import { use, withOnError } from "eridu-tech/middleware";

interface User {
id: string;
name: string;
}

const reportFailure = async (name: string, error: unknown): Promise<void> => {
console.error(`Failed to save user with name: ${name}`, error);
};

const saveUser = async (name: string): Promise<User> => {
return { id: "1", name };
};

// The hook may be async and is awaited before the error is re-thrown.
const createUser = use(
saveUser,
withOnError<[name: string], User>(async ([name], error) => {
await reportFailure(name, error);
}),
);

// With `detach` set to `true` the hook is fired without being awaited.
const createUserDetached = use(
saveUser,
withOnError<[name: string], User>(([name], error) => {
console.error(`Failed to save user with name: ${name}`, error);
}, true),
);

Further information​

For further information refer to eridu-tech/middleware API docs.