Skip to content

Commit 5453463

Browse files
SOIVclaude
andcommitted
feat: Phase 2 pre 모듈 레지스트리 시스템 구현
P2-Pre.1 ModuleRegistry 싱글턴: - module-registry.ts: ModuleRegistry 클래스 — register/unregister + 동적 디스패처 미들웨어. 매 요청마다 Map을 참조하는 방식으로 서버 재시작 없이 모듈 추가/제거 반영. - loadModulesIntoRegistry(): 기존 mountModuleRouters() 대체 — 스캔 결과를 레지스트리에 등록. - reloadModules(): 디스크 재스캔 후 추가/제거 모듈 자동 동기화. - app.ts: ModuleRegistry.dispatcher()를 app.use()에 단일 등록. mountModuleRouters() 제거. P2-Pre.2 모듈 관리 API (routes/core.ts 신규): - GET /core/modules — 레지스트리 모듈 목록 (어드민 전용) - POST /core/modules/reload — 디스크 재스캔 + 레지스트리 갱신 (어드민 전용) - GET /core/modules/me — 현재 유저의 모듈 활성화 목록 - PATCH /core/modules/:name/toggle — 유저별 활성화/비활성화 전환 - POST /core/modules/:name/install — 모듈 설치 (레지스트리 등록 + user_modules 레코드 생성) P2-Pre.3 Admin UI 연동: - AdminView: 모듈 관리 패널 구현 — GET /core/modules 로 목록 표시 + 새로고침 버튼. bypass 모드에서는 API 비활성 안내. installMode prop 추가. P2-Pre.4 user_modules 시스템: - 004_add_user_modules.sql: user_modules 테이블 마이그레이션. - AppShell: 로그인 후 GET /core/modules/me 로 사이드바 모듈 메뉴 동적 구성. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d9509b5 commit 5453463

9 files changed

Lines changed: 623 additions & 95 deletions

File tree

apps/api/src/app.ts

