@fuzionx/framework 0.1.43 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +501 -501
  2. package/bin/fx.js +12 -12
  3. package/cli/db-sync.js +100 -100
  4. package/cli/index.js +494 -494
  5. package/cli/templates/make/app/controllers/HomeController.js +14 -14
  6. package/cli/templates/make/app/routes/api.js +7 -7
  7. package/cli/templates/make/app/routes/web.js +5 -5
  8. package/cli/templates/make/app/views/default/errors/404.html +11 -11
  9. package/cli/templates/make/app/views/default/errors/500.html +14 -14
  10. package/cli/templates/make/app/views/default/layouts/main.html +22 -22
  11. package/cli/templates/make/app/views/default/pages/home.html +11 -11
  12. package/cli/templates/make/controller.js.tpl +40 -40
  13. package/cli/templates/make/event.js.tpl +8 -8
  14. package/cli/templates/make/job.js.tpl +10 -10
  15. package/cli/templates/make/middleware.js.tpl +10 -10
  16. package/cli/templates/make/model.js.tpl +15 -15
  17. package/cli/templates/make/service.js.tpl +15 -15
  18. package/cli/templates/make/task.js.tpl +15 -15
  19. package/cli/templates/make/test.js.tpl +7 -7
  20. package/cli/templates/make/worker.js.tpl +14 -14
  21. package/cli/templates/make/ws.js.tpl +18 -18
  22. package/index.js +67 -67
  23. package/lib/core/AppError.js +46 -46
  24. package/lib/core/Application.js +1006 -1006
  25. package/lib/core/AutoLoader.js +227 -227
  26. package/lib/core/Base.js +64 -64
  27. package/lib/core/Config.js +331 -331
  28. package/lib/core/Context.js +484 -484
  29. package/lib/database/ConnectionManager.js +208 -208
  30. package/lib/database/MariaModel.js +29 -29
  31. package/lib/database/Model.js +247 -247
  32. package/lib/database/ModelRegistry.js +72 -72
  33. package/lib/database/MongoModel.js +232 -232
  34. package/lib/database/Pagination.js +37 -37
  35. package/lib/database/PostgreModel.js +29 -29
  36. package/lib/database/QueryBuilder.js +172 -172
  37. package/lib/database/SQLiteModel.js +27 -27
  38. package/lib/database/SqlModel.js +257 -257
  39. package/lib/database/SqlQueryBuilder.js +332 -332
  40. package/lib/helpers/CryptoHelper.js +48 -48
  41. package/lib/helpers/FileHelper.js +61 -61
  42. package/lib/helpers/HashHelper.js +39 -39
  43. package/lib/helpers/I18nHelper.js +174 -174
  44. package/lib/helpers/Logger.js +108 -108
  45. package/lib/helpers/MediaHelper.js +84 -84
  46. package/lib/http/Controller.js +34 -34
  47. package/lib/http/ErrorHandler.js +136 -136
  48. package/lib/http/Middleware.js +43 -43
  49. package/lib/http/Router.js +109 -109
  50. package/lib/http/Validation.js +125 -125
  51. package/lib/middleware/apiAuth.js +79 -79
  52. package/lib/middleware/auth.js +42 -42
  53. package/lib/middleware/bodyParser.js +19 -19
  54. package/lib/middleware/cors.js +47 -47
  55. package/lib/middleware/csrf.js +32 -32
  56. package/lib/middleware/index.js +13 -13
  57. package/lib/middleware/session.js +27 -27
  58. package/lib/middleware/theme.js +20 -20
  59. package/lib/realtime/RoomManager.js +85 -85
  60. package/lib/realtime/WsHandler.js +107 -107
  61. package/lib/schedule/Job.js +38 -38
  62. package/lib/schedule/Queue.js +103 -103
  63. package/lib/schedule/Scheduler.js +171 -171
  64. package/lib/schedule/Task.js +39 -39
  65. package/lib/schedule/WorkerPool.js +225 -225
  66. package/lib/services/EventBus.js +94 -94
  67. package/lib/services/Service.js +261 -261
  68. package/lib/services/Storage.js +112 -112
  69. package/lib/utilities/ArrUtil.js +112 -112
  70. package/lib/utilities/DateUtil.js +98 -98
  71. package/lib/utilities/FunctionUtil.js +119 -119
  72. package/lib/utilities/NumUtil.js +75 -75
  73. package/lib/utilities/ObjectUtil.js +170 -170
  74. package/lib/utilities/PaginationUtil.js +81 -81
  75. package/lib/utilities/StrUtil.js +105 -105
  76. package/lib/utilities/index.js +18 -18
  77. package/lib/view/OpenAPI.js +231 -231
  78. package/lib/view/View.js +83 -83
  79. package/package.json +2 -2
  80. package/testing/index.js +232 -232
