UNPKG

@furystack/inject

Version:
207 lines (170 loc) 6.43 kB
import { isAsyncDisposable, isDisposable } from '@furystack/utils' import { Injectable, getInjectableOptions, type InjectorLifetime } from './injectable.js' import { getDependencyList } from './injected.js' import type { Constructable } from './models/constructable.js' import { withInjectorReference } from './with-injector-reference.js' const hasInitMethod = (obj: object): obj is { init: (injector: Injector) => void } => { return typeof (obj as { init?: (injector: Injector) => void }).init === 'function' } export class InjectorAlreadyDisposedError extends Error { constructor() { super('Injector already disposed') } } const lifetimesToCache: InjectorLifetime[] = ['singleton', 'scoped', 'explicit'] export class CannotInstantiateExplicitLifetimeError extends Error { /** * */ constructor(ctor: Constructable<unknown>) { super( `Cannot instantiate an instance of '${ctor.name}' as it's lifetime is set to 'explicit'. Ensure to initialize it properly`, ) } } @Injectable({ lifetime: 'system' as 'singleton' }) export class Injector implements AsyncDisposable { private isDisposed = false /** * Disposes the Injector object and all its disposable injectables */ public async [Symbol.asyncDispose]() { if (this.isDisposed) { throw new InjectorAlreadyDisposedError() } this.isDisposed = true const singletons = Array.from(this.cachedSingletons.entries()).map((e) => e[1]) const disposeRequests = singletons .filter((s) => s !== this) .map(async (s) => { if (isDisposable(s)) { s[Symbol.dispose]() } if (isAsyncDisposable(s)) { await s[Symbol.asyncDispose]() } }) const result = await Promise.allSettled(disposeRequests) const fails = result.filter((r) => r.status === 'rejected') if (fails && fails.length) { throw new Error( `There was an error during disposing '${fails.length}' global disposable objects: ${fails .map((f) => f.reason as string) .join(', ')}`, ) } this.cachedSingletons.clear() } /** * Options object for an injector instance */ public options: { parent?: Injector; owner?: unknown } = {} // public static injectableFields: Map<Constructable<any>, { [K: string]: Constructable<any> }> = new Map() public readonly cachedSingletons: Map<Constructable<unknown>, unknown> = new Map() public remove = <T>(ctor: Constructable<T>) => { if (this.isDisposed) { throw new InjectorAlreadyDisposedError() } this.cachedSingletons.delete(ctor) } public getInstance<T>(ctor: Constructable<T>): T { return withInjectorReference(this.getInstanceInternal(ctor), this) } /** * * @param ctor The constructor object (e.g. MyClass) * @returns The instance of the requested service */ private getInstanceInternal<T>(ctor: Constructable<T>): T { if (this.isDisposed) { throw new InjectorAlreadyDisposedError() } if (ctor === this.constructor) { return this as any as T } const existing = this.cachedSingletons.get(ctor) if (existing) { return existing as T } const meta = getInjectableOptions(ctor) const { lifetime } = meta const fromParent = (lifetime === 'singleton' || lifetime === 'explicit') && this.options.parent && this.options.parent.getInstanceInternal(ctor) if (fromParent) { return fromParent } if (lifetime === 'explicit') { throw new CannotInstantiateExplicitLifetimeError(ctor) } const dependencies = [...getDependencyList(ctor)] if (dependencies.includes(ctor)) { throw Error(`Circular dependencies found.`) } if (lifetime === 'singleton') { const invalidDeps = dependencies .map((dep) => ({ meta: getInjectableOptions(dep), dep })) .filter((m) => m.meta && (m.meta.lifetime === 'scoped' || m.meta.lifetime === 'transient')) .map((i) => i.meta && `${i.dep.name}:${i.meta.lifetime}`) if (invalidDeps.length) { throw Error( `Injector error: Singleton type '${ctor.name}' depends on non-singleton injectables: ${invalidDeps.join( ',', )}`, ) } } else if (lifetime === 'scoped') { const invalidDeps = dependencies .map((dep) => ({ meta: getInjectableOptions(dep), dep })) .filter((m) => m.meta && m.meta.lifetime === 'transient') .map((i) => i.meta && `${i.dep.name}:${i.meta.lifetime}`) if (invalidDeps.length) { throw Error( `Injector error: Scoped type '${ctor.name}' depends on transient injectables: ${invalidDeps.join(',')}`, ) } } const newInstance = new ctor() if (lifetimesToCache.includes(lifetime)) { this.setExplicitInstance(newInstance) } if (hasInitMethod(newInstance)) { withInjectorReference(newInstance, this).init(this) } return newInstance } /** * Sets explicitliy an instance for a key in the store * @param instance The created instance * @param key The class key to be persisted (optional, calls back to the instance's constructor) */ public setExplicitInstance<T extends object>(instance: T, key?: Constructable<any>) { if (this.isDisposed) { throw new InjectorAlreadyDisposedError() } const ctor = key || (instance.constructor as Constructable<T>) const { lifetime } = getInjectableOptions(ctor) if (instance.constructor === this.constructor) { throw Error('Cannot set an injector instance as injectable') } if (!lifetimesToCache.includes(lifetime)) { throw new Error(`Cannot set an instance of '${ctor.name}' as it's lifetime is set to '${lifetime}'`) } this.cachedSingletons.set(ctor, instance) } /** * Creates a child injector instance * @param options Additional injector options * @returns the created Injector */ public createChild(options?: Partial<Injector['options']>) { if (this.isDisposed) { throw new InjectorAlreadyDisposedError() } const i = new Injector() i.options = i.options || options i.options.parent = this return i } }