Lines changed: 8 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ import type { SharedLinkRenderer } from '@fieldstack/core' with { "resolution-mo
1919

2020
import { validateEnv } from './config/env';
2121
import { errorHandler } from './middleware/error';
22-
import type { BackendRouteRegistration } from './loader';
22+
import { ModuleRegistry } from './module-registry';
2323
import { createAdminRouter } from './routes/admin';
2424
import { createAuthRouter } from './routes/auth';
25+
import { createCoreRouter } from './routes/core';
2526
import { healthRouter } from './routes/health';
2627
import { createPublicRouter } from './routes/public';
2728
import { createSetupRouter } from './routes/setup';
@@ -39,14 +40,6 @@ export interface AppServices {
3940
settings: SystemSettingsService;
4041
}
4142

42-
// 모듈 라우터 규약:
43-
// default export → express.Router (서비스 불필요)
44-
// createRouter → (services: AppServices) => express.Router (서비스 주입)
45-
interface ModuleRouterModule {
46-
default?: express.Router;
47-
createRouter?: (services: AppServices) => express.Router;
48-
}
49-
5043
export function createApp(services?: AppServices) {
5144
const app = express();
5245

@@ -69,7 +62,10 @@ export function createApp(services?: AppServices) {
6962
if (services) {
7063
app.use('/auth', createAuthRouter(services));
7164
app.use('/core/share', createShareRouter(services));
65+
app.use('/core', createCoreRouter(services));
7266
app.use('/admin', createAdminRouter(services));
67+
// 모듈 라우터 디스패처 — ModuleRegistry에 등록된 모듈을 동적으로 서빙
68+
app.use(ModuleRegistry.getInstance().dispatcher());
7369
}
7470

7571
return app;
@@ -99,8 +95,11 @@ export function createAppWithPublicRouter(
9995

10096
app.use('/auth', createAuthRouter(services));
10197
app.use('/core/share', createShareRouter(services));
98+
app.use('/core', createCoreRouter(services));
10299
app.use('/admin', createAdminRouter(services));
103100
app.use('/s', createPublicRouter(services.sharedLink, getRenderer));
101+
// 모듈 라우터 디스패처 — ModuleRegistry에 등록된 모듈을 동적으로 서빙
102+
app.use(ModuleRegistry.getInstance().dispatcher());
104103

105104
return app;
106105
}
@@ -132,66 +131,6 @@ export function createSetupApp(): express.Application {
132131
return app;
133132
}
134133

135-
// ── 모듈 라우터 마운트 ────────────────────────────────────────
136-
137-
export async function mountModuleRouters(
138-
app: express.Application,
139-
registrations: BackendRouteRegistration[],
140-
modulesDir: string,
141-
services: AppServices,
142-
): Promise<void> {
143-
if (registrations.length === 0) {
144-
console.log('[fieldstack][loader] no enabled modules found');
145-
return;
146-
}
147-
148-
for (const reg of registrations) {
149-
if (!reg.apiBasePath) {
150-
console.warn(`[fieldstack][loader] module "${reg.moduleName}" has no apiBasePath, skipping`);
151-
continue;
152-
}
153-
154-
// 라우터 파일 탐색: backend/index.ts (dev) → backend/index.js (prod)
155-
const baseDir = path.join(modulesDir, reg.moduleName, 'backend');
156-
const candidatePaths = [
157-
path.join(baseDir, 'index.ts'),
158-
path.join(baseDir, 'index.js'),
159-
];
160-
161-
const routerFile = candidatePaths.find((p) => fs.existsSync(p));
162-
if (!routerFile) {
163-
console.warn(
164-
`[fieldstack][loader] module "${reg.moduleName}" has no backend router at ${baseDir}/index.{ts,js}`,
165-
);
166-
continue;
167-
}
168-
169-
try {
170-
const mod = (await import(routerFile)) as ModuleRouterModule;
171-
let router: express.Router | undefined;
172-
173-
if (typeof mod.createRouter === 'function') {
174-
router = mod.createRouter(services);
175-
} else if (mod.default) {
176-
router = mod.default;
177-
}
178-
179-
if (!router) {
180-
console.warn(
181-
`[fieldstack][loader] module "${reg.moduleName}" router file has no default export or createRouter`,
182-
);
183-
continue;
184-
}
185-
186-
app.use(reg.apiBasePath, router);
187-
console.log(
188-
`[fieldstack][loader] mounted module "${reg.moduleName}" at ${reg.apiBasePath}`,
189-
);
190-
} catch (err) {
191-
console.error(`[fieldstack][loader] failed to load module "${reg.moduleName}":`, err);
192-
}
193-
}
194-
}
195134

196135
// ── Error handler 마운트 (반드시 모든 라우트 등록 후 마지막에 호출) ──
197136

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- ── 004_add_user_modules.sql ────────────────────────────────────
2+
-- 유저별 모듈 활성화 설정 테이블.
3+
--
4+
-- 서버 레지스트리(ModuleRegistry, 전역)와 유저 설정(개인)을 분리한다:
5+
-- - 모듈 설치: 일반 유저도 가능 (마켓플레이스 → 서버 레지스트리 등록)
6+
-- - 모듈 제거: 관리자 전용 (레지스트리 해제 + 전체 유저 레코드 삭제)
7+
-- - 모듈 활성화/비활성화: 각 유저 본인
8+
9+
CREATE TABLE IF NOT EXISTS user_modules (
10+
id {{UUID_PRIMARY_KEY}},
11+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
12+
module_name TEXT NOT NULL,
13+
enabled BOOLEAN NOT NULL DEFAULT {{BOOLEAN_TRUE}},
14+
installed_at TEXT NOT NULL DEFAULT ({{NOW}}),
15+
UNIQUE (user_id, module_name)
16+
);

apps/api/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
finalizeApp,
1212
initDb,
1313
initServices,
14-
mountModuleRouters,
1514
runMigrations,
1615
} from './app';
1716
import {
@@ -20,6 +19,7 @@ import {
2019
scanBackendModules,
2120
validateModuleDependencies,
2221
} from './loader';
22+
import { loadModulesIntoRegistry } from './module-registry';
2323
import { applyConfigToEnv, isInstalled } from './setup/mode';
2424

2525
// ── fieldstack.config.json → process.env 반영 (env vars 우선) ─
@@ -140,7 +140,7 @@ async function startApp() {
140140
}
141141

142142
const registrations = buildBackendRouteRegistrations(manifests);
143-
await mountModuleRouters(app, registrations, MODULES_DIR, services);
143+
await loadModulesIntoRegistry(registrations, manifests, MODULES_DIR, services);
144144
}
145145

146146
// ── Error handler (반드시 모든 라우트 등록 후 마지막) ────────

apps/api/src/module-registry.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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

Comments
 (0)