@furystack/inject
Version:
Core FuryStack package
207 lines (170 loc) • 6.43 kB
text/typescript
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`,
)
}
}
({ 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
}
}