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
|
||||
}
|
||||
}
|
||||
41
api/infra/createModule.ts
Normal file
41
api/infra/createModule.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Router } from 'express'
|
||||
import type { ServiceContainer } from './container.js'
|
||||
import type { ApiModule, ModuleMetadata, ModuleLifecycle } from './types.js'
|
||||
import type { ApiModuleConfig, ModuleEndpoints } from '../../shared/modules/types.js'
|
||||
|
||||
export interface CreateModuleOptions<TEndpoints extends ModuleEndpoints> {
|
||||
routes: (container: ServiceContainer) => Router | Promise<Router>
|
||||
lifecycle?: ModuleLifecycle
|
||||
services?: (container: ServiceContainer) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function createApiModule<
|
||||
TId extends string,
|
||||
TEndpoints extends ModuleEndpoints
|
||||
>(
|
||||
config: ApiModuleConfig<TId, TEndpoints>,
|
||||
options: CreateModuleOptions<TEndpoints>
|
||||
): ApiModule {
|
||||
const metadata: ModuleMetadata = {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
basePath: config.basePath,
|
||||
order: config.order,
|
||||
dependencies: config.dependencies,
|
||||
}
|
||||
|
||||
const lifecycle: ModuleLifecycle | undefined = options.lifecycle
|
||||
? options.lifecycle
|
||||
: options.services
|
||||
? {
|
||||
onLoad: options.services,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return {
|
||||
metadata,
|
||||
lifecycle,
|
||||
createRouter: options.routes,
|
||||
}
|
||||
}
|
||||
5
api/infra/index.ts
Normal file
5
api/infra/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { ServiceContainer } from './container.js'
|
||||
export type { ServiceFactory } from './container.js'
|
||||
export { loadModules } from './moduleLoader.js'
|
||||
export { ModuleManager } from './moduleManager.js'
|
||||
export type { ApiModule, ModuleMetadata, ModuleLifecycle } from './types.js'
|
||||
24
api/infra/moduleLoader.ts
Normal file
24
api/infra/moduleLoader.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Application } from 'express'
|
||||
import type { ServiceContainer } from './container.js'
|
||||
import type { ApiModule } from './types.js'
|
||||
import { ModuleManager } from './moduleManager.js'
|
||||
|
||||
export async function loadModules(
|
||||
app: Application,
|
||||
container: ServiceContainer,
|
||||
modules: ApiModule[]
|
||||
): Promise<ModuleManager> {
|
||||
const manager = new ModuleManager(container)
|
||||
|
||||
for (const module of modules) {
|
||||
await manager.register(module)
|
||||
}
|
||||
|
||||
for (const module of manager.getAllModules()) {
|
||||
await manager.activate(module.metadata.id)
|
||||
const router = await module.createRouter(container)
|
||||
app.use('/api' + module.metadata.basePath, router)
|
||||
}
|
||||
|
||||
return manager
|
||||
}
|
||||
76
api/infra/moduleManager.ts
Normal file
76
api/infra/moduleManager.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ApiModule, ModuleMetadata } from './types.js'
|
||||
import { ServiceContainer } from './container.js'
|
||||
|
||||
export class ModuleManager {
|
||||
private modules = new Map<string, ApiModule>()
|
||||
private activeModules = new Set<string>()
|
||||
private container: ServiceContainer
|
||||
|
||||
constructor(container: ServiceContainer) {
|
||||
this.container = container
|
||||
}
|
||||
|
||||
async register(module: ApiModule): Promise<void> {
|
||||
const { id, dependencies = [] } = module.metadata
|
||||
|
||||
for (const dep of dependencies) {
|
||||
if (!this.modules.has(dep)) {
|
||||
throw new Error(`Module '${id}' depends on '${dep}' which is not registered`)
|
||||
}
|
||||
}
|
||||
|
||||
this.modules.set(id, module)
|
||||
|
||||
if (module.lifecycle?.onLoad) {
|
||||
await module.lifecycle.onLoad(this.container)
|
||||
}
|
||||
}
|
||||
|
||||
async activate(id: string): Promise<void> {
|
||||
const module = this.modules.get(id)
|
||||
if (!module) {
|
||||
throw new Error(`Module '${id}' not found`)
|
||||
}
|
||||
|
||||
if (this.activeModules.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const { dependencies = [] } = module.metadata
|
||||
for (const dep of dependencies) {
|
||||
await this.activate(dep)
|
||||
}
|
||||
|
||||
if (module.lifecycle?.onActivate) {
|
||||
await module.lifecycle.onActivate(this.container)
|
||||
}
|
||||
|
||||
this.activeModules.add(id)
|
||||
}
|
||||
|
||||
async deactivate(id: string): Promise<void> {
|
||||
const module = this.modules.get(id)
|
||||
if (!module) return
|
||||
|
||||
if (!this.activeModules.has(id)) return
|
||||
|
||||
if (module.lifecycle?.onDeactivate) {
|
||||
await module.lifecycle.onDeactivate(this.container)
|
||||
}
|
||||
|
||||
this.activeModules.delete(id)
|
||||
}
|
||||
|
||||
getModule(id: string): ApiModule | undefined {
|
||||
return this.modules.get(id)
|
||||
}
|
||||
|
||||
getAllModules(): ApiModule[] {
|
||||
return Array.from(this.modules.values())
|
||||
.sort((a, b) => (a.metadata.order || 0) - (b.metadata.order || 0))
|
||||
}
|
||||
|
||||
getActiveModules(): string[] {
|
||||
return Array.from(this.activeModules)
|
||||
}
|
||||
}
|
||||
113
api/infra/moduleValidator.ts
Normal file
113
api/infra/moduleValidator.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { readdirSync, statSync, existsSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import type { ApiModule } from './types.js'
|
||||
import type { ModuleDefinition } from '../../shared/modules/types.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
function getSharedModulesPath(): string | null {
|
||||
const possiblePaths = [
|
||||
join(__dirname, '../../shared/modules'),
|
||||
join(__dirname, '../../../shared/modules'),
|
||||
join((process as unknown as { resourcesPath?: string })?.resourcesPath || '', 'shared/modules'),
|
||||
]
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p)) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface SharedModuleDefinition {
|
||||
id: string
|
||||
name: string
|
||||
backend?: {
|
||||
enabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function needsBackendImplementation(moduleDef: SharedModuleDefinition): boolean {
|
||||
return moduleDef.backend?.enabled !== false
|
||||
}
|
||||
|
||||
async function loadModuleDefinitions(): Promise<SharedModuleDefinition[]> {
|
||||
const modules: SharedModuleDefinition[] = []
|
||||
const sharedModulesPath = getSharedModulesPath()
|
||||
|
||||
if (!sharedModulesPath) {
|
||||
return modules
|
||||
}
|
||||
|
||||
const entries = readdirSync(sharedModulesPath)
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(sharedModulesPath, entry)
|
||||
const stat = statSync(entryPath)
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const moduleExports = await import(`../../shared/modules/${entry}/index.js`)
|
||||
|
||||
for (const key of Object.keys(moduleExports)) {
|
||||
if (key.endsWith('_MODULE')) {
|
||||
const moduleDef = moduleExports[key] as ModuleDefinition
|
||||
if (moduleDef && moduleDef.id) {
|
||||
modules.push({
|
||||
id: moduleDef.id,
|
||||
name: moduleDef.name,
|
||||
backend: moduleDef.backend,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 模块加载失败,跳过
|
||||
}
|
||||
}
|
||||
|
||||
return modules
|
||||
}
|
||||
|
||||
export async function validateModuleConsistency(apiModules: ApiModule[]): Promise<void> {
|
||||
const sharedModules = await loadModuleDefinitions()
|
||||
|
||||
if (sharedModules.length === 0) {
|
||||
console.log('[ModuleValidator] Skipping validation (shared modules not found, likely packaged mode)')
|
||||
return
|
||||
}
|
||||
|
||||
const apiModuleIds = new Set(apiModules.map((m) => m.metadata.id))
|
||||
const errors: string[] = []
|
||||
|
||||
for (const sharedModule of sharedModules) {
|
||||
const needsBackend = needsBackendImplementation(sharedModule)
|
||||
const hasApiModule = apiModuleIds.has(sharedModule.id)
|
||||
|
||||
if (needsBackend && !hasApiModule) {
|
||||
errors.push(
|
||||
`Module '${sharedModule.id}' is defined in shared but not registered in API modules`
|
||||
)
|
||||
}
|
||||
|
||||
if (!needsBackend && hasApiModule) {
|
||||
errors.push(
|
||||
`Module '${sharedModule.id}' has backend disabled but is registered in API modules`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Module consistency validation failed:\n - ${errors.join('\n - ')}`)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[ModuleValidator] ✓ Module consistency validated: ${sharedModules.length} shared, ${apiModules.length} API`
|
||||
)
|
||||
}
|
||||
31
api/infra/types.ts
Normal file
31
api/infra/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Router, Application } from 'express'
|
||||
import type { ServiceContainer } from './container.js'
|
||||
|
||||
export interface ModuleMetadata {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
basePath: string
|
||||
dependencies?: string[]
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface ModuleLifecycle {
|
||||
onLoad?(container: ServiceContainer): void | Promise<void>
|
||||
onUnload?(container: ServiceContainer): void | Promise<void>
|
||||
onActivate?(container: ServiceContainer): void | Promise<void>
|
||||
onDeactivate?(container: ServiceContainer): void | Promise<void>
|
||||
}
|
||||
|
||||
export interface ApiModule {
|
||||
metadata: ModuleMetadata
|
||||
lifecycle?: ModuleLifecycle
|
||||
createRouter: (container: ServiceContainer) => Router | Promise<Router>
|
||||
}
|
||||
|
||||
export interface LegacyApiModule {
|
||||
name: string
|
||||
basePath: string
|
||||
init?: (app: Application, container: ServiceContainer) => void | Promise<void>
|
||||
createRouter: (container: ServiceContainer) => Router | Promise<Router>
|
||||
}
|
||||
Reference in New Issue
Block a user