UNPKG

@stone-js/service-container

Version:

Javascript/Typescript IoC Service Container with proxy resolver and destructuring injection

557 lines (548 loc) 18.4 kB
/** * Class representing a Proxiable. * * This class allows instances to be wrapped in a Proxy, enabling custom behaviors for property access, assignment, etc. * * @author Mr. Stone <evensstone@gmail.com> */ /* eslint-disable-next-line @typescript-eslint/no-extraneous-class */ class Proxiable { /** * Creates a Proxiable instance wrapped in a Proxy. * * @param handler - A trap object for the proxy, which defines custom behavior for fundamental operations (e.g., property lookup, assignment, etc.). * @returns A new proxy object for this instance. */ constructor(handler) { return new Proxy(this, handler); } } /** * Abstract class representing a Binding. * * This abstract class serves as the base class for all types of bindings in the service container. It holds a value and provides an abstract method * to resolve and return that value, allowing different subclasses to implement their own resolution logic. Bindings are used to manage dependencies * and control how objects are instantiated within the container. * * @template V - The type of value that this binding holds. * @author Mr. Stone <evensstone@gmail.com> */ class Binding { /** * The value held by the binding. * * This value is resolved at runtime, either directly or through a resolver function. */ value; /** * Create a new instance of Binding. * * @param value - The value to be held by the binding. */ constructor(value) { this.value = value; } /** * Check if the value has been resolved. * * @returns A boolean indicating whether the value has been resolved. */ isResolved() { return this.value !== undefined; } } /** * Class representing a ContainerError. * * @author Mr. Stone <evensstone@gmail.com> */ class ContainerError extends Error { /** * Error type indicating an alias conflict. */ static ALIAS_TYPE = 'alias'; /** * Error type indicating that the resolver is not a function. */ static RESOLVER_TYPE = 'resolver'; /** * Error type indicating a resolution failure. */ static RESOLUTION_TYPE = 'resolution'; /** * Error type indicating an attempt to alias an unbound value. */ static ALIAS_UNBOUND_TYPE = 'alias_unbound'; /** * Error type indicating that a value is not a service. */ static NOT_A_SERVICE_TYPE = 'not_a_service'; /** * Error type indicating an error thrown by the resolver function. */ static CANNOT_RESOLVE_TYPE = 'cannot_resolve'; /** * Error type indicating a circular dependency. */ static CIRCULAR_DEPENDENCY_TYPE = 'circular_dependency'; /** * The type of the error. */ type; /** * Create a ContainerError. * * @param type - The type of the error. * @param message - The error message or key related to the error. */ constructor(type, message) { super(); this.type = type; this.name = 'ContainerError'; this.message = this.getMessage(type, message); } /** * Retrieve the error message based on the type and provided message. * * @param type - The type of the error. * @param message - The error message or key related to the error. * @returns The formatted error message. */ getMessage(type, message) { const messages = { [ContainerError.RESOLUTION_TYPE]: this.getResolutionMessage(message), [ContainerError.ALIAS_TYPE]: `${String(message)} is aliased to itself`, [ContainerError.CANNOT_RESOLVE_TYPE]: `Failed to resolve binding: ${String(message)}`, [ContainerError.ALIAS_UNBOUND_TYPE]: `Cannot alias an unbound value : ${String(message)}`, [ContainerError.CIRCULAR_DEPENDENCY_TYPE]: `Circular dependency detected for key: ${String(message)}`, [ContainerError.RESOLVER_TYPE]: `Invalid resolver: Expected a function but received ${typeof message}`, [ContainerError.NOT_A_SERVICE_TYPE]: `This (${String(message)}) is not a service. Must contain $$metadata$$ static property or must use @Service decorator.` }; return messages[type] ?? 'An error has occurred.'; } /** * Retrieve the resolution message based on the key. * * @param key - The key for which the resolution failed. * @returns The formatted resolution error message. */ getResolutionMessage(key) { let value = ''; if (key === undefined) { value = 'undefined'; } else if (key === null) { value = 'null'; } else if (typeof key === 'function') { const funcName = key.name !== '' ? `: ${key.name}` : ''; value = `[Function${funcName}]`; } else if (typeof key === 'object') { value = `[Object: ${key.constructor.name}]`; } else if (typeof key === 'string') { value = `type ${typeof key} with a value of '${key}'`; } else if (typeof key === 'symbol') { value = key.toString(); } else { value = `type ${typeof key} with a value of ${String(key)}`; } return `Failed to resolve a binding with a key of ${value} from the service container.`; } } /** * Class representing a ResolverBinding. * * This class extends the Binding class, using a resolver function to lazily resolve the value when needed. * * @template V - The type of value that this binding holds. * @author Mr. Stone <evensstone@gmail.com> */ class ResolverBinding extends Binding { /** * The resolver function used to provide the binding value. * * This function will be called when the value is needed, allowing for lazy instantiation * and dependency resolution. It should return an instance of type `V`. */ resolver; /** * Create a new instance of ResolverBinding. * * @param resolver - The resolver function to provide the binding value. * @throws ContainerError if the resolver is not a function. */ constructor(resolver) { super(); if (typeof resolver !== 'function') { throw new ContainerError(ContainerError.RESOLVER_TYPE, resolver); } this.resolver = resolver; } } /** * Class representing a Factory. * * The Factory class extends the ResolverBinding class, providing a mechanism to resolve a new instance each time the binding is resolved. * This ensures that a fresh instance is created with each call to the `resolve` method. * * @template V - The type of value that this binding holds. * @author Mr. Stone <evensstone@gmail.com> */ class Factory extends ResolverBinding { /** * Resolve and return the value of the binding. * * Each time this method is called, a new value is resolved using the resolver function. * This is intended for cases where a fresh instance is required for each resolution, such as factories or transient dependencies. * * @param container - The container to resolve dependencies from. * @returns The resolved value of the binding. * @throws ContainerError if the value cannot be resolved. */ resolve(container) { try { return this.resolver(container); } catch (error) { throw new ContainerError(ContainerError.CANNOT_RESOLVE_TYPE, error.message); } } } /** * Class representing an Instance. * * This class extends the Binding class and directly holds an instance value. * It provides a straightforward resolution mechanism that simply returns the stored value. * * @template V - The type of value that this binding holds. * @author Mr. Stone <evensstone@gmail.com> */ class Instance extends Binding { /** * Resolve and return the value of the binding. * * @param _container - Container to resolve dependencies (not used in this implementation). * @returns The resolved value of the binding. */ resolve(_container) { return this.value; } } /** * Class representing a Singleton. * * The Singleton class extends the ResolverBinding class, ensuring that the value is only resolved once. * Subsequent calls to the `resolve` method will return the previously resolved value, making it behave as a singleton. * * @template V - The type of value that this binding holds. * @author Mr. Stone <evensstone@gmail.com> */ class Singleton extends ResolverBinding { /** * Resolve and return the value of the binding. * * If the value has already been resolved, return the cached value. Otherwise, use the resolver function * to resolve the value, store it, and return it. * * @param container - The container to resolve dependencies from. * @returns The resolved value of the binding. * @throws ContainerError if the value cannot be resolved. */ resolve(container) { if (!this.isResolved()) { try { this.value = this.resolver(container); } catch (error) { throw new ContainerError(ContainerError.CANNOT_RESOLVE_TYPE, error.message); } } return this.value; } } /** * Class representing a Container. * * The Container class acts as a dependency injection container, managing bindings and resolving instances. * It supports different types of bindings, such as singletons, factories, and instances, and allows the use of aliases for bindings. * This makes it easier to manage and resolve complex dependency trees in an application. * * @author Mr. Stone <evensstone@gmail.com> */ class Container extends Proxiable { aliases; resolvingKeys = new Set(); bindings; /** * Create a Container. * * @returns A new Container instance. */ static create() { return new this(); } /** * Create a ProxyHandler for the container. * * @returns A new ProxyHandler instance. */ static Proxyhandler() { return { get: (target, prop, receiver) => { if (Reflect.has(target, prop)) { return Reflect.get(target, prop, receiver); } else { return target.make(prop); } } }; } /** * Create a container. * * Initializes the container with empty alias and binding maps. */ constructor() { super(Container.Proxyhandler()); this.aliases = new Map(); this.bindings = new Map(); } /** * Retrieve the value of the bindings property. * * @returns A map of all bindings registered in the container. */ getBindings() { return this.bindings; } /** * Retrieve the value of the aliases property. * * @returns A map of all aliases registered in the container. */ getAliases() { return this.aliases; } /** * Set a binding as alias. * * Adds one or more aliases for a given binding key. * * @param key - The binding value. * @param aliases - One or more strings representing the aliases. * @returns The container instance. */ alias(key, aliases) { [].concat(aliases).forEach((alias) => { if (key === alias) { throw new ContainerError(ContainerError.ALIAS_TYPE, key); } else if (!this.has(key)) { throw new ContainerError(ContainerError.ALIAS_UNBOUND_TYPE, key); } this.aliases.set(alias, key); }); return this; } /** * Check if an alias exists in the container. * * @param alias - The alias to check. * @returns True if the alias exists, false otherwise. */ isAlias(alias) { return this.aliases.has(alias); } /** * Get a binding key by its alias. * * @param alias - The alias name. * @returns The binding key associated with the alias, or undefined if not found. */ getAliasKey(alias) { return this.aliases.get(alias); } /** * Bind a single instance or value into the container under the provided key. * * @param key - The key to associate with the value. * @param value - The value to be bound. * @returns The container instance. */ instance(key, value) { this.bindings.set(key, new Instance(value)); return this; } /** * Bind a single instance or value into the container under the provided key if not already bound. * * @param key - The key to associate with the value. * @param value - The value to be bound. * @returns The container instance. */ instanceIf(key, value) { if (!this.bound(key)) { this.instance(key, value); } return this; } /** * Bind a resolver function into the container under the provided key as a singleton. * * The resolver function will be called once, and the resulting value will be cached for future use. * * @param key - The key to associate with the singleton value. * @param resolver - The resolver function to provide the value. * @returns The container instance. */ singleton(key, resolver) { this.bindings.set(key, new Singleton(resolver)); return this; } /** * Bind a resolver function into the container under the provided key as a singleton if not already bound. * * @param key - The key to associate with the singleton value. * @param resolver - The resolver function to provide the value. * @returns The container instance. */ singletonIf(key, resolver) { if (!this.bound(key)) { this.singleton(key, resolver); } return this; } /** * Bind a resolver function into the container under the provided key, returning a new instance each time. * * @param key - The key to associate with the value. * @param resolver - The resolver function to provide the value. * @returns The container instance. */ binding(key, resolver) { this.bindings.set(key, new Factory(resolver)); return this; } /** * Bind a resolver function into the container under the provided key, returning a new instance each time if not already bound. * * @param key - The key to associate with the value. * @param resolver - The resolver function to provide the value. * @returns The container instance. */ bindingIf(key, resolver) { if (!this.bound(key)) { this.binding(key, resolver); } return this; } /** * Resolve a registered value from the container by its key. * * @param key - The key to resolve. * @returns The resolved value. * @throws ContainerError if the key cannot be resolved. */ make(key) { key = this.getAliasKey(key) ?? key; if (this.resolvingKeys.has(key)) { throw new ContainerError(ContainerError.CIRCULAR_DEPENDENCY_TYPE, key); } this.resolvingKeys.add(key); try { const binding = this.bindings.get(key); if (binding !== undefined) { return binding.resolve(new Proxy(this, Container.Proxyhandler())); } } finally { this.resolvingKeys.delete(key); } throw new ContainerError(ContainerError.RESOLUTION_TYPE, key); } /** * Resolve a value from the container by its key, binding it if necessary. * * @param key - The key to resolve. * @param singleton - Whether to bind as a singleton if not already bound. * @returns The resolved value. */ resolve(key, singleton = false) { if (this.has(key)) { return this.make(key); } else { return this.autoBinding(key, key, singleton).make(key); } } /** * Resolve a value from the container by its key and return it in a factory function. * * @param key - The key to resolve. * @returns A factory function that returns the resolved value. */ factory(key) { return () => this.make(key); } /** * Check if a value is already bound in the container by its key. * * @param key - The key to check. * @returns True if the key is bound, false otherwise. */ bound(key) { return this.bindings.has(this.getAliasKey(key) ?? key); } /** * Check if a value is already bound in the container by its key. * * @param key - The key to check. * @returns True if the key is bound, false otherwise. */ has(key) { return this.bound(key); } /** * Reset the container so that all bindings are removed. * * @returns The container instance. */ clear() { this.aliases.clear(); this.bindings.clear(); return this; } /** * AutoBind value to the service container. * * @param name - A key to make the binding. Can be anything. * @param item - The item to bind. * @param singleton - Bind as singleton when true. * @param alias - Key binding aliases. * @returns The container instance. */ autoBinding(name, item, singleton = true, alias = []) { const key = name; const value = item ?? name; if (!this.bound(key)) { if (typeof value === 'function') { const callable = value; const resolver = Object.hasOwn(callable, 'prototype') ? (container) => new callable.prototype.constructor(container) : (container) => callable(container); singleton ? this.singleton(key, resolver) : this.binding(key, resolver); } else { this.instance(key, value); } this.alias(key, alias); } return this; } } export { Binding, Container, ContainerError, Factory, Instance, Proxiable, ResolverBinding, Singleton };