@stone-js/pipeline
Version:
An implementation based on the Chain of Responsibility (aka CoR) design pattern.
362 lines (358 loc) • 12.7 kB
JavaScript
/**
* Define a new middleware for the pipeline.
*
* @param module - The pipe module to add to the pipeline.
* @param options - Additional options for the middleware.
* @returns The metadata for the middleware.
*/
const defineMiddleware = (module, options = {}) => {
return { ...options, module };
};
/**
* Check if the value is a string.
*
* @param value - The value to check.
* @returns `true` if the value is an string, otherwise `false`.
*/
const isString = (value) => typeof value === 'string';
/**
* Check if the value is a function.
*
* @param value - The value to check.
* @returns `true` if the value is a function, otherwise `false`.
*/
const isFunction = (value) => typeof value === 'function';
/**
* Checks if the given value is a constructor function.
*
* @param value - The value to be checked.
* @returns True if the value is a constructor function, false otherwise.
*/
const isConstructor = (value) => {
return isFunction(value) && Object.prototype.hasOwnProperty.call(value, 'prototype');
};
/**
* Check if the meta pipe is a function pipe.
*
* @param metaPipe - The meta pipe to check.
* @returns `true` if the meta pipe is a function pipe, otherwise `false`.
*/
const isFunctionPipe = (metaPipe) => {
return !isAliasPipe(metaPipe) && !isClassPipe(metaPipe) && !isFactoryPipe(metaPipe);
};
/**
* Check if the meta pipe is an alias pipe.
*
* @param metaPipe - The meta pipe to check.
* @returns `true` if the meta pipe is an alias pipe, otherwise `false`.
*/
const isAliasPipe = (metaPipe) => {
return metaPipe.isAlias === true || isString(metaPipe.module);
};
/**
* Check if the meta pipe is a class pipe.
*
* @param metaPipe - The meta pipe to check.
* @returns `true` if the meta pipe is a class pipe, otherwise `false`.
*/
const isClassPipe = (metaPipe) => {
return metaPipe.isClass === true && isFunction(metaPipe.module) && isConstructor(metaPipe.module);
};
/**
* Check if the meta pipe is a factory pipe.
*
* @param metaPipe - The meta pipe to check.
* @returns `true` if the meta pipe is a factory pipe, otherwise `false`.
*/
const isFactoryPipe = (metaPipe) => {
return metaPipe.isFactory === true && isFunction(metaPipe.module);
};
/**
* Custom error for pipeline operations.
*/
class PipelineError extends Error {
constructor(message) {
super(message);
this.name = 'PipelineError';
}
}
/**
* Class representing a Pipeline.
*
* @template T - The type of the passable object in the pipeline.
* @template R - The type of the return value from the pipeline execution.
*
* This class is responsible for managing and executing a series of operations
* on a set of passable values through multiple configurable pipes.
*/
class Pipeline {
/** The passable objects sent through the pipeline */
passable;
/** The method name to call on each pipe */
method;
/** Flag indicating whether the pipeline should run synchronously or asynchronously */
isSync;
/** The default priority for the pipes in the pipeline */
_defaultPriority;
/** The sorted metadata pipes that will be executed */
sortedMetaPipes;
/** The pipeline hooks */
hooks;
/** The resolver function used to resolve pipes before they are executed in the pipeline. */
resolver;
/**
* Create a pipeline instance.
*
* @param options - Optional Pipeline options.
* @returns The pipeline instance.
*/
static create(options) {
return new this(options);
}
/**
* Initialize a new Pipeline instance.
*
* @param options - Optional Pipeline options.
*/
constructor(options) {
this.isSync = false;
this.method = 'handle';
this.sortedMetaPipes = [];
this._defaultPriority = 10;
this.resolver = options?.resolver;
this.hooks = options?.hooks ?? {};
}
/**
* Set the default priority for pipes in the pipeline.
*
* @param value - The priority value to set.
* @returns The current Pipeline instance.
*/
defaultPriority(value) {
this._defaultPriority = value;
return this;
}
/**
* Set the passable objects being sent through the pipeline.
*
* @param passable - The object to pass through the pipeline.
* @returns The current Pipeline instance.
*/
send(passable) {
this.passable = passable;
return this;
}
/**
* Set the pipes for the pipeline.
*
* @param pipes - The pipes or MetaPipe instances.
* @returns The current Pipeline instance.
*/
through(...pipes) {
const priority = this._defaultPriority;
const metaPipes = pipes.map(pipe => ((isString(pipe) || isFunction(pipe)) ? { module: pipe, priority, isAlias: isString(pipe) } : { priority, ...pipe }));
this.sortedMetaPipes = Array
.from(metaPipes.reduce((acc, pipe) => acc.set(pipe.module, pipe), new Map()).values())
.sort((a, b) => a.priority !== undefined && b.priority !== undefined ? a.priority - b.priority : 0)
.reverse();
return this;
}
/**
* Add additional pipes to the pipeline.
*
* @param {...MixedPipe} pipe - A single pipe or a list of pipes to add.
* @returns The current Pipeline instance.
*/
pipe(...pipe) {
return this.through(...this.sortedMetaPipes, ...pipe);
}
/**
* Set the method to call on each pipe.
*
* @param method - The method name to use on each pipe.
* @returns The current Pipeline instance.
*/
via(method) {
this.method = method;
return this;
}
/**
* Configure the pipeline to run synchronously or asynchronously.
*
* @param value - Set true for sync, false for async (default is true).
* @returns The current Pipeline instance.
*/
sync(value = true) {
this.isSync = value;
return this;
}
/**
* Add a hook to the pipeline.
*
* @param name - The name of the hook.
* @param listener - The hook listener function.
* @returns The current Pipeline instance.
*/
on(name, listener) {
this.hooks[name] ??= [];
this.hooks[name] = this.hooks[name].concat(listener);
return this;
}
/**
* Run the pipeline with a final destination callback.
*
* @param destination - The final function to execute.
* @returns The result of the pipeline, either synchronously or as a Promise.
*/
then(destination) {
if (this.passable === undefined) {
throw new PipelineError('No passable object has been set for this pipeline.');
}
return this
.sortedMetaPipes
.reduce(this.isSync ? this.reducer() : this.asyncReducer(), destination.bind(destination))(this.passable);
}
/**
* Run the pipeline and return the result.
*
* @returns The result of the pipeline, either synchronously or as a Promise.
*/
thenReturn() {
return this.then((passable) => passable);
}
/**
* Get the asynchronous reducer to iterate over the pipes.
*
* @returns The asynchronous reducer callback.
*/
asyncReducer() {
return (previousPipeExecutor, currentPipe) => {
return async (passable) => {
return await this.executeAsyncPipe(currentPipe, passable, previousPipeExecutor);
};
};
}
/**
* Get the synchronous reducer to iterate over the pipes.
*
* @returns The synchronous reducer callback.
*/
reducer() {
return (previousPipeExecutor, currentPipe) => {
return (passable) => {
return this.executePipe(currentPipe, passable, previousPipeExecutor);
};
};
}
/**
* Resolve and execute async pipe.
*
* @param currentPipe - The current pipe to execute (class or service alias string).
* @param passable - The passable object to send through the pipe.
* @param previousPipeExecutor - The previous pipe executor in the sequence.
* @returns The result of the pipe execution.
* @throws PipelineError If the pipe cannot be resolved or the method is missing.
*/
async executeAsyncPipe(currentPipe, passable, previousPipeExecutor) {
const instance = this.resolvePipe(currentPipe);
await this.executeAsyncHooks('onProcessingPipe', currentPipe, instance, passable);
const result = await instance[this.method](passable, previousPipeExecutor, ...(currentPipe.params ?? []));
await this.executeAsyncHooks('onPipeProcessed', currentPipe, instance, passable);
return result;
}
/**
* Resolve and execute a pipe.
*
* @param currentPipe - The current pipe to execute (class or service alias string).
* @param passable - The passable object to send through the pipe.
* @param previousPipeExecutor - The previous pipe executor in the sequence.
* @returns The result of the pipe execution.
* @throws PipelineError If the pipe cannot be resolved or the method is missing.
*/
executePipe(currentPipe, passable, previousPipeExecutor) {
const instance = this.resolvePipe(currentPipe);
this.executeHooks('onProcessingPipe', currentPipe, instance, passable);
const result = instance[this.method](passable, previousPipeExecutor, ...(currentPipe.params ?? []));
this.executeHooks('onPipeProcessed', currentPipe, instance, passable);
return result;
}
/**
* Resolve pipe.
*
* @param currentPipe - The current pipe to execute (class or service alias string).
* @returns The resolved pipe instance.
* @throws PipelineError If the pipe cannot be resolved or the method is missing.
*/
resolvePipe(currentPipe) {
let instance = (isFunction(this.resolver) ? this.resolver(currentPipe) : undefined);
if (instance === undefined) {
instance = this.createInstanceFromPipe(currentPipe);
if (instance === undefined) {
throw new PipelineError(`Cannot resolve this pipe ${String(currentPipe)}.`);
}
}
else if (isFunction(instance)) {
instance = { [this.method]: instance };
}
this.validatePipeMethod(instance, currentPipe);
return instance;
}
/**
* Create an instance from the provided pipe.
*
* @param currentPipe - The pipe function to create an instance from.
* @returns The created instance or an object with the method.
*/
createInstanceFromPipe(currentPipe) {
if (isFunction(currentPipe.module)) {
if (isClassPipe(currentPipe)) {
return new currentPipe.module.prototype.constructor(...[]);
}
else if (isFactoryPipe(currentPipe)) {
return { [this.method]: currentPipe.module(...[]) };
}
else if (isFunctionPipe(currentPipe)) {
return { [this.method]: currentPipe.module };
}
}
}
/**
* Validate that the required method exists on the instance.
*
* @param instance - The instance to validate.
* @param currentPipe - The current pipe being executed.
* @throws {PipelineError} If the method does not exist on the instance.
*/
validatePipeMethod(instance, currentPipe) {
if (!isFunction(instance[this.method])) {
throw new PipelineError(`No method with this name(${this.method}) exists in this constructor(${currentPipe.module.constructor.name})`);
}
}
/**
* Execute lifecycle async hooks.
*
* @param name - The hook's name.
* @param pipe - The current pipe instance.
*/
async executeAsyncHooks(name, pipe, instance, passable) {
if (Array.isArray(this.hooks[name])) {
for (const listener of this.hooks[name]) {
await listener({ passable, instance, pipe, pipes: this.sortedMetaPipes });
}
}
}
/**
* Execute lifecycle hooks.
*
* @param name - The hook's name.
* @param pipe - The current pipe instance.
*/
executeHooks(name, pipe, instance, passable) {
if (Array.isArray(this.hooks[name])) {
for (const listener of this.hooks[name]) {
listener({ passable, instance, pipe, pipes: this.sortedMetaPipes });
}
}
}
}
export { Pipeline, PipelineError, defineMiddleware, isAliasPipe, isClassPipe, isConstructor, isFactoryPipe, isFunction, isFunctionPipe, isString };