UNPKG

@stone-js/pipeline

Version:

An implementation based on the Chain of Responsibility (aka CoR) design pattern.

461 lines (457 loc) 16.7 kB
/** * 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 = 'handle'; /** Flag indicating whether the pipeline should run synchronously or asynchronously */ isSync = false; /** The default priority for the pipes in the pipeline */ _defaultPriority; /** The raw pipes registered via `through`/`pipe`, normalized and sorted at execution time. */ rawPipes; /** The sorted metadata pipes that will be executed (computed at `then` time). */ 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.rawPipes = []; 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; // Re-normalize so the new default applies even when set after `through()`. this.sortedMetaPipes = this.buildSortedPipes(); 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) { // Store raw pipes; priority (including the default) is resolved from `_defaultPriority` // at build time, so `defaultPriority()` works regardless of whether it is called before // or after `through()`. `sortedMetaPipes` is kept in sync for inspection. this.rawPipes = pipes; this.sortedMetaPipes = this.buildSortedPipes(); 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) { this.rawPipes = [...this.rawPipes, ...pipe]; this.sortedMetaPipes = this.buildSortedPipes(); return this; } /** * Normalize the raw pipes into sorted meta pipes. * * Applies the default priority only when a pipe does not specify one (an explicit * `priority: undefined` no longer silently overrides the default), dedupes by module * (last occurrence wins — override semantics), then orders by priority. Order semantics * are preserved from prior versions: higher priority runs first, and for equal priorities * the last-registered pipe runs first. * * @returns The sorted meta pipes. */ buildSortedPipes() { const priority = this._defaultPriority; const metaPipes = this.rawPipes.map(pipe => { if (isString(pipe)) { return parseAliasPipe(pipe, priority); } if (isFunction(pipe)) { return { module: pipe, priority, isAlias: false }; } return { ...pipe, priority: pipe.priority ?? priority }; }); const deduped = Array.from(metaPipes.reduce((acc, pipe) => acc.set(pipe.module, pipe), new Map()).values()); // Ascending stable sort then reverse — preserves the established ordering while // guaranteeing every priority is a number (no non-deterministic `undefined`). /* v8 ignore next -- priority is normalised to a number for every pipe just above, so the `?? priority` fallbacks are unreachable at runtime. */ deduped.sort((a, b) => (a.priority ?? priority) - (b.priority ?? priority)); return deduped.reverse(); } /** * 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.'); } // Resolve priorities now (with the final `_defaultPriority`), so ordering is // independent of the fluent-call order. this.sortedMetaPipes = this.buildSortedPipes(); 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)) { const PipeClass = currentPipe.module; return new PipeClass(...[]); } 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])) { let name; if (isFunction(currentPipe.module)) { name = currentPipe.module.name.length > 0 ? currentPipe.module.name : 'anonymous'; } else { name = String(currentPipe.module); } throw new PipelineError(`No method "${this.method}" exists on pipe "${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 }); } } } } /** * Parse an alias-pipe string into a {@link MetaPipe}, supporting inline parameters. * * Syntax: `'alias'` or `'alias:param1,param2,...'`. Parameters are split on commas, trimmed, * and coerced (`true`/`false` → boolean, `null` → null, numeric → number, otherwise string), * then passed as extra arguments to the pipe method after `(passable, next)`. * * @example * ```ts * pipeline.through('throttle:60,100') // → throttle(passable, next, 60, 100) * pipeline.through('auth:admin') // → auth(passable, next, 'admin') * pipeline.through('feature:beta,true') // → feature(passable, next, 'beta', true) * ``` * * @param pipe - The alias string. * @param priority - The priority to assign. * @returns The parsed meta pipe. */ function parseAliasPipe(pipe, priority) { const separatorIndex = pipe.indexOf(':'); if (separatorIndex === -1) { return { module: pipe.trim(), priority, isAlias: true }; } const alias = pipe.slice(0, separatorIndex).trim(); const rawParams = pipe.slice(separatorIndex + 1); const params = rawParams.length > 0 ? rawParams.split(',').map(coercePipeParam) : undefined; return { module: alias, priority, isAlias: true, ...(params !== undefined ? { params: params } : {}) }; } /** * Coerce a raw string parameter to a boolean, null, number, or trimmed string. * * @param raw - The raw parameter. * @returns The coerced value. */ function coercePipeParam(raw) { const value = raw.trim(); if (value === 'true') { return true; } if (value === 'false') { return false; } if (value === 'null') { return null; } if (value.length > 0 && /^-?\d+(\.\d+)?$/.test(value)) { return Number(value); } return value; } export { Pipeline, PipelineError, defineMiddleware, isAliasPipe, isClassPipe, isConstructor, isFactoryPipe, isFunction, isFunctionPipe, isString };