UNPKG

proxydi

Version:

A typed hierarchical DI container that resolves circular dependencies via Proxy

797 lines (783 loc) 30.2 kB
'use strict'; const injectableClasses = {}; /** * Registers a class as an automatically injectable for dependency injection container. * * @param dependencyId - Optional dependency identifier. If omitted, the class name is used. * @returns A class decorator function. * * Note: During dependency resolution, any container that does not have an instance for the specified dependency identifier * will create an instance of the decorated class. However, if a container already has an instance with that identifier * prior to resolution, the decorated class will be ignored by that container. */ function injectable(dependencyId) { return function (value, context) { if ((context === null || context === void 0 ? void 0 : context.kind) !== 'class') { throw new Error('@injectable decorator should decorate classes'); } const name = dependencyId ? dependencyId : context.name; if (injectableClasses[name]) { throw new Error(`ProxyDi has already regisered dependency ${String(name)} by @injectable`); } injectableClasses[name] = value; }; } function findInjectableId(injectable) { // Search in string keys for (const [id, DependencyClass] of Object.entries(injectableClasses)) { if (DependencyClass === injectable) { return id; } } // Search in symbol keys for (const id of Object.getOwnPropertySymbols(injectableClasses)) { if (injectableClasses[id] === injectable) { return id; } } throw new Error(`Class is not @injectable: ${injectable.name}`); } exports.ResolveScope = void 0; (function (ResolveScope) { ResolveScope[ResolveScope["Parent"] = 1] = "Parent"; ResolveScope[ResolveScope["Current"] = 2] = "Current"; ResolveScope[ResolveScope["Children"] = 4] = "Children"; ResolveScope[ResolveScope["All"] = 7] = "All"; })(exports.ResolveScope || (exports.ResolveScope = {})); /** * Type guard to check if injection is AllInjection */ function isAllInjection(injection) { return 'isAll' in injection && injection.isAll === true; } const INJECTIONS = Symbol('injections'); /** * This symbol constant defines a property name. * This property is present in each dependency instance that was registered in ProxyDiContainer. * The property stores the dependency identifier that should be used to resolve dependency from the container where it was registered. */ const DEPENDENCY_ID = Symbol('DependencyId'); /** * This symbol constant defines a property name. * This property is present in each dependency instance that was registered in ProxyDiContainer. * The property stores a reference to the ProxyDiContainer in which the dependency was registered. */ const PROXYDI_CONTAINER = Symbol('proxyDiContainer'); const ON_CONTAINERIZED = Symbol('onContainerized'); const IS_INJECTION_PROXY = Symbol('isInjectionProxy'); const INJECTION_OWNER = Symbol('injectionOwner'); const IS_INSTANCE_PROXY = Symbol('isInstanceProxy'); /** * Registers an injection for dependency injection. * * @param dependencyId - Optional dependecy identifier. If omitted, the property name is used. * @returns A decorator function for class fields. * * The decorated field will receive its dependency from the same container as the injection owner. */ const inject = (dependencyId) => { return function (_value, context) { if ((context === null || context === void 0 ? void 0 : context.kind) === 'field') { let id; if (!dependencyId) { id = context.name; } else if (typeof dependencyId === 'function') { try { // Try to find in @injectable (for custom IDs) id = findInjectableId(dependencyId); } catch (_a) { // Fallback to class.name for non-injectable classes if (dependencyId.name) { id = dependencyId.name; } else { throw new Error('Invalid dependency class'); } } } else { id = dependencyId; } const injection = { property: context.name, dependencyId: id, set: context.access.set, }; context.addInitializer(function () { if (!this[INJECTIONS]) { this[INJECTIONS] = {}; } this[INJECTIONS][injection.property] = injection; }); } else { throw new Error('@inject decorator should decorate fields'); } }; }; /** * Registers an injection for multiple dependencies of the same type. * * @param dependencyId - Dependency identifier to resolve all instances from container hierarchy. * @param scope - Bitwise enum to control where to search (Parent | Current | Children). Defaults to Children. * @returns A decorator function for class fields. * * The decorated field will receive an array of all dependencies with the given ID * from the container hierarchy according to the scope parameter. */ const injectAll = (dependencyId, scope = exports.ResolveScope.Children) => { return function (_value, context) { if ((context === null || context === void 0 ? void 0 : context.kind) === 'field') { let id; if (typeof dependencyId === 'function') { try { // Try to find in @injectable (for custom IDs) id = findInjectableId(dependencyId); } catch (_a) { // Fallback to class.name for non-injectable classes if (dependencyId.name) { id = dependencyId.name; } else { throw new Error('Invalid dependency class'); } } } else { id = dependencyId; } const injection = { property: context.name, dependencyId: id, set: context.access.set, isAll: true, scope: scope, }; context.addInitializer(function () { if (!this[INJECTIONS]) { this[INJECTIONS] = {}; } this[INJECTIONS][injection.property] = injection; }); } else { throw new Error('@injectAll decorator should decorate fields'); } }; }; const DEFAULT_SETTINGS = { allowRewriteDependencies: false, resolveInContainerContext: false, }; var _a; class InjectionProxy { constructor(onwer, container) { this[_a] = true; this[INJECTION_OWNER] = onwer; this[PROXYDI_CONTAINER] = container; } } _a = IS_INJECTION_PROXY; const makeInjectionProxy = (injection, injectionOwner, container) => { function getDependency() { if (container.isKnown(injection.dependencyId)) { const dependency = container.resolve(injection.dependencyId); if (!container.settings.allowRewriteDependencies) { injection.set(injectionOwner, dependency); } return dependency; } else { throw new Error(`Unknown dependency: ${String(injection.dependencyId)}`); } } return new Proxy(new InjectionProxy(injectionOwner, container), { get: function (target, prop, receiver) { if (target[prop]) { return target[prop]; } const dependency = getDependency(); return Reflect.get(dependency, prop, receiver); }, set: function (target, prop, value) { const dependency = getDependency(); return Reflect.set(dependency, prop, value); }, has: function (target, prop) { const dependency = getDependency(); return Reflect.has(dependency, prop); }, }); }; const makeInjectAllProxy = (injection, injectionOwner, container) => { function getDependencies() { const ownerContainer = injectionOwner[PROXYDI_CONTAINER]; if (!ownerContainer) { throw new Error('Instance is not registered in any container'); } // Use existing resolveAll function with scope from injection return ownerContainer.resolveAll(injection.dependencyId, injection.scope); } return new Proxy(new InjectionProxy(injectionOwner, container), { get: function (target, prop, receiver) { if (target[prop]) { return target[prop]; } const dependencies = getDependencies(); return Reflect.get(dependencies, prop, receiver); }, set: function (target, prop, value) { const dependencies = getDependencies(); return Reflect.set(dependencies, prop, value); }, has: function (target, prop) { const dependencies = getDependencies(); return Reflect.has(dependencies, prop); }, }); }; function makeDependencyProxy(dependency) { const injectionValues = {}; return new Proxy(dependency, { get: function (target, prop, receiver) { if (prop === IS_INSTANCE_PROXY) { return true; } if (injectionValues[prop]) { return injectionValues[prop]; } return Reflect.get(target, prop, receiver); }, set: function (target, prop, value) { injectionValues[prop] = value; return Reflect.set(target, prop, value); }, }); } const middlewaresClasses = {}; function middleware() { return function (value, context) { if ((context === null || context === void 0 ? void 0 : context.kind) !== 'class') { throw new Error('@middleware decorator should decorate classes'); } const name = context.name; if (middlewaresClasses[name]) { throw new Error(`ProxyDi has already regisered middleware ${String(name)} by @middleware`); } middlewaresClasses[name] = value; }; } class MiddlewareManager { constructor(parent) { this.parent = parent; this.handlers = { register: [], remove: [], resolve: [], }; } add(middleware) { if (isRegistrator(middleware)) { middleware.onRegister && this.on('register', middleware.onRegister); } if (isRemover(middleware)) { middleware.onRemove && this.on('remove', middleware.onRemove); } if (isResolver(middleware)) { middleware.onResolve && this.on('resolve', middleware.onResolve); } } remove(middleware) { if (isRegistrator(middleware)) { middleware.onRegister && this.off('register', middleware.onRegister); } if (isRemover(middleware)) { middleware.onRemove && this.off('remove', middleware.onRemove); } if (isResolver(middleware)) { middleware.onResolve && this.off('resolve', middleware.onResolve); } } on(event, listener) { this.handlers[event].push(listener); } onRegister(context) { var _a; this.handlers.register.forEach((listener) => listener(context)); (_a = this.parent) === null || _a === void 0 ? void 0 : _a.onRegister(context); } onRemove(context) { var _a; this.handlers.remove.forEach((listener) => listener(context)); (_a = this.parent) === null || _a === void 0 ? void 0 : _a.onRemove(context); } onResolve(context) { let result = context; this.handlers.resolve.forEach((listener) => { result = listener(result); }); return result; } off(event, listener) { const index = this.handlers[event].indexOf(listener); if (index !== -1) { this.handlers[event].splice(index, 1); } } } function isRegistrator(middleware) { return !!middleware.onRegister; } function isRemover(middleware) { return !!middleware.onRemove; } function isResolver(middleware) { return !!middleware.onResolve; } /** * A dependency injection container */ class ProxyDiContainer { /** * Creates a new instance of ProxyDiContainer. * @param settings Optional container settings to override defaults. * @param parent Optional parent container. */ constructor(settings, parent) { this._children = {}; /** * Holds dependency instances registered particular in this container. */ this.dependencies = {}; /** * Holds proxies for dependencies registered in parent containers to provide for it dependencies from this container */ this.inContextProxies = {}; this.resolveImpl = (dependencyId, scope = exports.ResolveScope.Current | exports.ResolveScope.Parent) => { // Current - check inContextProxies and dependencies if (scope & exports.ResolveScope.Current) { const proxy = this.inContextProxies[dependencyId]; if (proxy) { return proxy; } const instance = this.dependencies[dependencyId]; if (instance) { return instance; } } // Parent - recursively check up the hierarchy if (scope & exports.ResolveScope.Parent) { if (this.parent) { const parentInstance = this.parent.findDependency(dependencyId); if (parentInstance) { if (parentInstance[PROXYDI_CONTAINER] !== this && typeof parentInstance === 'object' && this.settings.resolveInContainerContext) { const proxy = makeDependencyProxy(parentInstance); this.injectDependenciesTo(proxy); this.inContextProxies[dependencyId] = proxy; return proxy; } return parentInstance; } } } // Children - recursively check down the hierarchy if (scope & exports.ResolveScope.Children) { for (const child of this.children) { if (child.hasOwn(dependencyId)) { return child.resolve(dependencyId, exports.ResolveScope.Current); } if (child.isKnown(dependencyId, exports.ResolveScope.Children)) { return child.resolve(dependencyId, exports.ResolveScope.Children); } } } // @injectable - create instance (always reached if isKnown returned true) const InjectableClass = injectableClasses[dependencyId]; return this.register(InjectableClass, dependencyId); }; this.id = ProxyDiContainer.idCounter++; this.middlewareManager = new MiddlewareManager(parent === null || parent === void 0 ? void 0 : parent.middlewareManager); if (parent) { this.parent = parent; this.parent.addChild(this); } this.settings = Object.assign(Object.assign({}, DEFAULT_SETTINGS), settings); } registerMiddleware(middleware) { this.middlewareManager.add(middleware); } removeMiddleware(middleware) { this.middlewareManager.remove(middleware); } register(dependency, dependecyId) { var _a, _b; let id; if (dependecyId) { id = this.normalizeDependencyId(dependecyId); } else if (typeof dependency === 'function') { try { id = findInjectableId(dependency); } catch (_c) { id = dependency.name; } } else if (((_a = dependency === null || dependency === void 0 ? void 0 : dependency.constructor) === null || _a === void 0 ? void 0 : _a.name) && dependency.constructor.name !== 'Object') { id = dependency.constructor.name; } else { throw new Error('dependencyId is required when registering plain objects or literals'); } if (this.dependencies[id]) { if (!this.settings.allowRewriteDependencies) { throw new Error(`ProxyDi already has dependency for ${String(id)}`); } } let instance; const isClass = typeof dependency === 'function'; if (isClass) { instance = new dependency(); } else { instance = dependency; } const isObject = typeof instance === 'object'; if (isObject) { instance[PROXYDI_CONTAINER] = this; instance[DEPENDENCY_ID] = id; instance[ON_CONTAINERIZED] && instance[ON_CONTAINERIZED](this); } this.injectDependenciesTo(instance); this.dependencies[id] = instance; const constructorName = (_b = instance.constructor) === null || _b === void 0 ? void 0 : _b.name; if (constructorName && middlewaresClasses[constructorName]) { this.middlewareManager.add(instance); } let context = { container: this, dependencyId: id, dependency: instance, }; this.middlewareManager.onRegister(context); return instance; } /** * Checks if a dependency with the given ID is known to the container based on the scope. * @param dependencyId The identifier of the dependency. Can be a string, symbol, or class constructor (which will be normalized to class name). * @param scope Bitwise enum to control where to search. Defaults to Current | Parent (searches up the hierarchy). * @returns True if the dependency is known, false otherwise. */ isKnown(dependencyId, scope = exports.ResolveScope.Current | exports.ResolveScope.Parent) { const id = this.normalizeDependencyId(dependencyId); // @injectable classes are always available if (injectableClasses[id]) { return true; } // Current - check in this container if (scope & exports.ResolveScope.Current) { if (this.inContextProxies[id] || this.dependencies[id]) { return true; } } // Parent - recursively check up the hierarchy if (scope & exports.ResolveScope.Parent) { if (this.parent && this.parent.isKnown(id, exports.ResolveScope.Current | exports.ResolveScope.Parent)) { return true; } } // Children - recursively check down the hierarchy if (scope & exports.ResolveScope.Children) { for (const child of this.children) { if (child.isKnown(id, exports.ResolveScope.Current | exports.ResolveScope.Children)) { return true; } } } return false; } /** * Checks if a dependency with the given ID exists in this container only (does not check parents) * @param dependencyId The identifier of the dependency. Can be a string, symbol, or class constructor (which will be normalized to class name). * @returns True if the dependency exists in this container, false otherwise. */ hasOwn(dependencyId) { const id = this.normalizeDependencyId(dependencyId); return !!(this.inContextProxies[id] || this.dependencies[id]); } resolve(dependency, scope = exports.ResolveScope.Current | exports.ResolveScope.Parent) { if (typeof dependency === 'function') { let id; try { id = findInjectableId(dependency); } catch (_a) { id = dependency.name; } return this.resolve(id, scope); } if (!this.isKnown(dependency, scope)) { throw new Error(`Can't resolve unknown dependency: ${String(dependency)}`); } let context = { container: this, dependencyId: dependency, dependency: this.resolveImpl(dependency, scope), }; context = this.middlewareManager.onResolve(context); return context.dependency; } /** * Injects dependencies to the given object based on its defined injections metadata. Does not affect the container. * @param injectionsOwner The object to inject dependencies into. */ injectDependenciesTo(injectionsOwner) { const dependencyInjects = injectionsOwner[INJECTIONS] || {}; Object.values(dependencyInjects).forEach((injection) => { const dependencyProxy = isAllInjection(injection) ? makeInjectAllProxy(injection, injectionsOwner, this) : makeInjectionProxy(injection, injectionsOwner, this); injection.set(injectionsOwner, dependencyProxy); }); } /** * Creates instances for all injectable classes and registers them in this container. * @returns This container to allow use along with constructor. */ registerInjectables() { for (const [dependencyId, InjectableClass] of Object.entries(injectableClasses)) { this.register(InjectableClass, dependencyId); } return this; } /** * Finalizes dependency injections, prevents further rewriting of dependencies, * and recursively bakes injections for child containers. */ bakeInjections() { for (const dependency of Object.values(this.dependencies)) { const dependencyInjects = dependency[INJECTIONS] || {}; Object.values(dependencyInjects).forEach((inject) => { if (!isAllInjection(inject)) { // Only bake single injections // Array injections (@injectAll) remain dynamic - array updates on each access, // but elements are baked through container.resolve() const value = this.resolve(inject.dependencyId); inject.set(dependency, value); } }); } this.settings.allowRewriteDependencies = false; for (const child of Object.values(this._children)) { child.bakeInjections(); } } /** * Creates a child container that inherits settings and dependencies from this container. * @returns A new child instance of ProxyDiContainer. */ createChildContainer() { return new ProxyDiContainer(this.settings, this); } /** * Removes a given dependency from the container using either the dependency instance or its ID. * @param dependencyOrId The dependency instance or dependency identifier to remove. */ remove(dependencyOrId) { var _a; const id = isDependency(dependencyOrId) ? dependencyOrId[DEPENDENCY_ID] : dependencyOrId; const dependency = this.dependencies[id]; if (dependency) { const constructorName = (_a = dependency.constructor) === null || _a === void 0 ? void 0 : _a.name; if (constructorName && middlewaresClasses[constructorName]) { this.middlewareManager.remove(dependency); } const dependencyInjects = dependency[INJECTIONS] ? dependency[INJECTIONS] : {}; Object.values(dependencyInjects).forEach((inject) => { inject.set(dependency, undefined); }); delete dependency[DEPENDENCY_ID]; delete this.dependencies[id]; this.middlewareManager.onRemove({ container: this, dependencyId: id, dependency, }); } } /** * Destroys the container by removing all dependencies, * recursively destroying child containers and removing itself from its parent. */ destroy() { const allDependencies = Object.values(this.dependencies); for (const dependency of allDependencies) { this.remove(dependency); } this.dependencies = {}; for (const child of Object.values(this._children)) { child.destroy(); } this._children = {}; if (this.parent) { this.parent.removeChild(this.id); this.parent = undefined; } } /** * Recursively finds a dependency by its ID from this container or its parent. * @param dependencyId The identifier of the dependency to find. * @returns The dependency if found, otherwise undefined. */ findDependency(dependencyId) { const dependency = this.dependencies[dependencyId]; if (!dependency && this.parent) { const parentDependency = this.parent.findDependency(dependencyId); return parentDependency; } return dependency; } /** * Normalizes dependency identifier by converting class constructors to their names. * @param id The dependency identifier (string, symbol, or class constructor). * @returns Normalized dependency identifier (string or symbol). */ normalizeDependencyId(id) { if (typeof id === 'function') { try { return findInjectableId(id); } catch (_a) { return id.name; } } return id; } resolveAll(dependencyId, scope = exports.ResolveScope.Children) { const id = this.normalizeDependencyId(dependencyId); return this.recursiveResolveAll(id, scope); } recursiveResolveAll(dependencyId, scope = exports.ResolveScope.All) { if (scope === 0) { throw new Error('ResolveScope must have at least one flag set'); } let all = []; // Current - search in current container only if (scope & exports.ResolveScope.Current) { if (this.hasOwn(dependencyId)) { all.push(this.resolve(dependencyId)); } } // Parent - search up the hierarchy if (scope & exports.ResolveScope.Parent) { // eslint-disable-next-line @typescript-eslint/no-this-alias let parent = this.parent; if (parent && parent.isKnown(dependencyId)) { const dependency = parent.resolve(dependencyId); all.push(dependency); } } // Children - recursively search down the hierarchy if (scope & exports.ResolveScope.Children) { for (const child of this.children) { // Recursive call with Children + Current (always include Current for children) const childScope = exports.ResolveScope.Children | exports.ResolveScope.Current; const childResults = child.recursiveResolveAll(dependencyId, childScope); all = all.concat(childResults); } } // Remove duplicates - same instance should not appear twice const unique = []; const seen = new Set(); for (const item of all) { if (!seen.has(item)) { seen.add(item); unique.push(item); } } return unique; } /** * All direct descendants of this container */ get children() { return Object.values(this._children); } /** * * @param id Unique identifier of container * @returns */ getChild(id) { const child = this._children[id]; if (!child) { throw new Error(`Unknown ProxyDiContainer child ID: ${id}`); } return child; } /** * Registers a child container to this container. * @param child The child container to add. * @throws Error if a child with the same ID already exists. */ addChild(child) { if (this._children[child.id]) { throw new Error(`ProxyDi already has child with id ${child.id}`); } this._children[child.id] = child; } /** * Removes a child container by its ID. * @param id The identifier of the child container to remove. */ removeChild(id) { const child = this._children[id]; if (child) { delete this._children[id]; } } } /** * Static counter used to assign unique IDs to containers. */ ProxyDiContainer.idCounter = 0; /** * Helper function to determine if the provided argument is a dependency instance. * @param dependencyOrId The dependency instance or dependency identifier. * @returns True if the argument is a dependency instance, false otherwise. */ function isDependency(dependencyOrId) { return (typeof dependencyOrId === 'object' && !!dependencyOrId[DEPENDENCY_ID]); } function resolveAll(instance, dependencyId, scope = exports.ResolveScope.Children) { if (typeof dependencyId === 'function') { const id = findInjectableId(dependencyId); return resolveAll(instance, id, scope); } const container = instance[PROXYDI_CONTAINER]; if (!container) { throw new Error('Instance is not registered in any container'); } return container.resolveAll(dependencyId, scope); } exports.DEPENDENCY_ID = DEPENDENCY_ID; exports.ON_CONTAINERIZED = ON_CONTAINERIZED; exports.PROXYDI_CONTAINER = PROXYDI_CONTAINER; exports.ProxyDiContainer = ProxyDiContainer; exports.inject = inject; exports.injectAll = injectAll; exports.injectable = injectable; exports.middleware = middleware; exports.resolveAll = resolveAll;