@stone-js/service-container
Version:
Javascript/Typescript IoC Service Container with proxy resolver and destructuring injection
557 lines (548 loc) • 18.4 kB
JavaScript
/**
* 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 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 };