Initial commit
This commit is contained in:
242
api/infra/container.ts
Normal file
242
api/infra/container.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user