@@ -1,1006 +1,1006 @@
1
- /**
2
- * Application — 서비스 컨테이너 + 부트스트랩 오케스트레이터 + Bridge 어댑터
3
- *
4
- * @fuzionx/core의 FuzionXApp을 내부적으로 사용하여 Bridge 서버 시작.
5
- * rawReq → Context 변환 → 미들웨어 체인 → 컨트롤러 → toResponse().
6
- *
7
- * @see docs/framework/00-overview.md
8
- * @see docs/framework/04-bootstrap-lifecycle.md
9
- */
10
- import path from 'node:path';
11
- import { promises as fs } from 'node:fs';
12
- import cluster from 'node:cluster';
13
- import Config from './Config.js';
14
- import Context from './Context.js';
15
- import Router from '../http/Router.js';
16
- import ModelRegistry from '../database/ModelRegistry.js';
17
- import ErrorHandler from '../http/ErrorHandler.js';
18
- import AutoLoader from './AutoLoader.js';
19
- import Logger from '../helpers/Logger.js';
20
- import I18nHelper from '../helpers/I18nHelper.js';
21
- import CryptoHelper from '../helpers/CryptoHelper.js';
22
- import HashHelper from '../helpers/HashHelper.js';
23
- import MediaHelper from '../helpers/MediaHelper.js';
24
- import FileHelper from '../helpers/FileHelper.js';
25
- import View from '../view/View.js';
26
- import OpenAPI from '../view/OpenAPI.js';
27
- import Scheduler from '../schedule/Scheduler.js';
28
- import Queue from '../schedule/Queue.js';
29
- import Storage from '../services/Storage.js';
30
- import ConnectionManager from '../database/ConnectionManager.js';
31
- import SqlModel from '../database/SqlModel.js';
32
- import MongoModel from '../database/MongoModel.js';
33
- import WorkerPool from '../schedule/WorkerPool.js';
34
-
35
- // Bridge: lazy-load (테스트 환경에서 native 바인딩 없을 수 있음)
36
- let FuzionXApp;
37
- try {
38
- const core = await import('@fuzionx/core');
39
- FuzionXApp = core.FuzionXApp || core.RuxyApp;
40
- } catch (e) {
41
- console.error('[fuzionx-framework] ⚠️ @fuzionx/core 로드 실패 — Bridge 없이 테스트 모드로 실행됩니다:',
42
- e.message);
43
- }
44
-
45
- export default class Application {
46
- /**
47
- * @param {object} [opts]
48
- * @param {object} [opts.config] - 이미 파싱된 설정 객체 또는 Config 인스턴스
49
- * @param {string} [opts.configPath] - YAML 설정 파일 경로
50
- * @param {string} [opts.baseDir='.'] - 프로젝트 루트 경로
51
- * @param {object} [opts.bridge] - @fuzionx/core bridge 인스턴스 (선택)
52
- */
53
- constructor(opts = {}) {
54
- // Config
55
- if (opts.config instanceof Config) {
56
- this.config = opts.config;
57
- } else {
58
- this.config = new Config(opts.config || {});
59
- }
60
-
61
- // 프로젝트 루트 (반드시 절대경로 — ESM import 호환)
62
- this.baseDir = path.resolve(opts.baseDir || process.cwd());
63
- this.configPath = opts.configPath || null;
64
-
65
- // configPath 지정 시 YAML 파일에서 database/app/themes 등 로드
66
- if (this.configPath) {
67
- this.config.loadYaml(path.resolve(this.baseDir, this.configPath));
68
- }
69
-
70
- // Bridge 참조 (listen() 시 FuzionXApp 생성)
71
- this._bridge = null;
72
- this._coreApp = null;
73
-
74
- // DI 컨테이너
75
- this._services = new Map();
76
- this._factories = new Map();
77
-
78
- // 이벤트 핸들러
79
- this._eventHandlers = new Map();
80
- this._errorHandlers = [];
81
-
82
- // 멀티앱 레지스트리 (도메인→앱 라우팅)
83
- this._appRegistry = new Map();
84
-
85
- // 글로벌 미들웨어
86
- this._globalMiddleware = [];
87
-
88
- // 프레임워크 컴포넌트
89
- this.db = new ModelRegistry();
90
- this.logger = new Logger({
91
- level: this.config.get('bridge.logging.level', 'info'),
92
- bridge: this._bridge,
93
- json: this.config.get('bridge.logging.json', false),
94
- });
95
- this.crypto = new CryptoHelper(this._bridge);
96
- this.hash = new HashHelper(this._bridge);
97
- this.media = new MediaHelper(this._bridge);
98
- this.file = new FileHelper(this._bridge);
99
- this.ws = null;
100
- this.i18n = new I18nHelper({
101
- defaultLocale: this.config.get('app.i18n.default_locale', 'ko'),
102
- fallback: this.config.get('app.i18n.fallback', 'en'),
103
- dir: this.config.get('app.i18n.dir', './locales'),
104
- bridge: this._bridge,
105
- });
106
- this.storage = null;
107
- this._scheduler = null;
108
- this._queue = null;
109
- this._wsHandlers = new Map();
110
-
111
- // DB 연결 매니저
112
- this._connectionManager = new ConnectionManager();
113
-
114
- // Worker 매니저 — shared/workers 기준
115
- this._workerPool = new WorkerPool(this, {
116
- workersDir: path.resolve(this.baseDir, 'shared/workers'),
117
- });
118
-
119
- /** @type {WorkerPool} public worker 접근 */
120
- this.worker = this._workerPool;
121
-
122
- // 에러 핸들러
123
- this._errorHandler = new ErrorHandler({
124
- isDev: this.config.get('app.environment', 'development') === 'development',
125
- logger: (...args) => this.logger.error(...args),
126
- });
127
-
128
- // 라이프사이클 상태
129
- this._booted = false;
130
- }
131
-
132
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
133
- // DI — 서비스 컨테이너
134
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
135
-
136
- register(name, factory) {
137
- this._factories.set(name, factory);
138
- }
139
-
140
- make(name) {
141
- if (this._services.has(name)) return this._services.get(name);
142
- const factory = this._factories.get(name);
143
- if (!factory) throw new Error(`Service '${name}' is not registered`);
144
- const instance = factory(this);
145
- this._services.set(name, instance);
146
- return instance;
147
- }
148
-
149
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
150
- // 라우터 접근
151
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
152
-
153
- /**
154
- * 앱별 라우터 인스턴스
155
- * @param {string} [appName] - 앱 이름 (미지정 시 첫 번째 앱)
156
- */
157
- getRouter(appName) {
158
- const name = appName || this._getDefaultAppName();
159
- return this._appRegistry.get(name)?.router || null;
160
- }
161
-
162
- /**
163
- * 라우트 파일 로드 (특정 앱에)
164
- * @param {Function} routeCallback - (r: RouteGroup) => void
165
- * @param {string} [appName]
166
- */
167
- routes(routeCallback, appName) {
168
- const name = appName || this._getDefaultAppName();
169
- const entry = this._appRegistry.get(name);
170
- if (entry?.router) entry.router.load(routeCallback);
171
- }
172
-
173
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
174
- // 미들웨어
175
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
176
-
177
- /**
178
- * 글로벌 미들웨어 등록
179
- * @param {Function} fn - (ctx, next) => void
180
- */
181
- use(fn) {
182
- this._globalMiddleware.push(fn);
183
- return this;
184
- }
185
-
186
- /**
187
- * 이름으로 미들웨어 인스턴스 조회
188
- * @param {string} name - 'auth', 'csrf' 등
189
- * @returns {Function} - (ctx, next) => void
190
- */
191
- /**
192
- * @param {string} name - 미들웨어 이름 (e.g. 'auth', 'role:admin')
193
- * @param {string} [appName] - 앱 이름 (앱별 미들웨어 우선 탐색)
194
- */
195
- resolveMiddleware(name, appName) {
196
- // 캐시 히트 → zero-allocation (M-1)
197
- const cacheKey = appName ? `${appName}:${name}` : name;
198
- if (this._mwFnCache?.has(cacheKey)) return this._mwFnCache.get(cacheKey);
199
-
200
- // 파라미터화: 'role:admin' → name='role', params=['admin']
201
- const [mwName, ...params] = name.split(':');
202
-
203
- // 앱별 미들웨어 우선 탐색 → 없으면 무시
204
- let MwClass = null;
205
- if (appName) {
206
- const appEntry = this._appRegistry.get(appName);
207
- MwClass = appEntry?.middlewareRegistry?.get(mwName) || null;
208
- }
209
- if (!MwClass) return null;
210
-
211
- const instance = new MwClass(this);
212
- const fn = (ctx, next) => instance.handle(ctx, next, ...params);
213
-
214
- // 캐시 저장
215
- if (!this._mwFnCache) this._mwFnCache = new Map();
216
- this._mwFnCache.set(cacheKey, fn);
217
- return fn;
218
- }
219
-
220
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
221
- // 이벤트 시스템
222
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
223
-
224
- on(event, handler) {
225
- if (!this._eventHandlers.has(event)) {
226
- this._eventHandlers.set(event, []);
227
- }
228
- this._eventHandlers.get(event).push(handler);
229
- }
230
-
231
- async emit(event, data, opts) {
232
- const handlers = this._eventHandlers.get(event);
233
- if (!handlers) return;
234
- const meta = { remote: false, server: this.config.get('app.name', 'fuzionx'), timestamp: Date.now() };
235
- for (const handler of handlers) {
236
- try { await handler(data, meta); } catch (err) {
237
- this.logger.error(`Event '${event}' handler error:`, err);
238
- }
239
- }
240
- }
241
-
242
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
243
- // 에러 핸들러
244
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
245
-
246
- useError(pathOrHandler, handler) {
247
- if (typeof pathOrHandler === 'function') {
248
- this._errorHandlers.push({ path: null, handler: pathOrHandler });
249
- } else {
250
- this._errorHandlers.push({ path: pathOrHandler, handler });
251
- }
252
- }
253
-
254
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
255
- // 큐 디스패치
256
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
257
-
258
- dispatch(taskOrName, data, opts) {
259
- if (this._queue) this._queue.dispatch(taskOrName, data, opts);
260
- }
261
-
262
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
263
- // 부트스트랩
264
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
265
-
266
- async boot() {
267
- if (this._booted) return;
268
-
269
- // 0. Bridge 초기화 — boot 로직 전에 bridge 확보
270
- // Helper(crypto/hash/media/file/i18n/logger/view)가 boot 중 bridge 필요
271
- if (FuzionXApp && !this._coreApp) {
272
- this._coreApp = new FuzionXApp({
273
- config: this.configPath
274
- ? path.resolve(this.baseDir, this.configPath)
275
- : undefined,
276
- });
277
- this._bridge = this._coreApp._bridge;
278
- this._propagateBridge();
279
- }
280
-
281
- // 1. .env 로드 (17-config.md)
282
- this.config.loadEnv(this.baseDir);
283
- // .env 로드 후 캐시 클리어 (새로운 환경변수 반영)
284
- this.config._cache?.clear();
285
-
286
- await this.emit('booting');
287
-
288
- // 2. i18n 로드 (04-bootstrap-lifecycle.md)
289
- await this.i18n.load();
290
-
291
- // 3. Scheduler / Queue / Storage 초기화
292
- this._scheduler = new Scheduler(this);
293
- this._queue = new Queue(this, { driver: this.config.get('queue.driver', 'memory') });
294
- this.storage = new Storage({
295
- driver: this.config.get('storage.driver', 'local'),
296
- basePath: path.resolve(this.baseDir, this.config.get('storage.path', './storage')),
297
- fileHelper: this.file,
298
- });
299
-
300
- // 4. Phase 1: 공유 리소스 로드 (database/models, shared/events,jobs,workers)
301
- const sharedLoader = new AutoLoader(this, this.baseDir, { mode: 'shared' });
302
- await sharedLoader.load();
303
-
304
- // 5. Phase 2: 앱별 리소스 로드 (app/{name}/controllers,routes,services,middleware,ws,views)
305
- const appNames = this._getAppNames();
306
- for (const name of appNames) {
307
- const appDir = path.resolve(this.baseDir, 'app', name);
308
- const appEntry = {
309
- name,
310
- router: new Router(),
311
- controllers: new Map(),
312
- controllerCache: new Map(),
313
- middlewareRegistry: new Map(),
314
- wsHandlers: new Map(),
315
- view: new View({
316
- viewsPath: path.resolve(appDir, 'views'),
317
- theme: this.config.get('app.themes.default', 'default'),
318
- bridge: this._bridge,
319
- }),
320
- };
321
- const appLoader = new AutoLoader(this, appDir, { mode: 'app', appContext: appEntry });
322
- await appLoader.load();
323
- this._appRegistry.set(name, appEntry);
324
- }
325
-
326
- // 6. DB 연결 매니저 초기화 (database 섹션이 있을 때만)
327
- const dbConfig = this.config.get('database');
328
- if (dbConfig) {
329
- if (!dbConfig.connections) {
330
- const connections = {};
331
- for (const [key, val] of Object.entries(dbConfig)) {
332
- if (key !== 'default' && typeof val === 'object' && val !== null) {
333
- connections[key] = val;
334
- }
335
- }
336
- if (Object.keys(connections).length > 0) {
337
- dbConfig.connections = connections;
338
- if (!dbConfig.default) dbConfig.default = Object.keys(connections)[0];
339
- }
340
- }
341
- if (dbConfig.connections) {
342
- this._connectionManager.configure(dbConfig);
343
- SqlModel.setConnectionManager(this._connectionManager);
344
- MongoModel.setConnectionManager(this._connectionManager);
345
- }
346
- }
347
-
348
- // 7. OpenAPI / Swagger UI 라우트 등록 (모든 앱의 라우트 통합)
349
- this._registerDocsRoutes();
350
-
351
- this._booted = true;
352
-
353
- // hostname → appName 매핑 사전 계산 (매 요청 O(1) 조회)
354
- this._hostAppMap = new Map();
355
- const appsConfig = this.config.get('apps') || {};
356
- for (const [hostname, appName] of Object.entries(appsConfig)) {
357
- this._hostAppMap.set(hostname, appName);
358
- }
359
-
360
- await this.emit('booted');
361
- }
362
-
363
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
364
- // Multi-App 도메인 라우팅
365
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
366
-
367
- /**
368
- * config.apps에서 유니크 앱 이름 목록 추출
369
- * @returns {string[]}
370
- */
371
- _getAppNames() {
372
- const appsConfig = this.config.get('apps') || {};
373
- return [...new Set(Object.values(appsConfig))];
374
- }
375
-
376
- /**
377
- * 기본 앱 이름 (config.apps의 첫 번째 항목 값)
378
- * @returns {string}
379
- */
380
- _getDefaultAppName() {
381
- const appsConfig = this.config.get('apps') || {};
382
- const values = Object.values(appsConfig);
383
- return values[0] || 'fuzionx';
384
- }
385
-
386
- /**
387
- * 호스트 → 앱 이름 결정
388
- * reverse proxy 경유 시 X-Forwarded-Host 우선 사용.
389
- * @param {string} host - 요청 Host 헤더 (port 포함 가능)
390
- * @param {object} [headers] - 전체 요청 헤더 (proxy 지원)
391
- * @returns {string} 앱 이름
392
- */
393
- _resolveApp(host, headers) {
394
- // reverse proxy: X-Forwarded-Host > X-Original-Host > Host
395
- const forwardedHost = headers?.['x-forwarded-host'] || headers?.['X-Forwarded-Host']
396
- || headers?.['x-original-host'] || headers?.['X-Original-Host'];
397
- const rawHost = forwardedHost || host || '';
398
- const hostname = rawHost.split(':')[0]; // 포트 제거
399
-
400
- // 사전 계산된 Map O(1) 조회
401
- const mapped = this._hostAppMap?.get(hostname);
402
- if (mapped) return mapped;
403
-
404
- // 매칭 없으면 기본 앱
405
- return this._getDefaultAppName();
406
- }
407
-
408
- /**
409
- * Bridge 참조를 모든 Helper 인스턴스에 전파
410
- * @private
411
- */
412
- _propagateBridge() {
413
- if (!this._bridge) return;
414
- // View는 앱별 → boot()에서 생성 시 bridge 주입됨
415
- if (this.logger) this.logger._bridge = this._bridge;
416
- if (this.crypto) this.crypto._bridge = this._bridge;
417
- if (this.hash) this.hash._bridge = this._bridge;
418
- if (this.media) this.media._bridge = this._bridge;
419
- if (this.file) this.file._bridge = this._bridge;
420
- if (this.i18n) this.i18n._bridge = this._bridge;
421
- }
422
-
423
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
424
- // 서버 시작 — Bridge 연결
425
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
426
-
427
- /**
428
- * Bridge 서버 시작
429
- * FuzionXApp과 연동하여 rawReq를 Context로 변환.
430
- * @param {number} [port]
431
- * @param {Function} [callback]
432
- */
433
- async listen(port, callback) {
434
- if (typeof port === 'function') { callback = port; port = undefined; }
435
- port = port ?? this.config.get('bridge.server.port') ?? this.config.get('bridge.port', 3000);
436
-
437
- if (!this._booted) await this.boot();
438
-
439
- // PID 파일 생성 (primary만)
440
- if (!cluster.isWorker) {
441
- await this._checkPort(port);
442
- const pidPath = path.join(this.baseDir, 'fuzionx.pid');
443
- await fs.writeFile(pidPath, String(process.pid));
444
- }
445
-
446
- // process.title 설정 (ps에서 보이는 프로세스 이름)
447
- const appName = this.config.get('app.name', 'fuzionx');
448
- if (cluster.isWorker) {
449
- process.title = `${appName}-worker-${cluster.worker.id}`;
450
- } else {
451
- process.title = `${appName}-primary`;
452
- }
453
-
454
- await this.emit('ready');
455
-
456
- // Bridge 연동 — _coreApp은 boot()에서 이미 생성됨
457
- if (this._coreApp) {
458
- // boot()에서 port 없이 생성됐으므로 port 설정
459
- this._coreApp._port = port;
460
-
461
- // WS proxy 연결 (WsHandler에서 this.app.ws 사용)
462
- if (!this.ws) this.ws = this._coreApp.ws;
463
-
464
- this._registerBridgeRoutes(this._coreApp);
465
-
466
- // WsHandler → Bridge WS 이벤트 연결
467
- this._registerWsHandlers(this._coreApp);
468
-
469
- this._coreApp.listen(port, callback);
470
- // Bridge가 자체 graceful shutdown 처리 → Framework 중복 등록 불필요
471
- } else {
472
- // Bridge 미설치 (테스트 환경)
473
- this._initControllers();
474
- if (callback) callback();
475
- this._setupGracefulShutdown();
476
- }
477
-
478
- await this.emit('listening');
479
-
480
- // Scheduler 시작 — primary 프로세스에서만 (워커 중복 실행 방지)
481
- // fuzionx 상위 레이어가 cluster.fork()로 워커를 생성하므로
482
- // cluster.isPrimary로 단일 프로세스 보장
483
- if (this._scheduler && this._scheduler._jobs.length > 0 && !cluster.isWorker) {
484
- this._scheduler.start();
485
- }
486
-
487
- return this;
488
- }
489
-
490
- /**
491
- * 포트 사용 여부 확인 — 이미 바인딩된 포트면 명확한 에러
492
- * @param {number} port
493
- * @private
494
- */
495
- async _checkPort(port) {
496
- const net = await import('node:net');
497
- return new Promise((resolve) => {
498
- const tester = net.createServer()
499
- .once('error', (err) => {
500
- if (err.code === 'EADDRINUSE') {
501
- console.error(
502
- `\n❌ 포트 ${port}이(가) 이미 사용 중입니다.\n` +
503
- ` 다른 FuzionX 인스턴스가 실행 중인지 확인하세요.\n` +
504
- ` 확인: lsof -i :${port}\n` +
505
- ` 종료: fuser -k ${port}/tcp\n`
506
- );
507
- process.exit(1);
508
- } else {
509
- console.error(`❌ 포트 ${port} 확인 실패:`, err.message);
510
- process.exit(1);
511
- }
512
- })
513
- .once('listening', () => {
514
- tester.close(() => resolve());
515
- })
516
- .listen(port, '0.0.0.0');
517
- });
518
- }
519
-
520
- /**
521
- * OpenAPI / Swagger UI 라우트 등록 (21-openapi.md)
522
- *
523
- * app.docs.enabled = true 시 활성화.
524
- * 라우트: GET /docs, GET /docs/openapi.json, GET /docs/openapi.yaml
525
- * @private
526
- */
527
- _registerDocsRoutes() {
528
- const docsConfig = this.config.get('app.docs');
529
- if (!docsConfig || docsConfig.enabled === false) return;
530
-
531
- const docsPath = docsConfig.path || '/docs';
532
-
533
- // 모든 앱의 라우트 통합
534
- const allRoutes = [];
535
- for (const [, appEntry] of this._appRegistry) {
536
- allRoutes.push(...appEntry.router.getRoutes());
537
- }
538
-
539
- // OpenAPI spec 빌드 (1회, 캐싱)
540
- this._openapi = new OpenAPI({
541
- title: docsConfig.title || this.config.get('app.name', 'FuzionX') + ' API',
542
- version: docsConfig.version || '1.0.0',
543
- description: docsConfig.description || '',
544
- servers: docsConfig.servers || [],
545
- });
546
- this._openapi.build(allRoutes);
547
-
548
- // docs 라우트는 기본 앱의 라우터에 등록
549
- const defaultAppName = this._getDefaultAppName();
550
- const defaultApp = this._appRegistry.get(defaultAppName);
551
- if (!defaultApp) return;
552
-
553
- // JSON spec
554
- defaultApp.router.get(`${docsPath}/openapi.json`, (ctx) => {
555
- ctx.json(this._openapi.toJSON());
556
- });
557
-
558
- // YAML spec
559
- defaultApp.router.get(`${docsPath}/openapi.yaml`, (ctx) => {
560
- ctx.setHeader('Content-Type', 'text/yaml; charset=utf-8');
561
- ctx.send(this._openapi.toYAML());
562
- });
563
-
564
- // Swagger UI HTML (CDN — 외부 의존 없음)
565
- defaultApp.router.get(docsPath, (ctx) => {
566
- const specUrl = `${docsPath}/openapi.json`;
567
- const title = docsConfig.title || 'API Docs';
568
- ctx.html(Application._swaggerHtml(title, specUrl));
569
- });
570
-
571
- this.logger.info(`[docs] Swagger UI → ${docsPath}`);
572
- }
573
-
574
- /**
575
- * Swagger UI HTML 생성 (CDN 기반)
576
- * @private
577
- */
578
- static _swaggerHtml(title, specUrl) {
579
- return `<!DOCTYPE html>
580
- <html lang="ko">
581
- <head>
582
- <meta charset="UTF-8">
583
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
584
- <title>${title}</title>
585
- <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
586
- <style>
587
- body { margin: 0; background: #fafafa; }
588
- .topbar { display: none; }
589
- </style>
590
- </head>
591
- <body>
592
- <div id="swagger-ui"></div>
593
- <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
594
- <script>
595
- SwaggerUIBundle({
596
- url: '${specUrl}',
597
- dom_id: '#swagger-ui',
598
- deepLinking: true,
599
- presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
600
- layout: 'BaseLayout',
601
- });
602
- </script>
603
- </body>
604
- </html>`;
605
- }
606
-
607
- /**
608
- * 앱별 싱글톤 컨트롤러 초기화
609
- * @private
610
- */
611
- _initControllers() {
612
- for (const [, appEntry] of this._appRegistry) {
613
- for (const route of appEntry.router.getRoutes()) {
614
- const handler = route.handler;
615
- if (handler?.__handler__ && handler.controller) {
616
- const CtrlClass = handler.controller;
617
- if (!appEntry.controllerCache.has(CtrlClass)) {
618
- appEntry.controllerCache.set(CtrlClass, new CtrlClass(this));
619
- }
620
- }
621
- }
622
- }
623
- }
624
-
625
- /**
626
- * 프레임워크 라우트 → Bridge 라우트 변환 (Host 기반 앱 디스패치)
627
- *
628
- * 동일 path에 여러 앱이 라우트를 가질 수 있으므로,
629
- * 각 method+path 당 하나의 Bridge 핸들러를 등록하고
630
- * 런타임에 Host 헤더로 앱을 결정한다.
631
- *
632
- * @private
633
- */
634
- _registerBridgeRoutes(coreApp) {
635
- this._initControllers();
636
-
637
- // 디스패치 테이블: "METHOD:path" → Map<appName, { route, middlewareFns }>
638
- const dispatch = new Map();
639
-
640
- for (const [appName, appEntry] of this._appRegistry) {
641
- for (const route of appEntry.router.getRoutes()) {
642
- const key = `${route.method}:${route.path}`;
643
- if (!dispatch.has(key)) dispatch.set(key, new Map());
644
-
645
- // 미들웨어 체인 사전 구성
646
- const middlewareFns = [...this._globalMiddleware];
647
- if (route.middleware?.length) {
648
- for (const mw of route.middleware) {
649
- // 함수면 직접 사용 (built-in: auth(), cors() 등)
650
- // 문자열이면 resolveMiddleware로 클래스 기반 조회
651
- if (typeof mw === 'function') {
652
- middlewareFns.push(mw);
653
- } else if (typeof mw === 'string') {
654
- const fn = this.resolveMiddleware(mw, appName);
655
- if (fn) middlewareFns.push(fn);
656
- }
657
- }
658
- }
659
-
660
- dispatch.get(key).set(appName, { route, middlewareFns });
661
- }
662
- }
663
-
664
- // 유니크 path별 하나의 Bridge 핸들러 등록
665
- for (const [key, appMap] of dispatch) {
666
- const [method, routePath] = [key.split(':')[0].toLowerCase(), key.slice(key.indexOf(':') + 1)];
667
- if (typeof coreApp[method] !== 'function') continue;
668
-
669
- const bridgeHandler = this._createDispatchHandler(appMap);
670
- coreApp[method](routePath, bridgeHandler);
671
- }
672
- }
673
-
674
- /**
675
- * WsHandler → Bridge WS 이벤트 연결 (모든 앱의 핸들러)
676
- * @private
677
- */
678
- _registerWsHandlers(coreApp) {
679
- if (!coreApp.ws) return;
680
-
681
- for (const [, appEntry] of this._appRegistry) {
682
- if (!appEntry.wsHandlers || appEntry.wsHandlers.size === 0) continue;
683
-
684
- for (const [namespace, HandlerClass] of appEntry.wsHandlers) {
685
- const eventMap = HandlerClass.buildEventMap();
686
- const wsNs = coreApp.ws(namespace);
687
-
688
- // 싱글톤 인스턴스
689
- const inst = new HandlerClass(this);
690
-
691
- wsNs.on('connect', (socket) => {
692
- this.logger.debug(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
693
- inst.onConnect(socket);
694
- });
695
-
696
- wsNs.on('message', (socket, rawMessage) => {
697
- let parsed;
698
- try { parsed = typeof rawMessage === 'string' ? JSON.parse(rawMessage) : rawMessage; }
699
- catch { parsed = { type: 'message', data: rawMessage }; }
700
- const eventType = parsed.type || 'message';
701
- const eventData = parsed.data || parsed;
702
- this.logger.debug(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
703
- const entry = eventMap.get(eventType);
704
- if (entry) {
705
- const result = entry.handler.call(inst, socket, eventData);
706
- if (result && typeof result.then === 'function') {
707
- result.then(r => { if (r) socket.send(JSON.stringify(r)); })
708
- .catch(e => console.error(`[WS] error: ${eventType}`, e));
709
- } else if (result) {
710
- socket.send(JSON.stringify(result));
711
- }
712
- } else {
713
- inst.onEvent(socket, eventType, eventData);
714
- }
715
- });
716
-
717
- wsNs.on('disconnect', (socket) => {
718
- this.logger.debug(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
719
- inst.onDisconnect(socket);
720
- });
721
-
722
- this.logger.info(`[WS] 핸들러 등록: ${namespace} (events: ${[...eventMap.keys()].join(', ')})`);
723
- }
724
- }
725
- }
726
-
727
- /**
728
- * Host 기반 디스패치 핸들러 생성
729
- *
730
- * 동일 path에 대해 Host 헤더로 앱을 결정하고
731
- * 해당 앱의 route + middleware 체인을 실행.
732
- *
733
- * @param {Map<string, {route, middlewareFns}>} appMap - appName → {route, middlewareFns}
734
- * @returns {Function} Bridge 핸들러
735
- * @private
736
- */
737
- _createDispatchHandler(appMap) {
738
- return (req, res) => {
739
- // Host 헤더에서 앱 결정 (reverse proxy: X-Forwarded-Host 우선)
740
- const host = req.headers?.host || req.headers?.Host || '';
741
- const appName = this._resolveApp(host, req.headers);
742
-
743
- // 해당 앱의 route+middleware 찾기 (없으면 기본 앱)
744
- let entry = appMap.get(appName);
745
- if (!entry) {
746
- // 매칭 앱에 이 경로가 없으면 기본 앱 시도
747
- const defaultApp = this._getDefaultAppName();
748
- entry = appMap.get(defaultApp);
749
- }
750
- if (!entry) {
751
- // 어떤 앱에도 없으면 첫 번째 앱
752
- entry = appMap.values().next().value;
753
- }
754
-
755
- const { route, middlewareFns } = entry;
756
-
757
- // rawReq → Context
758
- const ctx = new Context({
759
- method: req.method,
760
- url: req.url,
761
- path: req.path,
762
- query: req.query,
763
- params: req.params,
764
- headers: req.headers,
765
- body: req.body || req.json,
766
- remoteIp: req.ip,
767
- handlerId: req.handlerId,
768
- requestId: req.requestId,
769
- sessionId: req.sessionId,
770
- session: req.session?._data || {},
771
- files: req.files || null,
772
- formFields: req.formFields || null,
773
- }, this);
774
-
775
- ctx.appName = appName;
776
-
777
- // async 체인 실행 → sendAsyncResponse
778
- const promise = this._executeChain(middlewareFns, route, ctx);
779
-
780
- promise.then(() => {
781
- const response = ctx.toResponse();
782
- const contentType = response.headers?.['Content-Type'] || 'application/json';
783
- const headerParts = [];
784
- if (response.headers) {
785
- for (const [k, v] of Object.entries(response.headers)) {
786
- if (k !== 'Content-Type') headerParts.push(`${k}: ${v}`);
787
- }
788
- }
789
- const extraHeaders = headerParts.length > 0 ? headerParts.join('\r\n') + '\r\n' : '';
790
- try {
791
- this._coreApp._bridge.sendAsyncResponse(
792
- req.requestId, response.status,
793
- response.body || '', contentType, extraHeaders,
794
- );
795
- } catch (e) {
796
- console.error('[fuzionx] sendAsyncResponse failed:', e.message);
797
- }
798
- }).catch((err) => {
799
- console.error('[fuzionx] Handler error:', err.message || err);
800
- try {
801
- this._coreApp._bridge.sendAsyncResponse(
802
- req.requestId, 500,
803
- JSON.stringify({ error: err.message || 'Internal Server Error' }),
804
- 'application/json', '',
805
- );
806
- } catch (e) {
807
- console.error('[fuzionx] sendAsyncResponse error failed:', e.message);
808
- }
809
- });
810
-
811
- return { async: true };
812
- };
813
- }
814
-
815
- /**
816
- * 단일 라우트의 Bridge 핸들러 생성 (앱별)
817
- * rawReq → Context → middleware → controller → toResponse
818
- * @deprecated _createDispatchHandler를 사용하세요. 이 메서드는 테스트 호환용으로만 유지됩니다.
819
- * @param {object} route
820
- * @param {string} appName - 이 라우트가 속한 앱
821
- * @private
822
- */
823
- _createBridgeHandler(route, appName) {
824
- // 미들웨어 체인 사전 구성
825
- const middlewareFns = [];
826
-
827
- // 글로벌 미들웨어
828
- middlewareFns.push(...this._globalMiddleware);
829
-
830
- // 앱별 라우트 미들웨어 (이름 → 인스턴스 변환)
831
- if (route.middleware?.length) {
832
- for (const mwName of route.middleware) {
833
- const fn = this.resolveMiddleware(mwName, appName);
834
- if (fn) middlewareFns.push(fn);
835
- }
836
- }
837
-
838
- return (req, res) => {
839
- // rawReq → Context 변환
840
- const ctx = new Context({
841
- method: req.method,
842
- url: req.url,
843
- path: req.path,
844
- query: req.query,
845
- params: req.params,
846
- headers: req.headers,
847
- body: req.body || req.json,
848
- remoteIp: req.ip,
849
- handlerId: req.handlerId,
850
- requestId: req.requestId,
851
- sessionId: req.sessionId,
852
- session: req.session?._data || {},
853
- files: req.files || null,
854
- formFields: req.formFields || null,
855
- }, this);
856
-
857
- // 앱 이름 주입
858
- ctx.appName = appName;
859
-
860
- // Framework → async 체인 실행 후 직접 sendAsyncResponse
861
- const promise = this._executeChain(middlewareFns, route, ctx);
862
-
863
- promise.then(() => {
864
- const response = ctx.toResponse();
865
- const contentType = response.headers?.['Content-Type'] || 'application/json';
866
- const headerParts = [];
867
- if (response.headers) {
868
- for (const [k, v] of Object.entries(response.headers)) {
869
- if (k !== 'Content-Type') headerParts.push(`${k}: ${v}`);
870
- }
871
- }
872
- const extraHeaders = headerParts.length > 0 ? headerParts.join('\r\n') + '\r\n' : '';
873
- try {
874
- this._coreApp._bridge.sendAsyncResponse(
875
- req.requestId, response.status,
876
- response.body || '', contentType, extraHeaders,
877
- );
878
- } catch (e) {
879
- console.error('[fuzionx] sendAsyncResponse failed:', e.message);
880
- }
881
- }).catch((err) => {
882
- console.error('[fuzionx] Handler error:', err.message || err);
883
- try {
884
- this._coreApp._bridge.sendAsyncResponse(
885
- req.requestId, 500,
886
- JSON.stringify({ error: err.message || 'Internal Server Error' }),
887
- 'application/json', '',
888
- );
889
- } catch (e) {
890
- console.error('[fuzionx] sendAsyncResponse error failed:', e.message);
891
- }
892
- });
893
-
894
- return { async: true };
895
- };
896
- }
897
-
898
- /**
899
- * 미들웨어 체인 + 핸들러 실행
900
- * @private
901
- */
902
- async _executeChain(middlewareFns, route, ctx) {
903
- let idx = 0;
904
-
905
- const next = async () => {
906
- if (ctx._sent) return;
907
- if (idx < middlewareFns.length) {
908
- const fn = middlewareFns[idx++];
909
- await fn(ctx, next);
910
- return;
911
- }
912
-
913
- // 미들웨어 완료 → 핸들러 실행
914
- await this._executeHandler(route.handler, ctx);
915
- };
916
-
917
- try {
918
- await next();
919
- } catch (err) {
920
- // 커스텀 에러 핸들러 체크 (useError로 등록)
921
- let handled = false;
922
- for (const { path: ePath, handler } of this._errorHandlers) {
923
- if (!ePath || ctx.path?.startsWith(ePath)) {
924
- try {
925
- await handler(err, ctx);
926
- handled = true;
927
- break;
928
- } catch { /* 커스텀 핸들러 실패 시 기본으로 */ }
929
- }
930
- }
931
- if (!handled) this._errorHandler.handle(err, ctx);
932
- }
933
- // 응답은 _createBridgeHandler의 .then()에서 sendAsyncResponse로 전송
934
- }
935
-
936
- /**
937
- * 핸들러 실행 (싱글톤 컨트롤러 or 일반 함수)
938
- * @private
939
- */
940
- async _executeHandler(handler, ctx) {
941
- if (handler?.__handler__) {
942
- // 앱별 싱글톤 컨트롤러 메서드 참조
943
- const appEntry = this._appRegistry.get(ctx.appName);
944
- const instance = appEntry?.controllerCache?.get(handler.controller);
945
- if (instance && typeof instance[handler.method] === 'function') {
946
- await instance[handler.method](ctx);
947
- return;
948
- }
949
- }
950
-
951
- // 일반 함수 핸들러
952
- if (typeof handler === 'function') {
953
- await handler(ctx);
954
- }
955
- }
956
-
957
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
958
- // Graceful Shutdown
959
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
960
-
961
- _setupGracefulShutdown() {
962
- let shutdownCalled = false;
963
-
964
- const shutdown = async (signal) => {
965
- if (shutdownCalled) return;
966
- shutdownCalled = true;
967
-
968
- this.logger.info(`[fuzionx] ${signal} received — shutting down`);
969
- await this.emit('shutting-down', { signal });
970
-
971
- // 1. 스케줄러 정지 (04-bootstrap-lifecycle.md)
972
- if (this._scheduler) this._scheduler.stop();
973
-
974
- // 2. 큐 drain — 처리 중 Task 완료 대기 (doc 04 순서 3)
975
- if (this._queue && this._queue.pending > 0) {
976
- this.logger.info(`[fuzionx] Draining queue (${this._queue.pending} pending)...`);
977
- const drainTimeout = this.config.get('app.shutdown_timeout', 30000);
978
- const start = Date.now();
979
- while (this._queue.pending > 0 && (Date.now() - start) < drainTimeout) {
980
- await new Promise(r => setTimeout(r, 100));
981
- }
982
- }
983
-
984
- // 3. DB 연결 해제 (ConnectionManager)
985
- if (this._connectionManager) {
986
- try { await this._connectionManager.closeAll(); } catch {}
987
- }
988
-
989
- // 3-1. Worker 종료
990
- if (this._workerPool) {
991
- try { await this._workerPool.terminate(); } catch {}
992
- }
993
-
994
- // 4. Bridge 서버 정지
995
- if (this._coreApp?._bridge) {
996
- try { this._coreApp._bridge.stopFusionServer(); } catch {}
997
- }
998
-
999
- await this.emit('shutdown');
1000
- setTimeout(() => process.exit(0), 2000);
1001
- };
1002
-
1003
- process.once('SIGTERM', () => shutdown('SIGTERM'));
1004
- process.once('SIGINT', () => shutdown('SIGINT'));
1005
- }
1006
- }
1
+ /**
2
+ * Application — 서비스 컨테이너 + 부트스트랩 오케스트레이터 + Bridge 어댑터
3
+ *
4
+ * @fuzionx/core의 FuzionXApp을 내부적으로 사용하여 Bridge 서버 시작.
5
+ * rawReq → Context 변환 → 미들웨어 체인 → 컨트롤러 → toResponse().
6
+ *
7
+ * @see docs/framework/00-overview.md
8
+ * @see docs/framework/04-bootstrap-lifecycle.md
9
+ */
10
+ import path from 'node:path';
11
+ import { promises as fs } from 'node:fs';
12
+ import cluster from 'node:cluster';
13
+ import Config from './Config.js';
14
+ import Context from './Context.js';
15
+ import Router from '../http/Router.js';
16
+ import ModelRegistry from '../database/ModelRegistry.js';
17
+ import ErrorHandler from '../http/ErrorHandler.js';
18
+ import AutoLoader from './AutoLoader.js';
19
+ import Logger from '../helpers/Logger.js';
20
+ import I18nHelper from '../helpers/I18nHelper.js';
21
+ import CryptoHelper from '../helpers/CryptoHelper.js';
22
+ import HashHelper from '../helpers/HashHelper.js';
23
+ import MediaHelper from '../helpers/MediaHelper.js';
24
+ import FileHelper from '../helpers/FileHelper.js';
25
+ import View from '../view/View.js';
26
+ import OpenAPI from '../view/OpenAPI.js';
27
+ import Scheduler from '../schedule/Scheduler.js';
28
+ import Queue from '../schedule/Queue.js';
29
+ import Storage from '../services/Storage.js';
30
+ import ConnectionManager from '../database/ConnectionManager.js';
31
+ import SqlModel from '../database/SqlModel.js';
32
+ import MongoModel from '../database/MongoModel.js';
33
+ import WorkerPool from '../schedule/WorkerPool.js';
34
+
35
+ // Bridge: lazy-load (테스트 환경에서 native 바인딩 없을 수 있음)
36
+ let FuzionXApp;
37
+ try {
38
+ const core = await import('@fuzionx/core');
39
+ FuzionXApp = core.FuzionXApp || core.RuxyApp;
40
+ } catch (e) {
41
+ console.error('[fuzionx-framework] ⚠️ @fuzionx/core 로드 실패 — Bridge 없이 테스트 모드로 실행됩니다:',
42
+ e.message);
43
+ }
44
+
45
+ export default class Application {
46
+ /**
47
+ * @param {object} [opts]
48
+ * @param {object} [opts.config] - 이미 파싱된 설정 객체 또는 Config 인스턴스
49
+ * @param {string} [opts.configPath] - YAML 설정 파일 경로
50
+ * @param {string} [opts.baseDir='.'] - 프로젝트 루트 경로
51
+ * @param {object} [opts.bridge] - @fuzionx/core bridge 인스턴스 (선택)
52
+ */
53
+ constructor(opts = {}) {
54
+ // Config
55
+ if (opts.config instanceof Config) {
56
+ this.config = opts.config;
57
+ } else {
58
+ this.config = new Config(opts.config || {});
59
+ }
60
+
61
+ // 프로젝트 루트 (반드시 절대경로 — ESM import 호환)
62
+ this.baseDir = path.resolve(opts.baseDir || process.cwd());
63
+ this.configPath = opts.configPath || null;
64
+
65
+ // configPath 지정 시 YAML 파일에서 database/app/themes 등 로드
66
+ if (this.configPath) {
67
+ this.config.loadYaml(path.resolve(this.baseDir, this.configPath));
68
+ }
69
+
70
+ // Bridge 참조 (listen() 시 FuzionXApp 생성)
71
+ this._bridge = null;
72
+ this._coreApp = null;
73
+
74
+ // DI 컨테이너
75
+ this._services = new Map();
76
+ this._factories = new Map();
77
+
78
+ // 이벤트 핸들러
79
+ this._eventHandlers = new Map();
80
+ this._errorHandlers = [];
81
+
82
+ // 멀티앱 레지스트리 (도메인→앱 라우팅)
83
+ this._appRegistry = new Map();
84
+
85
+ // 글로벌 미들웨어
86
+ this._globalMiddleware = [];
87
+
88
+ // 프레임워크 컴포넌트
89
+ this.db = new ModelRegistry();
90
+ this.logger = new Logger({
91
+ level: this.config.get('bridge.logging.level', 'info'),
92
+ bridge: this._bridge,
93
+ json: this.config.get('bridge.logging.json', false),
94
+ });
95
+ this.crypto = new CryptoHelper(this._bridge);
96
+ this.hash = new HashHelper(this._bridge);
97
+ this.media = new MediaHelper(this._bridge);
98
+ this.file = new FileHelper(this._bridge);
99
+ this.ws = null;
100
+ this.i18n = new I18nHelper({
101
+ defaultLocale: this.config.get('app.i18n.default_locale', 'ko'),
102
+ fallback: this.config.get('app.i18n.fallback', 'en'),
103
+ dir: this.config.get('app.i18n.dir', './locales'),
104
+ bridge: this._bridge,
105
+ });
106
+ this.storage = null;
107
+ this._scheduler = null;
108
+ this._queue = null;
109
+ this._wsHandlers = new Map();
110
+
111
+ // DB 연결 매니저
112
+ this._connectionManager = new ConnectionManager();
113
+
114
+ // Worker 매니저 — shared/workers 기준
115
+ this._workerPool = new WorkerPool(this, {
116
+ workersDir: path.resolve(this.baseDir, 'shared/workers'),
117
+ });
118
+
119
+ /** @type {WorkerPool} public worker 접근 */
120
+ this.worker = this._workerPool;
121
+
122
+ // 에러 핸들러
123
+ this._errorHandler = new ErrorHandler({
124
+ isDev: this.config.get('app.environment', 'development') === 'development',
125
+ logger: (...args) => this.logger.error(...args),
126
+ });
127
+
128
+ // 라이프사이클 상태
129
+ this._booted = false;
130
+ }
131
+
132
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
133
+ // DI — 서비스 컨테이너
134
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
135
+
136
+ register(name, factory) {
137
+ this._factories.set(name, factory);
138
+ }
139
+
140
+ make(name) {
141
+ if (this._services.has(name)) return this._services.get(name);
142
+ const factory = this._factories.get(name);
143
+ if (!factory) throw new Error(`Service '${name}' is not registered`);
144
+ const instance = factory(this);
145
+ this._services.set(name, instance);
146
+ return instance;
147
+ }
148
+
149
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
150
+ // 라우터 접근
151
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
152
+
153
+ /**
154
+ * 앱별 라우터 인스턴스
155
+ * @param {string} [appName] - 앱 이름 (미지정 시 첫 번째 앱)
156
+ */
157
+ getRouter(appName) {
158
+ const name = appName || this._getDefaultAppName();
159
+ return this._appRegistry.get(name)?.router || null;
160
+ }
161
+
162
+ /**
163
+ * 라우트 파일 로드 (특정 앱에)
164
+ * @param {Function} routeCallback - (r: RouteGroup) => void
165
+ * @param {string} [appName]
166
+ */
167
+ routes(routeCallback, appName) {
168
+ const name = appName || this._getDefaultAppName();
169
+ const entry = this._appRegistry.get(name);
170
+ if (entry?.router) entry.router.load(routeCallback);
171
+ }
172
+
173
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
174
+ // 미들웨어
175
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
176
+
177
+ /**
178
+ * 글로벌 미들웨어 등록
179
+ * @param {Function} fn - (ctx, next) => void
180
+ */
181
+ use(fn) {
182
+ this._globalMiddleware.push(fn);
183
+ return this;
184
+ }
185
+
186
+ /**
187
+ * 이름으로 미들웨어 인스턴스 조회
188
+ * @param {string} name - 'auth', 'csrf' 등
189
+ * @returns {Function} - (ctx, next) => void
190
+ */
191
+ /**
192
+ * @param {string} name - 미들웨어 이름 (e.g. 'auth', 'role:admin')
193
+ * @param {string} [appName] - 앱 이름 (앱별 미들웨어 우선 탐색)
194
+ */
195
+ resolveMiddleware(name, appName) {
196
+ // 캐시 히트 → zero-allocation (M-1)
197
+ const cacheKey = appName ? `${appName}:${name}` : name;
198
+ if (this._mwFnCache?.has(cacheKey)) return this._mwFnCache.get(cacheKey);
199
+
200
+ // 파라미터화: 'role:admin' → name='role', params=['admin']
201
+ const [mwName, ...params] = name.split(':');
202
+
203
+ // 앱별 미들웨어 우선 탐색 → 없으면 무시
204
+ let MwClass = null;
205
+ if (appName) {
206
+ const appEntry = this._appRegistry.get(appName);
207
+ MwClass = appEntry?.middlewareRegistry?.get(mwName) || null;
208
+ }
209
+ if (!MwClass) return null;
210
+
211
+ const instance = new MwClass(this);
212
+ const fn = (ctx, next) => instance.handle(ctx, next, ...params);
213
+
214
+ // 캐시 저장
215
+ if (!this._mwFnCache) this._mwFnCache = new Map();
216
+ this._mwFnCache.set(cacheKey, fn);
217
+ return fn;
218
+ }
219
+
220
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
221
+ // 이벤트 시스템
222
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
223
+
224
+ on(event, handler) {
225
+ if (!this._eventHandlers.has(event)) {
226
+ this._eventHandlers.set(event, []);
227
+ }
228
+ this._eventHandlers.get(event).push(handler);
229
+ }
230
+
231
+ async emit(event, data, opts) {
232
+ const handlers = this._eventHandlers.get(event);
233
+ if (!handlers) return;
234
+ const meta = { remote: false, server: this.config.get('app.name', 'fuzionx'), timestamp: Date.now() };
235
+ for (const handler of handlers) {
236
+ try { await handler(data, meta); } catch (err) {
237
+ this.logger.error(`Event '${event}' handler error:`, err);
238
+ }
239
+ }
240
+ }
241
+
242
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
243
+ // 에러 핸들러
244
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
245
+
246
+ useError(pathOrHandler, handler) {
247
+ if (typeof pathOrHandler === 'function') {
248
+ this._errorHandlers.push({ path: null, handler: pathOrHandler });
249
+ } else {
250
+ this._errorHandlers.push({ path: pathOrHandler, handler });
251
+ }
252
+ }
253
+
254
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
255
+ // 큐 디스패치
256
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
257
+
258
+ dispatch(taskOrName, data, opts) {
259
+ if (this._queue) this._queue.dispatch(taskOrName, data, opts);
260
+ }
261
+
262
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
263
+ // 부트스트랩
264
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
265
+
266
+ async boot() {
267
+ if (this._booted) return;
268
+
269
+ // 0. Bridge 초기화 — boot 로직 전에 bridge 확보
270
+ // Helper(crypto/hash/media/file/i18n/logger/view)가 boot 중 bridge 필요
271
+ if (FuzionXApp && !this._coreApp) {
272
+ this._coreApp = new FuzionXApp({
273
+ config: this.configPath
274
+ ? path.resolve(this.baseDir, this.configPath)
275
+ : undefined,
276
+ });
277
+ this._bridge = this._coreApp._bridge;
278
+ this._propagateBridge();
279
+ }
280
+
281
+ // 1. .env 로드 (17-config.md)
282
+ this.config.loadEnv(this.baseDir);
283
+ // .env 로드 후 캐시 클리어 (새로운 환경변수 반영)
284
+ this.config._cache?.clear();
285
+
286
+ await this.emit('booting');
287
+
288
+ // 2. i18n 로드 (04-bootstrap-lifecycle.md)
289
+ await this.i18n.load();
290
+
291
+ // 3. Scheduler / Queue / Storage 초기화
292
+ this._scheduler = new Scheduler(this);
293
+ this._queue = new Queue(this, { driver: this.config.get('queue.driver', 'memory') });
294
+ this.storage = new Storage({
295
+ driver: this.config.get('storage.driver', 'local'),
296
+ basePath: path.resolve(this.baseDir, this.config.get('storage.path', './storage')),
297
+ fileHelper: this.file,
298
+ });
299
+
300
+ // 4. Phase 1: 공유 리소스 로드 (database/models, shared/events,jobs,workers)
301
+ const sharedLoader = new AutoLoader(this, this.baseDir, { mode: 'shared' });
302
+ await sharedLoader.load();
303
+
304
+ // 5. Phase 2: 앱별 리소스 로드 (app/{name}/controllers,routes,services,middleware,ws,views)
305
+ const appNames = this._getAppNames();
306
+ for (const name of appNames) {
307
+ const appDir = path.resolve(this.baseDir, 'app', name);
308
+ const appEntry = {
309
+ name,
310
+ router: new Router(),
311
+ controllers: new Map(),
312
+ controllerCache: new Map(),
313
+ middlewareRegistry: new Map(),
314
+ wsHandlers: new Map(),
315
+ view: new View({
316
+ viewsPath: path.resolve(appDir, 'views'),
317
+ theme: this.config.get('app.themes.default', 'default'),
318
+ bridge: this._bridge,
319
+ }),
320
+ };
321
+ const appLoader = new AutoLoader(this, appDir, { mode: 'app', appContext: appEntry });
322
+ await appLoader.load();
323
+ this._appRegistry.set(name, appEntry);
324
+ }
325
+
326
+ // 6. DB 연결 매니저 초기화 (database 섹션이 있을 때만)
327
+ const dbConfig = this.config.get('database');
328
+ if (dbConfig) {
329
+ if (!dbConfig.connections) {
330
+ const connections = {};
331
+ for (const [key, val] of Object.entries(dbConfig)) {
332
+ if (key !== 'default' && typeof val === 'object' && val !== null) {
333
+ connections[key] = val;
334
+ }
335
+ }
336
+ if (Object.keys(connections).length > 0) {
337
+ dbConfig.connections = connections;
338
+ if (!dbConfig.default) dbConfig.default = Object.keys(connections)[0];
339
+ }
340
+ }
341
+ if (dbConfig.connections) {
342
+ this._connectionManager.configure(dbConfig);
343
+ SqlModel.setConnectionManager(this._connectionManager);
344
+ MongoModel.setConnectionManager(this._connectionManager);
345
+ }
346
+ }
347
+
348
+ // 7. OpenAPI / Swagger UI 라우트 등록 (모든 앱의 라우트 통합)
349
+ this._registerDocsRoutes();
350
+
351
+ this._booted = true;
352
+
353
+ // hostname → appName 매핑 사전 계산 (매 요청 O(1) 조회)
354
+ this._hostAppMap = new Map();
355
+ const appsConfig = this.config.get('apps') || {};
356
+ for (const [hostname, appName] of Object.entries(appsConfig)) {
357
+ this._hostAppMap.set(hostname, appName);
358
+ }
359
+
360
+ await this.emit('booted');
361
+ }
362
+
363
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
364
+ // Multi-App 도메인 라우팅
365
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
366
+
367
+ /**
368
+ * config.apps에서 유니크 앱 이름 목록 추출
369
+ * @returns {string[]}
370
+ */
371
+ _getAppNames() {
372
+ const appsConfig = this.config.get('apps') || {};
373
+ return [...new Set(Object.values(appsConfig))];
374
+ }
375
+
376
+ /**
377
+ * 기본 앱 이름 (config.apps의 첫 번째 항목 값)
378
+ * @returns {string}
379
+ */
380
+ _getDefaultAppName() {
381
+ const appsConfig = this.config.get('apps') || {};
382
+ const values = Object.values(appsConfig);
383
+ return values[0] || 'fuzionx';
384
+ }
385
+
386
+ /**
387
+ * 호스트 → 앱 이름 결정
388
+ * reverse proxy 경유 시 X-Forwarded-Host 우선 사용.
389
+ * @param {string} host - 요청 Host 헤더 (port 포함 가능)
390
+ * @param {object} [headers] - 전체 요청 헤더 (proxy 지원)
391
+ * @returns {string} 앱 이름
392
+ */
393
+ _resolveApp(host, headers) {
394
+ // reverse proxy: X-Forwarded-Host > X-Original-Host > Host
395
+ const forwardedHost = headers?.['x-forwarded-host'] || headers?.['X-Forwarded-Host']
396
+ || headers?.['x-original-host'] || headers?.['X-Original-Host'];
397
+ const rawHost = forwardedHost || host || '';
398
+ const hostname = rawHost.split(':')[0]; // 포트 제거
399
+
400
+ // 사전 계산된 Map O(1) 조회
401
+ const mapped = this._hostAppMap?.get(hostname);
402
+ if (mapped) return mapped;
403
+
404
+ // 매칭 없으면 기본 앱
405
+ return this._getDefaultAppName();
406
+ }
407
+
408
+ /**
409
+ * Bridge 참조를 모든 Helper 인스턴스에 전파
410
+ * @private
411
+ */
412
+ _propagateBridge() {
413
+ if (!this._bridge) return;
414
+ // View는 앱별 → boot()에서 생성 시 bridge 주입됨
415
+ if (this.logger) this.logger._bridge = this._bridge;
416
+ if (this.crypto) this.crypto._bridge = this._bridge;
417
+ if (this.hash) this.hash._bridge = this._bridge;
418
+ if (this.media) this.media._bridge = this._bridge;
419
+ if (this.file) this.file._bridge = this._bridge;
420
+ if (this.i18n) this.i18n._bridge = this._bridge;
421
+ }
422
+
423
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
424
+ // 서버 시작 — Bridge 연결
425
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
426
+
427
+ /**
428
+ * Bridge 서버 시작
429
+ * FuzionXApp과 연동하여 rawReq를 Context로 변환.
430
+ * @param {number} [port]
431
+ * @param {Function} [callback]
432
+ */
433
+ async listen(port, callback) {
434
+ if (typeof port === 'function') { callback = port; port = undefined; }
435
+ port = port ?? this.config.get('bridge.server.port') ?? this.config.get('bridge.port', 3000);
436
+
437
+ if (!this._booted) await this.boot();
438
+
439
+ // PID 파일 생성 (primary만)
440
+ if (!cluster.isWorker) {
441
+ await this._checkPort(port);
442
+ const pidPath = path.join(this.baseDir, 'fuzionx.pid');
443
+ await fs.writeFile(pidPath, String(process.pid));
444
+ }
445
+
446
+ // process.title 설정 (ps에서 보이는 프로세스 이름)
447
+ const appName = this.config.get('app.name', 'fuzionx');
448
+ if (cluster.isWorker) {
449
+ process.title = `${appName}-worker-${cluster.worker.id}`;
450
+ } else {
451
+ process.title = `${appName}-primary`;
452
+ }
453
+
454
+ await this.emit('ready');
455
+
456
+ // Bridge 연동 — _coreApp은 boot()에서 이미 생성됨
457
+ if (this._coreApp) {
458
+ // boot()에서 port 없이 생성됐으므로 port 설정
459
+ this._coreApp._port = port;
460
+
461
+ // WS proxy 연결 (WsHandler에서 this.app.ws 사용)
462
+ if (!this.ws) this.ws = this._coreApp.ws;
463
+
464
+ this._registerBridgeRoutes(this._coreApp);
465
+
466
+ // WsHandler → Bridge WS 이벤트 연결
467
+ this._registerWsHandlers(this._coreApp);
468
+
469
+ this._coreApp.listen(port, callback);
470
+ // Bridge가 자체 graceful shutdown 처리 → Framework 중복 등록 불필요
471
+ } else {
472
+ // Bridge 미설치 (테스트 환경)
473
+ this._initControllers();
474
+ if (callback) callback();
475
+ this._setupGracefulShutdown();
476
+ }
477
+
478
+ await this.emit('listening');
479
+
480
+ // Scheduler 시작 — primary 프로세스에서만 (워커 중복 실행 방지)
481
+ // fuzionx 상위 레이어가 cluster.fork()로 워커를 생성하므로
482
+ // cluster.isPrimary로 단일 프로세스 보장
483
+ if (this._scheduler && this._scheduler._jobs.length > 0 && !cluster.isWorker) {
484
+ this._scheduler.start();
485
+ }
486
+
487
+ return this;
488
+ }
489
+
490
+ /**
491
+ * 포트 사용 여부 확인 — 이미 바인딩된 포트면 명확한 에러
492
+ * @param {number} port
493
+ * @private
494
+ */
495
+ async _checkPort(port) {
496
+ const net = await import('node:net');
497
+ return new Promise((resolve) => {
498
+ const tester = net.createServer()
499
+ .once('error', (err) => {
500
+ if (err.code === 'EADDRINUSE') {
501
+ console.error(
502
+ `\n❌ 포트 ${port}이(가) 이미 사용 중입니다.\n` +
503
+ ` 다른 FuzionX 인스턴스가 실행 중인지 확인하세요.\n` +
504
+ ` 확인: lsof -i :${port}\n` +
505
+ ` 종료: fuser -k ${port}/tcp\n`
506
+ );
507
+ process.exit(1);
508
+ } else {
509
+ console.error(`❌ 포트 ${port} 확인 실패:`, err.message);
510
+ process.exit(1);
511
+ }
512
+ })
513
+ .once('listening', () => {
514
+ tester.close(() => resolve());
515
+ })
516
+ .listen(port, '0.0.0.0');
517
+ });
518
+ }
519
+
520
+ /**
521
+ * OpenAPI / Swagger UI 라우트 등록 (21-openapi.md)
522
+ *
523
+ * app.docs.enabled = true 시 활성화.
524
+ * 라우트: GET /docs, GET /docs/openapi.json, GET /docs/openapi.yaml
525
+ * @private
526
+ */
527
+ _registerDocsRoutes() {
528
+ const docsConfig = this.config.get('app.docs');
529
+ if (!docsConfig || docsConfig.enabled === false) return;
530
+
531
+ const docsPath = docsConfig.path || '/docs';
532
+
533
+ // 모든 앱의 라우트 통합
534
+ const allRoutes = [];
535
+ for (const [, appEntry] of this._appRegistry) {
536
+ allRoutes.push(...appEntry.router.getRoutes());
537
+ }
538
+
539
+ // OpenAPI spec 빌드 (1회, 캐싱)
540
+ this._openapi = new OpenAPI({
541
+ title: docsConfig.title || this.config.get('app.name', 'FuzionX') + ' API',
542
+ version: docsConfig.version || '1.0.0',
543
+ description: docsConfig.description || '',
544
+ servers: docsConfig.servers || [],
545
+ });
546
+ this._openapi.build(allRoutes);
547
+
548
+ // docs 라우트는 기본 앱의 라우터에 등록
549
+ const defaultAppName = this._getDefaultAppName();
550
+ const defaultApp = this._appRegistry.get(defaultAppName);
551
+ if (!defaultApp) return;
552
+
553
+ // JSON spec
554
+ defaultApp.router.get(`${docsPath}/openapi.json`, (ctx) => {
555
+ ctx.json(this._openapi.toJSON());
556
+ });
557
+
558
+ // YAML spec
559
+ defaultApp.router.get(`${docsPath}/openapi.yaml`, (ctx) => {
560
+ ctx.setHeader('Content-Type', 'text/yaml; charset=utf-8');
561
+ ctx.send(this._openapi.toYAML());
562
+ });
563
+
564
+ // Swagger UI HTML (CDN — 외부 의존 없음)
565
+ defaultApp.router.get(docsPath, (ctx) => {
566
+ const specUrl = `${docsPath}/openapi.json`;
567
+ const title = docsConfig.title || 'API Docs';
568
+ ctx.html(Application._swaggerHtml(title, specUrl));
569
+ });
570
+
571
+ this.logger.info(`[docs] Swagger UI → ${docsPath}`);
572
+ }
573
+
574
+ /**
575
+ * Swagger UI HTML 생성 (CDN 기반)
576
+ * @private
577
+ */
578
+ static _swaggerHtml(title, specUrl) {
579
+ return `<!DOCTYPE html>
580
+ <html lang="ko">
581
+ <head>
582
+ <meta charset="UTF-8">
583
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
584
+ <title>${title}</title>
585
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
586
+ <style>
587
+ body { margin: 0; background: #fafafa; }
588
+ .topbar { display: none; }
589
+ </style>
590
+ </head>
591
+ <body>
592
+ <div id="swagger-ui"></div>
593
+ <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
594
+ <script>
595
+ SwaggerUIBundle({
596
+ url: '${specUrl}',
597
+ dom_id: '#swagger-ui',
598
+ deepLinking: true,
599
+ presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
600
+ layout: 'BaseLayout',
601
+ });
602
+ </script>
603
+ </body>
604
+ </html>`;
605
+ }
606
+
607
+ /**
608
+ * 앱별 싱글톤 컨트롤러 초기화
609
+ * @private
610
+ */
611
+ _initControllers() {
612
+ for (const [, appEntry] of this._appRegistry) {
613
+ for (const route of appEntry.router.getRoutes()) {
614
+ const handler = route.handler;
615
+ if (handler?.__handler__ && handler.controller) {
616
+ const CtrlClass = handler.controller;
617
+ if (!appEntry.controllerCache.has(CtrlClass)) {
618
+ appEntry.controllerCache.set(CtrlClass, new CtrlClass(this));
619
+ }
620
+ }
621
+ }
622
+ }
623
+ }
624
+
625
+ /**
626
+ * 프레임워크 라우트 → Bridge 라우트 변환 (Host 기반 앱 디스패치)
627
+ *
628
+ * 동일 path에 여러 앱이 라우트를 가질 수 있으므로,
629
+ * 각 method+path 당 하나의 Bridge 핸들러를 등록하고
630
+ * 런타임에 Host 헤더로 앱을 결정한다.
631
+ *
632
+ * @private
633
+ */
634
+ _registerBridgeRoutes(coreApp) {
635
+ this._initControllers();
636
+
637
+ // 디스패치 테이블: "METHOD:path" → Map<appName, { route, middlewareFns }>
638
+ const dispatch = new Map();
639
+
640
+ for (const [appName, appEntry] of this._appRegistry) {
641
+ for (const route of appEntry.router.getRoutes()) {
642
+ const key = `${route.method}:${route.path}`;
643
+ if (!dispatch.has(key)) dispatch.set(key, new Map());
644
+
645
+ // 미들웨어 체인 사전 구성
646
+ const middlewareFns = [...this._globalMiddleware];
647
+ if (route.middleware?.length) {
648
+ for (const mw of route.middleware) {
649
+ // 함수면 직접 사용 (built-in: auth(), cors() 등)
650
+ // 문자열이면 resolveMiddleware로 클래스 기반 조회
651
+ if (typeof mw === 'function') {
652
+ middlewareFns.push(mw);
653
+ } else if (typeof mw === 'string') {
654
+ const fn = this.resolveMiddleware(mw, appName);
655
+ if (fn) middlewareFns.push(fn);
656
+ }
657
+ }
658
+ }
659
+
660
+ dispatch.get(key).set(appName, { route, middlewareFns });
661
+ }
662
+ }
663
+
664
+ // 유니크 path별 하나의 Bridge 핸들러 등록
665
+ for (const [key, appMap] of dispatch) {
666
+ const [method, routePath] = [key.split(':')[0].toLowerCase(), key.slice(key.indexOf(':') + 1)];
667
+ if (typeof coreApp[method] !== 'function') continue;
668
+
669
+ const bridgeHandler = this._createDispatchHandler(appMap);
670
+ coreApp[method](routePath, bridgeHandler);
671
+ }
672
+ }
673
+
674
+ /**
675
+ * WsHandler → Bridge WS 이벤트 연결 (모든 앱의 핸들러)
676
+ * @private
677
+ */
678
+ _registerWsHandlers(coreApp) {
679
+ if (!coreApp.ws) return;
680
+
681
+ for (const [, appEntry] of this._appRegistry) {
682
+ if (!appEntry.wsHandlers || appEntry.wsHandlers.size === 0) continue;
683
+
684
+ for (const [namespace, HandlerClass] of appEntry.wsHandlers) {
685
+ const eventMap = HandlerClass.buildEventMap();
686
+ const wsNs = coreApp.ws(namespace);
687
+
688
+ // 싱글톤 인스턴스
689
+ const inst = new HandlerClass(this);
690
+
691
+ wsNs.on('connect', (socket) => {
692
+ this.logger.debug(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
693
+ inst.onConnect(socket);
694
+ });
695
+
696
+ wsNs.on('message', (socket, rawMessage) => {
697
+ let parsed;
698
+ try { parsed = typeof rawMessage === 'string' ? JSON.parse(rawMessage) : rawMessage; }
699
+ catch { parsed = { type: 'message', data: rawMessage }; }
700
+ const eventType = parsed.type || 'message';
701
+ const eventData = parsed.data || parsed;
702
+ this.logger.debug(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
703
+ const entry = eventMap.get(eventType);
704
+ if (entry) {
705
+ const result = entry.handler.call(inst, socket, eventData);
706
+ if (result && typeof result.then === 'function') {
707
+ result.then(r => { if (r) socket.send(JSON.stringify(r)); })
708
+ .catch(e => console.error(`[WS] error: ${eventType}`, e));
709
+ } else if (result) {
710
+ socket.send(JSON.stringify(result));
711
+ }
712
+ } else {
713
+ inst.onEvent(socket, eventType, eventData);
714
+ }
715
+ });
716
+
717
+ wsNs.on('disconnect', (socket) => {
718
+ this.logger.debug(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
719
+ inst.onDisconnect(socket);
720
+ });
721
+
722
+ this.logger.info(`[WS] 핸들러 등록: ${namespace} (events: ${[...eventMap.keys()].join(', ')})`);
723
+ }
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Host 기반 디스패치 핸들러 생성
729
+ *
730
+ * 동일 path에 대해 Host 헤더로 앱을 결정하고
731
+ * 해당 앱의 route + middleware 체인을 실행.
732
+ *
733
+ * @param {Map<string, {route, middlewareFns}>} appMap - appName → {route, middlewareFns}
734
+ * @returns {Function} Bridge 핸들러
735
+ * @private
736
+ */
737
+ _createDispatchHandler(appMap) {
738
+ return (req, res) => {
739
+ // Host 헤더에서 앱 결정 (reverse proxy: X-Forwarded-Host 우선)
740
+ const host = req.headers?.host || req.headers?.Host || '';
741
+ const appName = this._resolveApp(host, req.headers);
742
+
743
+ // 해당 앱의 route+middleware 찾기 (없으면 기본 앱)
744
+ let entry = appMap.get(appName);
745
+ if (!entry) {
746
+ // 매칭 앱에 이 경로가 없으면 기본 앱 시도
747
+ const defaultApp = this._getDefaultAppName();
748
+ entry = appMap.get(defaultApp);
749
+ }
750
+ if (!entry) {
751
+ // 어떤 앱에도 없으면 첫 번째 앱
752
+ entry = appMap.values().next().value;
753
+ }
754
+
755
+ const { route, middlewareFns } = entry;
756
+
757
+ // rawReq → Context
758
+ const ctx = new Context({
759
+ method: req.method,
760
+ url: req.url,
761
+ path: req.path,
762
+ query: req.query,
763
+ params: req.params,
764
+ headers: req.headers,
765
+ body: req.body || req.json,
766
+ remoteIp: req.ip,
767
+ handlerId: req.handlerId,
768
+ requestId: req.requestId,
769
+ sessionId: req.sessionId,
770
+ session: req.session?._data || {},
771
+ files: req.files || null,
772
+ formFields: req.formFields || null,
773
+ }, this);
774
+
775
+ ctx.appName = appName;
776
+
777
+ // async 체인 실행 → sendAsyncResponse
778
+ const promise = this._executeChain(middlewareFns, route, ctx);
779
+
780
+ promise.then(() => {
781
+ const response = ctx.toResponse();
782
+ const contentType = response.headers?.['Content-Type'] || 'application/json';
783
+ const headerParts = [];
784
+ if (response.headers) {
785
+ for (const [k, v] of Object.entries(response.headers)) {
786
+ if (k !== 'Content-Type') headerParts.push(`${k}: ${v}`);
787
+ }
788
+ }
789
+ const extraHeaders = headerParts.length > 0 ? headerParts.join('\r\n') + '\r\n' : '';
790
+ try {
791
+ this._coreApp._bridge.sendAsyncResponse(
792
+ req.requestId, response.status,
793
+ response.body || '', contentType, extraHeaders,
794
+ );
795
+ } catch (e) {
796
+ console.error('[fuzionx] sendAsyncResponse failed:', e.message);
797
+ }
798
+ }).catch((err) => {
799
+ console.error('[fuzionx] Handler error:', err.message || err);
800
+ try {
801
+ this._coreApp._bridge.sendAsyncResponse(
802
+ req.requestId, 500,
803
+ JSON.stringify({ error: err.message || 'Internal Server Error' }),
804
+ 'application/json', '',
805
+ );
806
+ } catch (e) {
807
+ console.error('[fuzionx] sendAsyncResponse error failed:', e.message);
808
+ }
809
+ });
810
+
811
+ return { async: true };
812
+ };
813
+ }
814
+
815
+ /**
816
+ * 단일 라우트의 Bridge 핸들러 생성 (앱별)
817
+ * rawReq → Context → middleware → controller → toResponse
818
+ * @deprecated _createDispatchHandler를 사용하세요. 이 메서드는 테스트 호환용으로만 유지됩니다.
819
+ * @param {object} route
820
+ * @param {string} appName - 이 라우트가 속한 앱
821
+ * @private
822
+ */
823
+ _createBridgeHandler(route, appName) {
824
+ // 미들웨어 체인 사전 구성
825
+ const middlewareFns = [];
826
+
827
+ // 글로벌 미들웨어
828
+ middlewareFns.push(...this._globalMiddleware);
829
+
830
+ // 앱별 라우트 미들웨어 (이름 → 인스턴스 변환)
831
+ if (route.middleware?.length) {
832
+ for (const mwName of route.middleware) {
833
+ const fn = this.resolveMiddleware(mwName, appName);
834
+ if (fn) middlewareFns.push(fn);
835
+ }
836
+ }
837
+
838
+ return (req, res) => {
839
+ // rawReq → Context 변환
840
+ const ctx = new Context({
841
+ method: req.method,
842
+ url: req.url,
843
+ path: req.path,
844
+ query: req.query,
845
+ params: req.params,
846
+ headers: req.headers,
847
+ body: req.body || req.json,
848
+ remoteIp: req.ip,
849
+ handlerId: req.handlerId,
850
+ requestId: req.requestId,
851
+ sessionId: req.sessionId,
852
+ session: req.session?._data || {},
853
+ files: req.files || null,
854
+ formFields: req.formFields || null,
855
+ }, this);
856
+
857
+ // 앱 이름 주입
858
+ ctx.appName = appName;
859
+
860
+ // Framework → async 체인 실행 후 직접 sendAsyncResponse
861
+ const promise = this._executeChain(middlewareFns, route, ctx);
862
+
863
+ promise.then(() => {
864
+ const response = ctx.toResponse();
865
+ const contentType = response.headers?.['Content-Type'] || 'application/json';
866
+ const headerParts = [];
867
+ if (response.headers) {
868
+ for (const [k, v] of Object.entries(response.headers)) {
869
+ if (k !== 'Content-Type') headerParts.push(`${k}: ${v}`);
870
+ }
871
+ }
872
+ const extraHeaders = headerParts.length > 0 ? headerParts.join('\r\n') + '\r\n' : '';
873
+ try {
874
+ this._coreApp._bridge.sendAsyncResponse(
875
+ req.requestId, response.status,
876
+ response.body || '', contentType, extraHeaders,
877
+ );
878
+ } catch (e) {
879
+ console.error('[fuzionx] sendAsyncResponse failed:', e.message);
880
+ }
881
+ }).catch((err) => {
882
+ console.error('[fuzionx] Handler error:', err.message || err);
883
+ try {
884
+ this._coreApp._bridge.sendAsyncResponse(
885
+ req.requestId, 500,
886
+ JSON.stringify({ error: err.message || 'Internal Server Error' }),
887
+ 'application/json', '',
888
+ );
889
+ } catch (e) {
890
+ console.error('[fuzionx] sendAsyncResponse error failed:', e.message);
891
+ }
892
+ });
893
+
894
+ return { async: true };
895
+ };
896
+ }
897
+
898
+ /**
899
+ * 미들웨어 체인 + 핸들러 실행
900
+ * @private
901
+ */
902
+ async _executeChain(middlewareFns, route, ctx) {
903
+ let idx = 0;
904
+
905
+ const next = async () => {
906
+ if (ctx._sent) return;
907
+ if (idx < middlewareFns.length) {
908
+ const fn = middlewareFns[idx++];
909
+ await fn(ctx, next);
910
+ return;
911
+ }
912
+
913
+ // 미들웨어 완료 → 핸들러 실행
914
+ await this._executeHandler(route.handler, ctx);
915
+ };
916
+
917
+ try {
918
+ await next();
919
+ } catch (err) {
920
+ // 커스텀 에러 핸들러 체크 (useError로 등록)
921
+ let handled = false;
922
+ for (const { path: ePath, handler } of this._errorHandlers) {
923
+ if (!ePath || ctx.path?.startsWith(ePath)) {
924
+ try {
925
+ await handler(err, ctx);
926
+ handled = true;
927
+ break;
928
+ } catch { /* 커스텀 핸들러 실패 시 기본으로 */ }
929
+ }
930
+ }
931
+ if (!handled) this._errorHandler.handle(err, ctx);
932
+ }
933
+ // 응답은 _createBridgeHandler의 .then()에서 sendAsyncResponse로 전송
934
+ }
935
+
936
+ /**
937
+ * 핸들러 실행 (싱글톤 컨트롤러 or 일반 함수)
938
+ * @private
939
+ */
940
+ async _executeHandler(handler, ctx) {
941
+ if (handler?.__handler__) {
942
+ // 앱별 싱글톤 컨트롤러 메서드 참조
943
+ const appEntry = this._appRegistry.get(ctx.appName);
944
+ const instance = appEntry?.controllerCache?.get(handler.controller);
945
+ if (instance && typeof instance[handler.method] === 'function') {
946
+ await instance[handler.method](ctx);
947
+ return;
948
+ }
949
+ }
950
+
951
+ // 일반 함수 핸들러
952
+ if (typeof handler === 'function') {
953
+ await handler(ctx);
954
+ }
955
+ }
956
+
957
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
958
+ // Graceful Shutdown
959
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
960
+
961
+ _setupGracefulShutdown() {
962
+ let shutdownCalled = false;
963
+
964
+ const shutdown = async (signal) => {
965
+ if (shutdownCalled) return;
966
+ shutdownCalled = true;
967
+
968
+ this.logger.info(`[fuzionx] ${signal} received — shutting down`);
969
+ await this.emit('shutting-down', { signal });
970
+
971
+ // 1. 스케줄러 정지 (04-bootstrap-lifecycle.md)
972
+ if (this._scheduler) this._scheduler.stop();
973
+
974
+ // 2. 큐 drain — 처리 중 Task 완료 대기 (doc 04 순서 3)
975
+ if (this._queue && this._queue.pending > 0) {
976
+ this.logger.info(`[fuzionx] Draining queue (${this._queue.pending} pending)...`);
977
+ const drainTimeout = this.config.get('app.shutdown_timeout', 30000);
978
+ const start = Date.now();
979
+ while (this._queue.pending > 0 && (Date.now() - start) < drainTimeout) {
980
+ await new Promise(r => setTimeout(r, 100));
981
+ }
982
+ }
983
+
984
+ // 3. DB 연결 해제 (ConnectionManager)
985
+ if (this._connectionManager) {
986
+ try { await this._connectionManager.closeAll(); } catch {}
987
+ }
988
+
989
+ // 3-1. Worker 종료
990
+ if (this._workerPool) {
991
+ try { await this._workerPool.terminate(); } catch {}
992
+ }
993
+
994
+ // 4. Bridge 서버 정지
995
+ if (this._coreApp?._bridge) {
996
+ try { this._coreApp._bridge.stopFusionServer(); } catch {}
997
+ }
998
+
999
+ await this.emit('shutdown');
1000
+ setTimeout(() => process.exit(0), 2000);
1001
+ };
1002
+
1003
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
1004
+ process.once('SIGINT', () => shutdown('SIGINT'));
1005
+ }
1006
+ }