243 lines
6.3 KiB
TypeScript
243 lines
6.3 KiB
TypeScript
|
|
export enum ServiceLifetime {
|
||
|
|
Singleton = 'singleton',
|
||
|
|
Transient = 'transient',
|
||
|
|
Scoped = 'scoped'
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ServiceDescriptor<T = any> {
|
||
|
|
name: string
|
||
|
|
factory: () => T | Promise<T>
|
||
|
|
lifetime: ServiceLifetime
|
||
|
|
dependencies?: string[]
|
||
|
|
onDispose?: (instance: T) => void | Promise<void>
|
||
|
|
}
|
||
|
|
|
||
|
|
export type ServiceFactory<T> = () => T | Promise<T>
|
||
|
|
|
||
|
|
export class ServiceContainer {
|
||
|
|
private descriptors = new Map<string, ServiceDescriptor>()
|
||
|
|
private singletonInstances = new Map<string, unknown>()
|
||
|
|
private scopedInstances = new Map<string, Map<string, unknown>>()
|
||
|
|
private resolutionStack: string[] = []
|
||
|
|
private disposed = false
|
||
|
|
|
||
|
|
register<T>(name: string, factory: ServiceFactory<T>): void
|
||
|
|
register<T>(descriptor: ServiceDescriptor<T>): void
|
||
|
|
register<T>(nameOrDescriptor: string | ServiceDescriptor<T>, factory?: ServiceFactory<T>): void {
|
||
|
|
this.ensureNotDisposed()
|
||
|
|
|
||
|
|
if (typeof nameOrDescriptor === 'string') {
|
||
|
|
const descriptor: ServiceDescriptor<T> = {
|
||
|
|
name: nameOrDescriptor,
|
||
|
|
factory: factory!,
|
||
|
|
lifetime: ServiceLifetime.Singleton
|
||
|
|
}
|
||
|
|
this.descriptors.set(nameOrDescriptor, descriptor)
|
||
|
|
} else {
|
||
|
|
this.descriptors.set(nameOrDescriptor.name, nameOrDescriptor)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async get<T>(name: string): Promise<T> {
|
||
|
|
this.ensureNotDisposed()
|
||
|
|
return this.resolveInternal<T>(name, null)
|
||
|
|
}
|
||
|
|
|
||
|
|
getSync<T>(name: string): T {
|
||
|
|
this.ensureNotDisposed()
|
||
|
|
|
||
|
|
const descriptor = this.descriptors.get(name)
|
||
|
|
if (!descriptor) {
|
||
|
|
throw new Error(`Service '${name}' not registered`)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (descriptor.lifetime === ServiceLifetime.Singleton) {
|
||
|
|
if (this.singletonInstances.has(name)) {
|
||
|
|
return this.singletonInstances.get(name) as T
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = descriptor.factory()
|
||
|
|
if (result instanceof Promise) {
|
||
|
|
throw new Error(
|
||
|
|
`Service '${name}' has an async factory but getSync() was called. Use get() instead.`
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (descriptor.lifetime === ServiceLifetime.Singleton) {
|
||
|
|
this.singletonInstances.set(name, result)
|
||
|
|
}
|
||
|
|
|
||
|
|
return result as T
|
||
|
|
}
|
||
|
|
|
||
|
|
createScope(scopeId: string): ServiceScope {
|
||
|
|
this.ensureNotDisposed()
|
||
|
|
return new ServiceScope(this, scopeId)
|
||
|
|
}
|
||
|
|
|
||
|
|
has(name: string): boolean {
|
||
|
|
return this.descriptors.has(name)
|
||
|
|
}
|
||
|
|
|
||
|
|
async dispose(): Promise<void> {
|
||
|
|
if (this.disposed) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const [name, instance] of this.singletonInstances) {
|
||
|
|
const descriptor = this.descriptors.get(name)
|
||
|
|
if (descriptor?.onDispose) {
|
||
|
|
try {
|
||
|
|
await descriptor.onDispose(instance)
|
||
|
|
} catch (error) {
|
||
|
|
console.error(`Error disposing service '${name}':`, error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const [, scopeMap] of this.scopedInstances) {
|
||
|
|
for (const [name, instance] of scopeMap) {
|
||
|
|
const descriptor = this.descriptors.get(name)
|
||
|
|
if (descriptor?.onDispose) {
|
||
|
|
try {
|
||
|
|
await descriptor.onDispose(instance)
|
||
|
|
} catch (error) {
|
||
|
|
console.error(`Error disposing scoped service '${name}':`, error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
this.singletonInstances.clear()
|
||
|
|
this.scopedInstances.clear()
|
||
|
|
this.descriptors.clear()
|
||
|
|
this.resolutionStack = []
|
||
|
|
this.disposed = true
|
||
|
|
}
|
||
|
|
|
||
|
|
clear(): void {
|
||
|
|
this.singletonInstances.clear()
|
||
|
|
this.scopedInstances.clear()
|
||
|
|
this.descriptors.clear()
|
||
|
|
this.resolutionStack = []
|
||
|
|
}
|
||
|
|
|
||
|
|
isDisposed(): boolean {
|
||
|
|
return this.disposed
|
||
|
|
}
|
||
|
|
|
||
|
|
private async resolveInternal<T>(name: string, scopeId: string | null): Promise<T> {
|
||
|
|
if (this.resolutionStack.includes(name)) {
|
||
|
|
const cycle = [...this.resolutionStack, name].join(' -> ')
|
||
|
|
throw new Error(`Circular dependency detected: ${cycle}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
const descriptor = this.descriptors.get(name)
|
||
|
|
if (!descriptor) {
|
||
|
|
throw new Error(`Service '${name}' not registered`)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (descriptor.lifetime === ServiceLifetime.Singleton) {
|
||
|
|
if (this.singletonInstances.has(name)) {
|
||
|
|
return this.singletonInstances.get(name) as T
|
||
|
|
}
|
||
|
|
|
||
|
|
this.resolutionStack.push(name)
|
||
|
|
try {
|
||
|
|
const instance = await descriptor.factory()
|
||
|
|
this.singletonInstances.set(name, instance)
|
||
|
|
return instance as T
|
||
|
|
} finally {
|
||
|
|
this.resolutionStack.pop()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (descriptor.lifetime === ServiceLifetime.Scoped) {
|
||
|
|
if (!scopeId) {
|
||
|
|
throw new Error(
|
||
|
|
`Scoped service '${name}' cannot be resolved outside of a scope. Use createScope() first.`
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
let scopeMap = this.scopedInstances.get(scopeId)
|
||
|
|
if (!scopeMap) {
|
||
|
|
scopeMap = new Map()
|
||
|
|
this.scopedInstances.set(scopeId, scopeMap)
|
||
|
|
}
|
||
|
|
|
||
|
|
if (scopeMap.has(name)) {
|
||
|
|
return scopeMap.get(name) as T
|
||
|
|
}
|
||
|
|
|
||
|
|
this.resolutionStack.push(name)
|
||
|
|
try {
|
||
|
|
const instance = await descriptor.factory()
|
||
|
|
scopeMap.set(name, instance)
|
||
|
|
return instance as T
|
||
|
|
} finally {
|
||
|
|
this.resolutionStack.pop()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
this.resolutionStack.push(name)
|
||
|
|
try {
|
||
|
|
const instance = await descriptor.factory()
|
||
|
|
return instance as T
|
||
|
|
} finally {
|
||
|
|
this.resolutionStack.pop()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private ensureNotDisposed(): void {
|
||
|
|
if (this.disposed) {
|
||
|
|
throw new Error('ServiceContainer has been disposed')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export class ServiceScope {
|
||
|
|
private container: ServiceContainer
|
||
|
|
private scopeId: string
|
||
|
|
private disposed = false
|
||
|
|
|
||
|
|
constructor(container: ServiceContainer, scopeId: string) {
|
||
|
|
this.container = container
|
||
|
|
this.scopeId = scopeId
|
||
|
|
}
|
||
|
|
|
||
|
|
async get<T>(name: string): Promise<T> {
|
||
|
|
if (this.disposed) {
|
||
|
|
throw new Error('ServiceScope has been disposed')
|
||
|
|
}
|
||
|
|
return this.container['resolveInternal']<T>(name, this.scopeId)
|
||
|
|
}
|
||
|
|
|
||
|
|
async dispose(): Promise<void> {
|
||
|
|
if (this.disposed) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const scopeMap = this.container['scopedInstances'].get(this.scopeId)
|
||
|
|
if (scopeMap) {
|
||
|
|
for (const [name, instance] of scopeMap) {
|
||
|
|
const descriptor = this.container['descriptors'].get(name)
|
||
|
|
if (descriptor?.onDispose) {
|
||
|
|
try {
|
||
|
|
await descriptor.onDispose(instance)
|
||
|
|
} catch (error) {
|
||
|
|
console.error(`Error disposing scoped service '${name}':`, error)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
this.container['scopedInstances'].delete(this.scopeId)
|
||
|
|
}
|
||
|
|
|
||
|
|
this.disposed = true
|
||
|
|
}
|
||
|
|
|
||
|
|
isDisposed(): boolean {
|
||
|
|
return this.disposed
|
||
|
|
}
|
||
|
|
}
|