Files
XCDesktop/api/infra/container.ts

243 lines
6.3 KiB
TypeScript
Raw Normal View History

2026-03-08 01:34:54 +08:00
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
}
}