import { DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass, StaticInstantiable, TypedDependencyKey, } from './types' import {AbstractFactory} from './factory/AbstractFactory' import { Awaitable, collect, Collection, globalRegistry, hasOwnProperty, logIfDebugging, Unsubscribe, } from '../util' import {ErrorWithContext, withErrorContext} from '../util/error/ErrorWithContext' import {Factory} from './factory/Factory' import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError' import {ClosureFactory} from './factory/ClosureFactory' import NamedFactory from './factory/NamedFactory' import SingletonFactory from './factory/SingletonFactory' import {InvalidDependencyKeyError} from './error/InvalidDependencyKeyError' import {ContainerBlueprint, ContainerResolutionCallback} from './ContainerBlueprint' export type MaybeFactory = AbstractFactory | undefined export type MaybeDependency = any | undefined export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resolved: any } /** * Singletons that implement this interface receive callbacks for * structural container events. */ export interface AwareOfContainerLifecycle { awareOfContainerLifecycle: true /** Called when this key is realized by its parent container. */ onContainerRealize?(): Awaitable /** Called before the parent container of this instance is destroyed. */ onContainerDestroy?(): Awaitable /** Called before an instance of a key is released from the container. */ onContainerRelease?(): Awaitable } export function isAwareOfContainerLifecycle(what: unknown): what is AwareOfContainerLifecycle { return Boolean( typeof what === 'object' && what !== null && hasOwnProperty(what, 'awareOfContainerLifecycle') && what.awareOfContainerLifecycle, ) } /** * A container of resolve-able dependencies that are created via inversion-of-control. */ export class Container { /** * Set to true when we're realizing a container. * Used to prevent infinite recursion when `getContainer()` is accidentally called * from somewhere within the `realizeContainer()` call. */ protected static realizingContainer = false /** * List of dependency keys currently being `make`'d as a reverse stack. * This is used to detect dependency cycles. * @protected */ protected static makeStack?: Collection /** * The 100 most recent dependency keys that were `make`'d. Used to help with * debugging cyclic dependency errors. * @protected */ protected static makeHistory?: Collection /** * Given a Container instance, apply the ContainerBlueprint to it. * @param container */ public static realizeContainer(container: T): T { ContainerBlueprint.getContainerBlueprint() .resolve() .map(factory => container.registerFactory(factory)) ContainerBlueprint.getContainerBlueprint() .resolveConstructable() .map((factory: StaticClass, any>) => container.registerFactory(container.make(factory))) ContainerBlueprint.getContainerBlueprint() .resolveResolutionCallbacks() .map((listener: {key: TypedDependencyKey, callback: ContainerResolutionCallback}) => { container.onResolve(listener.key) .then(value => listener.callback(value)) }) container.subscribeToBlueprintChanges(ContainerBlueprint.getContainerBlueprint()) return container } /** * Get the global instance of this container. */ public static getContainer(): Container { const existing = globalRegistry.getGlobal('extollo/injector') if ( !existing ) { if ( this.realizingContainer ) { throw new ErrorWithContext('Attempted getContainer call during container realization.') } this.realizingContainer = true const container = Container.realizeContainer(new Container()) globalRegistry.setGlobal('extollo/injector', container) this.realizingContainer = false return container } return existing } /** * Collection of factories registered with this container. * @type Collection */ protected factories: Collection> = new Collection>() /** * Collection of singleton instances produced by this container. * @type Collection */ protected instances: Collection = new Collection() /** * Collection of static-class overrides registered with this container. * @protected */ protected staticOverrides: Collection<{ base: StaticInstantiable, override: StaticInstantiable }> = new Collection<{base: StaticInstantiable; override: StaticInstantiable}>() /** * Collection of callbacks waiting for a dependency key to be resolved. * @protected */ protected waitingResolveCallbacks: Collection<{ key: DependencyKey, callback: (t: unknown) => unknown }> = new Collection<{key: DependencyKey; callback:(t: unknown) => unknown}>() /** * Collection of created objects that should have lifecycle events called on them, if they still exist. * @protected */ protected waitingLifecycleCallbacks: Collection> = new Collection() /** * Collection of subscriptions to ContainerBlueprint events. * We keep this around so we can remove the subscriptions when the container is destroyed. * @protected */ protected blueprintSubscribers: Collection = new Collection() constructor() { this.registerSingletonInstance(Container, this) this.registerSingleton('injector', this) } /** Make the container listen to changes in the given blueprint. */ private subscribeToBlueprintChanges(blueprint: ContainerBlueprint): void { this.blueprintSubscribers.push( blueprint.resolve$(factory => this.registerFactory(factory())), ) this.blueprintSubscribers.push( blueprint.resolveConstructable$(factoryClass => this.registerFactory(this.make(factoryClass))), ) this.blueprintSubscribers.push( blueprint.resolveResolutionCallbacks$(listener => this.onResolve(listener.key).then(value => listener.callback(value))), ) } /** * Purge all factories and instances of the given key from this container. * @param key */ purge(key: DependencyKey): this { this.factories = this.factories.filter(x => !x.match(key)) this.release(key) return this } /** * Remove all stored instances of the given key from this container. * @param key */ release(key: DependencyKey): this { this.instances = this.instances.filter(x => { if ( x.key === key && isAwareOfContainerLifecycle(x.value) ) { x.value.onContainerRelease?.() } return x.key !== key }) return this } /** * Register a basic instantiable class as a standard Factory with this container. * @param {Instantiable} dependency */ register(dependency: Instantiable): this { if ( this.resolve(dependency) ) { throw new DuplicateFactoryKeyError(dependency) } const factory = new Factory(dependency) this.factories.push(factory) return this } /** * Register a static class as an override of some base class. * @param base * @param override */ registerStaticOverride(base: StaticInstantiable, override: StaticInstantiable): this { if ( this.hasStaticOverride(base) ) { throw new DuplicateFactoryKeyError(base) } this.staticOverrides.push({ base, override, }) return this } /** Returns true if a static override exists for the given base class. */ hasStaticOverride(base: StaticInstantiable): boolean { return this.staticOverrides.where('base', '=', base).isNotEmpty() } /** * Get the static class overriding the base class. * @param base */ getStaticOverride(base: StaticInstantiable): StaticInstantiable { const override = this.staticOverrides.firstWhere('base', '=', base) if ( override ) { return override.override } return base } /** * Get the registered instance of the static override of a given class. * @param base * @param parameters */ makeByStaticOverride(base: StaticInstantiable, ...parameters: any[]): T { const key = this.getStaticOverride(base) return this.make(key, ...parameters) } /** * Register the given function as a factory within the container. * @param {string} name - unique name to identify the factory in the container * @param {function} producer - factory to produce a value */ registerProducer(name: DependencyKey, producer: () => any): this { if ( this.resolve(name) ) { throw new DuplicateFactoryKeyError(name) } const factory = new ClosureFactory(name, producer) this.factories.push(factory) return this } /** * Register a basic instantiable class as a standard Factory with this container, * identified by a string name rather than static class. * @param {string} name - unique name to identify the factory in the container * @param {Instantiable} dependency */ registerNamed(name: string, dependency: Instantiable): this { if ( this.resolve(name) ) { throw new DuplicateFactoryKeyError(name) } const factory = new NamedFactory(name, dependency) this.factories.push(factory) return this } /** * Register a value as a singleton in the container. It will not be instantiated, but * can be injected by its unique name. * @param {string} key - unique name to identify the singleton in the container * @param value */ registerSingleton(key: DependencyKey, value: T): this { if ( this.resolve(key) ) { throw new DuplicateFactoryKeyError(key) } this.factories.push(new SingletonFactory(key, value)) return this } /** * Register a static class to the container along with its already-instantiated * instance that will be used to resolve the class. * @param staticClass * @param instance */ registerSingletonInstance(staticClass: StaticClass | Instantiable, instance: T): this { if ( this.resolve(staticClass) ) { throw new DuplicateFactoryKeyError(staticClass) } this.register(staticClass) this.instances.push({ key: staticClass, value: instance, }) return this } /** * Register a given factory with the container. * @param {AbstractFactory} factory */ registerFactory(factory: AbstractFactory): this { if ( !this.factories.includes(factory) ) { this.factories.push(factory) } return this } /** * Returns true if the container has an already-produced value for the given key. * @param {DependencyKey} key */ hasInstance(key: DependencyKey): boolean { return this.instances.where('key', '=', key).isNotEmpty() } /** * Get a Promise that resolves the first time the given dependency key is resolved * by the application. If it has already been resolved, the Promise will resolve immediately. * @param key */ onResolve(key: TypedDependencyKey): Promise { if ( this.hasInstance(key) ) { return new Promise(res => res(this.make(key))) } // Otherwise, we haven't instantiated an instance with this key yet, // so put it onto the waitlist. return new Promise(res => { this.waitingResolveCallbacks.push({ key, callback: (res as (t: unknown) => unknown), }) }) } /** * Returns true if the container has a factory for the given key. * @param {DependencyKey} key */ hasKey(key: DependencyKey): boolean { return Boolean(this.resolve(key)) } /** * Get the already-produced value for the given key, if one exists. * @param {DependencyKey} key */ getExistingInstance(key: DependencyKey): MaybeDependency { const instances = this.instances.where('key', '=', key) if ( instances.isNotEmpty() ) { return instances.first() } } /** * Find the factory for the given key, if one is registered with this container. * @param {DependencyKey} key */ resolve(key: DependencyKey): MaybeFactory { const factory = this.factories.firstWhere(item => item.match(key)) if ( factory ) { return factory } else { logIfDebugging('extollo.di.injector', 'unable to resolve factory', key, factory, this.factories) } } /** * Resolve the dependency key. If a singleton value for that key already exists in this container, * return that value. Otherwise, use the factory and given parameters to produce and return the value. * @param {DependencyKey} key * @param {...any} parameters */ resolveAndCreate(key: DependencyKey, ...parameters: any[]): any { logIfDebugging('extollo.di.injector', 'resolveAndCreate', key, {parameters}) // If we've already instantiated this, just return that const instance = this.getExistingInstance(key) logIfDebugging('extollo.di.injector', 'resolveAndCreate existing instance?', instance) if ( typeof instance !== 'undefined' ) { return instance.value } // Otherwise, attempt to create it const factory = this.resolve(key) logIfDebugging('extollo.di.injector', 'resolveAndCreate factory', factory) if ( !factory ) { throw new InvalidDependencyKeyError(key) } // Produce and store a new instance const newInstance = this.produceFactory(factory, parameters) this.instances.push({ key, value: newInstance, }) if ( isAwareOfContainerLifecycle(newInstance) ) { newInstance.onContainerRealize?.() } this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => { if ( waiter.key === key ) { waiter.callback(newInstance) return false } return true }) return newInstance } /** * Given a factory and manually-provided parameters, resolve the dependencies for the * factory and produce its value. * @param {AbstractFactory} factory * @param {array} parameters */ protected produceFactory(factory: AbstractFactory, parameters: any[]): T { logIfDebugging('extollo.di.injector', 'Make stack', Container.makeStack) // Create the dependencies for the factory const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key)) const dependencies = keys.map(req => { return withErrorContext(() => { return { paramIndex: req.paramIndex, key: req.key, resolved: this.resolveAndCreate(req.key), } }, { producingToken: factory.getTokenName(), constructorDependency: req, }) }).sortBy('paramIndex') // Build the arguments for the factory, using dependencies in the // correct paramIndex positions, or parameters of we don't have // the dependency. const constructorArguments = [] const params = collect(parameters).reverse() for ( let i = 0; i <= dependencies.max('paramIndex'); i++ ) { const dep = dependencies.firstWhere('paramIndex', '=', i) if ( dep ) { constructorArguments.push(dep.resolved) } else { constructorArguments.push(params.pop()) } } // Produce a new instance const inst = factory.produce(constructorArguments, params.reverse().all()) logIfDebugging('extollo.di.injector', 'Resolving dependencies for factory', factory) factory.getInjectedProperties().each(dependency => { logIfDebugging('extollo.di.injector', 'Resolving injected dependency:', dependency) if ( dependency.key && inst ) { withErrorContext(() => { (inst as any)[dependency.property] = this.resolveAndCreate(dependency.key) }, { producingToken: factory.getTokenName(), propertyDependency: dependency, }) } }) if ( isAwareOfContainerLifecycle(inst) ) { this.waitingLifecycleCallbacks.push(new WeakRef(inst)) } return inst } /** * Create an instance of the given target. The target can either be a DependencyKey registered with * this container (in which case, the singleton value will be returned), or an instantiable class. * * If the instantiable class has the Injectable decorator, its injectable parameters will be automatically * injected into the instance. * @param {DependencyKey} target * @param {...any} parameters */ make(target: DependencyKey, ...parameters: any[]): T { if ( !Container.makeStack ) { Container.makeStack = new Collection() } if ( !Container.makeHistory ) { Container.makeHistory = new Collection() } Container.makeStack.push(target) if ( Container.makeHistory.length > 100 ) { Container.makeHistory = Container.makeHistory.slice(1, 100) } Container.makeHistory.push(target) this.checkForMakeCycles() try { const result = withErrorContext(() => { if (this.hasKey(target)) { const realized = this.resolveAndCreate(target, ...parameters) Container.makeStack?.pop() return realized } else if (typeof target !== 'string' && isInstantiable(target)) { const realized = this.produceFactory(new Factory(target), parameters) Container.makeStack?.pop() return realized } }, { makeStack: Container.makeStack.map(x => typeof x === 'string' ? x : (x?.name || 'unknown')).toArray(), }) if ( result ) { return result } } catch (e: unknown) { Container.makeStack.pop() throw e } Container.makeStack.pop() throw new TypeError(`Invalid or unknown make target: ${target}`) } /** * Check the `makeStack` for duplicates and throw an error if a dependency cycle is * detected. This is used to prevent infinite mutual recursion when cyclic dependencies * occur. * @protected */ protected checkForMakeCycles(): void { if ( !Container.makeStack ) { Container.makeStack = new Collection() } if ( !Container.makeHistory ) { Container.makeHistory = new Collection() } if ( Container.makeStack.unique().length !== Container.makeStack.length ) { const displayKey = (key: DependencyKey) => { if ( typeof key === 'string' ) { return 'key: `' + key + '`' } else { return `key: ${key.name}` } } const makeStack = Container.makeStack .reverse() .map(displayKey) const makeHistory = Container.makeHistory .reverse() .map(displayKey) console.error('Make Stack:') // eslint-disable-line no-console console.error(makeStack.join('\n')) // eslint-disable-line no-console console.error('Make History:') // eslint-disable-line no-console console.error(makeHistory.join('\n')) // eslint-disable-line no-console throw new ErrorWithContext('Cyclic dependency chain detected', { makeStack, makeHistory, }) } } /** * Create a new instance of the dependency key using this container, ignoring any pre-existing instances * in this container. * @param key * @param parameters */ makeNew(key: TypedDependencyKey, ...parameters: any[]): T { if ( isInstantiable(key) ) { const result = this.produceFactory(new Factory(key), parameters) if ( isAwareOfContainerLifecycle(result) ) { result.onContainerRealize?.() } return result } throw new TypeError(`Invalid or unknown make target: ${key}`) } /** * Get a collection of dependency keys required by the given target, if it is registered with this container. * @param {DependencyKey} target */ getDependencies(target: DependencyKey): Collection { const factory = this.resolve(target) if ( !factory ) { throw new InvalidDependencyKeyError(target) } return factory.getDependencyKeys().pluck('key') } /** * Perform any cleanup necessary to destroy this container instance. */ destroy(): void { this.blueprintSubscribers.mapCall('unsubscribe') this.waitingLifecycleCallbacks .mapCall('deref') .whereDefined() .each(inst => { if ( isAwareOfContainerLifecycle(inst) ) { inst.onContainerRelease?.() inst.onContainerDestroy?.() } }) } /** * Given a different container, copy the factories and instances from this container over to it. * @param container */ cloneTo(container: Container): this { container.factories = this.factories.clone() container.instances = this.instances.clone() return this } }