|
| 1 | +import path from 'node:path'; |
| 2 | +import fs from 'node:fs'; |
| 3 | + |
| 4 | +import type express from 'express'; |
| 5 | + |
| 6 | +import type { AppServices } from './app'; |
| 7 | +import type { BackendRouteRegistration, ModuleManifest } from './loader'; |
| 8 | + |
| 9 | +// ── ModuleRecord ────────────────────────────────────────────── |
| 10 | + |
| 11 | +export interface ModuleRecord { |
| 12 | + /** module.json의 name (또는 디렉터리 이름 폴백) */ |
| 13 | + name: string; |
| 14 | + /** Express 라우터가 마운트될 경로 (예: /api/ledger) */ |
| 15 | + basePath: string; |
| 16 | + /** module.json 전체 manifest */ |
| 17 | + manifest: ModuleManifest; |
| 18 | + router: express.Router; |
| 19 | +} |
| 20 | + |
| 21 | +// ── ModuleRegistry 싱글턴 ───────────────────────────────────── |
| 22 | +// |
| 23 | +// Express의 app.use()로 고정 마운트한 라우터는 런타임에 제거할 수 없다. |
| 24 | +// 대신 단일 디스패처 미들웨어를 app.use()에 한 번만 등록하고, |
| 25 | +// 실제 라우팅은 매 요청마다 레지스트리 Map을 참조해 처리한다. |
| 26 | +// |
| 27 | +// ┌─────────────────────────────────────────────────────┐ |
| 28 | +// │ app.use(registry.dispatcher()) ← 한 번만 등록 │ |
| 29 | +// │ │ |
| 30 | +// │ req → dispatcher → Map.get(basePath) → router │ |
| 31 | +// └─────────────────────────────────────────────────────┘ |
| 32 | +// |
| 33 | +// register() / unregister() 로 Map을 갱신하면 서버 재시작 없이 즉시 반영된다. |
| 34 | + |
| 35 | +export class ModuleRegistry { |
| 36 | + private static _instance: ModuleRegistry | null = null; |
| 37 | + |
| 38 | + /** basePath → ModuleRecord */ |
| 39 | + private readonly modules = new Map<string, ModuleRecord>(); |
| 40 | + |
| 41 | + private constructor() {} |
| 42 | + |
| 43 | + public static getInstance(): ModuleRegistry { |
| 44 | + if (!ModuleRegistry._instance) { |
| 45 | + ModuleRegistry._instance = new ModuleRegistry(); |
| 46 | + } |
| 47 | + return ModuleRegistry._instance; |
| 48 | + } |
| 49 | + |
| 50 | + // ── 등록 / 해제 ──────────────────────────────────────────── |
| 51 | + |
| 52 | + public register(record: ModuleRecord): void { |
| 53 | + this.modules.set(record.basePath, record); |
| 54 | + console.log(`[fieldstack][registry] registered module "${record.name}" at ${record.basePath}`); |
| 55 | + } |
| 56 | + |
| 57 | + public unregister(basePath: string): boolean { |
| 58 | + const record = this.modules.get(basePath); |
| 59 | + if (!record) return false; |
| 60 | + this.modules.delete(basePath); |
| 61 | + console.log(`[fieldstack][registry] unregistered module "${record.name}" (${basePath})`); |
| 62 | + return true; |
| 63 | + } |
| 64 | + |
| 65 | + /** 현재 등록된 모듈 목록 (읽기 전용 복사본) */ |
| 66 | + public list(): ModuleRecord[] { |
| 67 | + return Array.from(this.modules.values()); |
| 68 | + } |
| 69 | + |
| 70 | + // ── 디스패처 미들웨어 ──────────────────────────────────────── |
| 71 | + // |
| 72 | + // 매 요청마다 등록된 basePath를 순회하여 일치하는 라우터로 위임한다. |
| 73 | + // basePath가 req.path의 접두사인 경우에만 해당 라우터를 호출한다. |
| 74 | + |
| 75 | + public dispatcher(): express.RequestHandler { |
| 76 | + return (req, res, next) => { |
| 77 | + for (const record of this.modules.values()) { |
| 78 | + const base = record.basePath.endsWith('/') |
| 79 | + ? record.basePath |
| 80 | + : record.basePath + '/'; |
| 81 | + |
| 82 | + if (req.path === record.basePath || req.path.startsWith(base)) { |
| 83 | + // sub-path를 라우터에 전달하기 위해 url 재작성 |
| 84 | + const originalUrl = req.url; |
| 85 | + const originalPath = req.path; |
| 86 | + |
| 87 | + req.url = req.url.slice(record.basePath.length) || '/'; |
| 88 | + (req as express.Request & { path: string }).path = originalPath.slice(record.basePath.length) || '/'; |
| 89 | + |
| 90 | + record.router(req, res, (err?: unknown) => { |
| 91 | + // 라우터가 처리하지 못하면 url 복원 후 다음 미들웨어로 |
| 92 | + req.url = originalUrl; |
| 93 | + (req as express.Request & { path: string }).path = originalPath; |
| 94 | + next(err); |
| 95 | + }); |
| 96 | + return; |
| 97 | + } |
| 98 | + } |
| 99 | + next(); |
| 100 | + }; |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +// ── 모듈 로더 (디스크 → 레지스트리 등록) ───────────────────── |
| 105 | + |
| 106 | +interface ModuleRouterModule { |
| 107 | + default?: express.Router; |
| 108 | + createRouter?: (services: AppServices) => express.Router; |
| 109 | +} |
| 110 | + |
| 111 | +/** |
| 112 | + * registrations 목록을 순회해 각 모듈의 backend/index.{ts,js}를 로드하고 |
| 113 | + * ModuleRegistry에 등록한다. 기존 mountModuleRouters()를 대체한다. |
| 114 | + */ |
| 115 | +export async function loadModulesIntoRegistry( |
| 116 | + registrations: BackendRouteRegistration[], |
| 117 | + manifests: ModuleManifest[], |
| 118 | + modulesDir: string, |
| 119 | + services: AppServices, |
| 120 | +): Promise<void> { |
| 121 | + const registry = ModuleRegistry.getInstance(); |
| 122 | + const manifestMap = new Map(manifests.map((m) => [m.name, m])); |
| 123 | + |
| 124 | + if (registrations.length === 0) { |
| 125 | + console.log('[fieldstack][registry] no enabled modules found'); |
| 126 | + return; |
| 127 | + } |
| 128 | + |
| 129 | + for (const reg of registrations) { |
| 130 | + if (!reg.apiBasePath) { |
| 131 | + console.warn(`[fieldstack][registry] module "${reg.moduleName}" has no apiBasePath, skipping`); |
| 132 | + continue; |
| 133 | + } |
| 134 | + |
| 135 | + // 라우터 파일 탐색: backend/index.ts (dev) → backend/index.js (prod) |
| 136 | + const baseDir = path.join(modulesDir, reg.moduleName, 'backend'); |
| 137 | + const candidatePaths = [ |
| 138 | + path.join(baseDir, 'index.ts'), |
| 139 | + path.join(baseDir, 'index.js'), |
| 140 | + ]; |
| 141 | + |
| 142 | + const routerFile = candidatePaths.find((p) => fs.existsSync(p)); |
| 143 | + if (!routerFile) { |
| 144 | + console.warn( |
| 145 | + `[fieldstack][registry] module "${reg.moduleName}" has no backend router at ${baseDir}/index.{ts,js}`, |
| 146 | + ); |
| 147 | + continue; |
| 148 | + } |
| 149 | + |
| 150 | + try { |
| 151 | + const mod = (await import(routerFile)) as ModuleRouterModule; |
| 152 | + let router: express.Router | undefined; |
| 153 | + |
| 154 | + if (typeof mod.createRouter === 'function') { |
| 155 | + router = mod.createRouter(services); |
| 156 | + } else if (mod.default) { |
| 157 | + router = mod.default; |
| 158 | + } |
| 159 | + |
| 160 | + if (!router) { |
| 161 | + console.warn( |
| 162 | + `[fieldstack][registry] module "${reg.moduleName}" has no default export or createRouter`, |
| 163 | + ); |
| 164 | + continue; |
| 165 | + } |
| 166 | + |
| 167 | + const manifest = manifestMap.get(reg.moduleName); |
| 168 | + if (!manifest) continue; // scanBackendModules를 거친 manifests에 반드시 존재해야 함 |
| 169 | + |
| 170 | + registry.register({ name: reg.moduleName, basePath: reg.apiBasePath, manifest, router }); |
| 171 | + } catch (err) { |
| 172 | + console.error(`[fieldstack][registry] failed to load module "${reg.moduleName}":`, err); |
| 173 | + } |
| 174 | + } |
| 175 | +} |
| 176 | + |
| 177 | +/** |
| 178 | + * 모듈 디렉터리를 재스캔하여 레지스트리를 갱신한다. |
| 179 | + * - 새로 추가된 모듈: 등록 |
| 180 | + * - 삭제/비활성화된 모듈: 해제 |
| 181 | + * - 이미 등록된 모듈: 변경 없음 (라우터 핫리로드 미지원) |
| 182 | + */ |
| 183 | +export async function reloadModules( |
| 184 | + modulesDir: string, |
| 185 | + services: AppServices, |
| 186 | +): Promise<{ added: string[]; removed: string[] }> { |
| 187 | + const { loadModulesFromDisk, scanBackendModules, buildBackendRouteRegistrations } = |
| 188 | + await import('./loader/index.js'); |
| 189 | + |
| 190 | + const entries = await loadModulesFromDisk(modulesDir); |
| 191 | + const manifests = await scanBackendModules(entries); |
| 192 | + const registrations = buildBackendRouteRegistrations(manifests); |
| 193 | + |
| 194 | + const registry = ModuleRegistry.getInstance(); |
| 195 | + const currentPaths = new Set(registry.list().map((r: ModuleRecord) => r.basePath)); |
| 196 | + const newPaths = new Set( |
| 197 | + registrations.map((r: BackendRouteRegistration) => r.apiBasePath).filter(Boolean), |
| 198 | + ); |
| 199 | + |
| 200 | + // 제거된 모듈 해제 |
| 201 | + const removed: string[] = []; |
| 202 | + for (const existing of registry.list()) { |
| 203 | + if (!newPaths.has(existing.basePath)) { |
| 204 | + registry.unregister(existing.basePath); |
| 205 | + removed.push(existing.name); |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + // 새 모듈 등록 |
| 210 | + const toAdd = registrations.filter( |
| 211 | + (r: BackendRouteRegistration) => r.apiBasePath && !currentPaths.has(r.apiBasePath), |
| 212 | + ); |
| 213 | + await loadModulesIntoRegistry(toAdd, manifests, modulesDir, services); |
| 214 | + const added = toAdd.map((r: BackendRouteRegistration) => r.moduleName); |
| 215 | + |
| 216 | + return { added, removed }; |
| 217 | +} |
0 commit comments