@fuzionx/framework 0.1.2

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 (45) hide show
  1. package/bin/fx.js +12 -0
  2. package/index.js +64 -0
  3. package/lib/core/AppError.js +46 -0
  4. package/lib/core/Application.js +553 -0
  5. package/lib/core/AutoLoader.js +162 -0
  6. package/lib/core/Base.js +64 -0
  7. package/lib/core/Config.js +122 -0
  8. package/lib/core/Context.js +429 -0
  9. package/lib/database/ConnectionManager.js +192 -0
  10. package/lib/database/MariaModel.js +29 -0
  11. package/lib/database/Model.js +247 -0
  12. package/lib/database/ModelRegistry.js +72 -0
  13. package/lib/database/MongoModel.js +232 -0
  14. package/lib/database/Pagination.js +37 -0
  15. package/lib/database/PostgreModel.js +29 -0
  16. package/lib/database/QueryBuilder.js +172 -0
  17. package/lib/database/SQLiteModel.js +27 -0
  18. package/lib/database/SqlModel.js +252 -0
  19. package/lib/database/SqlQueryBuilder.js +309 -0
  20. package/lib/helpers/CryptoHelper.js +48 -0
  21. package/lib/helpers/FileHelper.js +61 -0
  22. package/lib/helpers/HashHelper.js +39 -0
  23. package/lib/helpers/I18nHelper.js +170 -0
  24. package/lib/helpers/Logger.js +105 -0
  25. package/lib/helpers/MediaHelper.js +38 -0
  26. package/lib/http/Controller.js +34 -0
  27. package/lib/http/ErrorHandler.js +135 -0
  28. package/lib/http/Middleware.js +43 -0
  29. package/lib/http/Router.js +109 -0
  30. package/lib/http/Validation.js +124 -0
  31. package/lib/middleware/index.js +286 -0
  32. package/lib/realtime/RoomManager.js +85 -0
  33. package/lib/realtime/WsHandler.js +107 -0
  34. package/lib/schedule/Job.js +34 -0
  35. package/lib/schedule/Queue.js +90 -0
  36. package/lib/schedule/Scheduler.js +161 -0
  37. package/lib/schedule/Task.js +39 -0
  38. package/lib/schedule/WorkerPool.js +225 -0
  39. package/lib/services/EventBus.js +94 -0
  40. package/lib/services/Service.js +261 -0
  41. package/lib/services/Storage.js +112 -0
  42. package/lib/view/OpenAPI.js +231 -0
  43. package/lib/view/View.js +72 -0
  44. package/package.json +52 -0
  45. package/testing/index.js +232 -0
package/bin/fx.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fx CLI — @fuzionx/framework 내장 명령줄 도구.
4
+ *
5
+ * @see docs/framework/16-cli.md
6
+ */
7
+ import { run } from '../cli/index.js';
8
+
9
+ run(process.argv.slice(2)).catch((err) => {
10
+ console.error(err.message || err);
11
+ process.exit(1);
12
+ });
package/index.js ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @fuzionx/framework
3
+ *
4
+ * Full-stack MVC framework built on @fuzionx/core.
5
+ */
6
+
7
+ // ── Core ──
8
+ export { default as AppError, ServiceError, ValidationError } from './lib/core/AppError.js';
9
+ export { default as Config } from './lib/core/Config.js';
10
+ export { default as Base } from './lib/core/Base.js';
11
+ export { default as Application } from './lib/core/Application.js';
12
+ export { default as Context } from './lib/core/Context.js';
13
+ export { default as AutoLoader } from './lib/core/AutoLoader.js';
14
+
15
+ // ── HTTP ──
16
+ export { default as Router, RouteGroup } from './lib/http/Router.js';
17
+ export { default as Controller } from './lib/http/Controller.js';
18
+ export { default as Middleware, runMiddlewareChain } from './lib/http/Middleware.js';
19
+ export { default as ErrorHandler } from './lib/http/ErrorHandler.js';
20
+ export { default as Validation, parseRules } from './lib/http/Validation.js';
21
+
22
+ // ── Database ──
23
+ export { default as Model } from './lib/database/Model.js';
24
+ export { default as SqlModel } from './lib/database/SqlModel.js';
25
+ export { default as SQLiteModel } from './lib/database/SQLiteModel.js';
26
+ export { default as MariaModel } from './lib/database/MariaModel.js';
27
+ export { default as PostgreModel } from './lib/database/PostgreModel.js';
28
+ export { default as MongoModel } from './lib/database/MongoModel.js';
29
+ export { default as ModelRegistry } from './lib/database/ModelRegistry.js';
30
+ export { default as QueryBuilder } from './lib/database/QueryBuilder.js';
31
+ export { default as SqlQueryBuilder } from './lib/database/SqlQueryBuilder.js';
32
+ export { default as ConnectionManager } from './lib/database/ConnectionManager.js';
33
+ export { default as Pagination } from './lib/database/Pagination.js';
34
+
35
+ // ── Services ──
36
+ export { default as Service } from './lib/services/Service.js';
37
+ export { default as EventBus } from './lib/services/EventBus.js';
38
+ export { default as Storage } from './lib/services/Storage.js';
39
+
40
+ // ── Realtime ──
41
+ export { default as WsHandler, EventBuilder } from './lib/realtime/WsHandler.js';
42
+ export { default as RoomManager } from './lib/realtime/RoomManager.js';
43
+
44
+ // ── Schedule ──
45
+ export { default as Scheduler } from './lib/schedule/Scheduler.js';
46
+ export { default as Queue } from './lib/schedule/Queue.js';
47
+ export { default as Job } from './lib/schedule/Job.js';
48
+ export { default as Task } from './lib/schedule/Task.js';
49
+ export { default as WorkerPool } from './lib/schedule/WorkerPool.js';
50
+
51
+ // ── Helpers (Bridge N-API) ──
52
+ export { default as Logger } from './lib/helpers/Logger.js';
53
+ export { default as I18nHelper } from './lib/helpers/I18nHelper.js';
54
+ export { default as CryptoHelper } from './lib/helpers/CryptoHelper.js';
55
+ export { default as HashHelper } from './lib/helpers/HashHelper.js';
56
+ export { default as MediaHelper } from './lib/helpers/MediaHelper.js';
57
+ export { default as FileHelper } from './lib/helpers/FileHelper.js';
58
+
59
+ // ── View ──
60
+ export { default as View } from './lib/view/View.js';
61
+ export { default as OpenAPI } from './lib/view/OpenAPI.js';
62
+
63
+ // ── Built-in Middleware ──
64
+ export { bodyParser, cors, auth, apiAuth, csrf, session, theme } from './lib/middleware/index.js';
@@ -0,0 +1,46 @@
1
+ /**
2
+ * AppError — 프레임워크 에러 타입
3
+ *
4
+ * HTTP 상태 코드 + 추가 데이터를 포함하는 에러.
5
+ * ServiceError, ValidationError 등의 기반 클래스.
6
+ *
7
+ * @see docs/framework/08-error-handling.md
8
+ */
9
+ export default class AppError extends Error {
10
+ /**
11
+ * @param {string} message - 에러 메시지
12
+ * @param {number} [status=500] - HTTP 상태 코드
13
+ * @param {object|null} [data=null] - 추가 데이터 (validation errors 등)
14
+ */
15
+ constructor(message, status = 500, data = null) {
16
+ super(message);
17
+ this.name = 'AppError';
18
+ this.status = status;
19
+ this.data = data;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * ServiceError — 비즈니스 로직 에러 (400/403/404)
25
+ */
26
+ export class ServiceError extends AppError {
27
+ constructor(message, status = 400, data = null) {
28
+ super(message, status, data);
29
+ this.name = 'ServiceError';
30
+ }
31
+ }
32
+
33
+ /**
34
+ * ValidationError — 입력 검증 실패 (422)
35
+ */
36
+ export class ValidationError extends AppError {
37
+ /**
38
+ * @param {string} message - 에러 메시지
39
+ * @param {object} fields - 필드별 에러 메시지 { email: '필수 입력', name: '2자 이상' }
40
+ */
41
+ constructor(message = 'Validation failed', fields = {}) {
42
+ super(message, 422, { fields });
43
+ this.name = 'ValidationError';
44
+ this.fields = fields;
45
+ }
46
+ }
@@ -0,0 +1,553 @@
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 Config from './Config.js';
12
+ import Context from './Context.js';
13
+ import Router from '../http/Router.js';
14
+ import ModelRegistry from '../database/ModelRegistry.js';
15
+ import ErrorHandler from '../http/ErrorHandler.js';
16
+ import AutoLoader from './AutoLoader.js';
17
+ import Logger from '../helpers/Logger.js';
18
+ import I18nHelper from '../helpers/I18nHelper.js';
19
+ import CryptoHelper from '../helpers/CryptoHelper.js';
20
+ import HashHelper from '../helpers/HashHelper.js';
21
+ import MediaHelper from '../helpers/MediaHelper.js';
22
+ import FileHelper from '../helpers/FileHelper.js';
23
+ import View from '../view/View.js';
24
+ import Scheduler from '../schedule/Scheduler.js';
25
+ import Queue from '../schedule/Queue.js';
26
+ import Storage from '../services/Storage.js';
27
+ import ConnectionManager from '../database/ConnectionManager.js';
28
+ import SqlModel from '../database/SqlModel.js';
29
+ import MongoModel from '../database/MongoModel.js';
30
+ import WorkerPool from '../schedule/WorkerPool.js';
31
+
32
+ // Bridge: lazy-load (테스트 환경에서 native 바인딩 없을 수 있음)
33
+ let FuzionXApp;
34
+ try {
35
+ const core = await import('@fuzionx/core');
36
+ FuzionXApp = core.FuzionXApp || core.RuxyApp;
37
+ } catch {}
38
+
39
+ export default class Application {
40
+ /**
41
+ * @param {object} [opts]
42
+ * @param {object} [opts.config] - 이미 파싱된 설정 객체 또는 Config 인스턴스
43
+ * @param {string} [opts.configPath] - YAML 설정 파일 경로
44
+ * @param {string} [opts.baseDir='.'] - 프로젝트 루트 경로
45
+ * @param {object} [opts.bridge] - @fuzionx/core bridge 인스턴스 (선택)
46
+ */
47
+ constructor(opts = {}) {
48
+ // Config
49
+ if (opts.config instanceof Config) {
50
+ this.config = opts.config;
51
+ } else {
52
+ this.config = new Config(opts.config || {});
53
+ }
54
+
55
+ // 프로젝트 루트 (반드시 절대경로 — ESM import 호환)
56
+ this.baseDir = path.resolve(opts.baseDir || process.cwd());
57
+ this.configPath = opts.configPath || null;
58
+
59
+ // Bridge 참조 (listen() 시 FuzionXApp 생성)
60
+ this._bridge = null;
61
+ this._coreApp = null;
62
+
63
+ // DI 컨테이너
64
+ this._services = new Map();
65
+ this._factories = new Map();
66
+
67
+ // 이벤트 핸들러
68
+ this._eventHandlers = new Map();
69
+ this._errorHandlers = [];
70
+
71
+ // 라우터
72
+ this._router = new Router();
73
+
74
+ // 미들웨어
75
+ this._globalMiddleware = [];
76
+ this._middlewareRegistry = new Map();
77
+
78
+ // 프레임워크 컴포넌트
79
+ this.db = new ModelRegistry();
80
+ this.logger = new Logger({
81
+ level: this.config.get('bridge.logging.level', 'info'),
82
+ bridge: this._bridge,
83
+ json: this.config.get('bridge.logging.json', false),
84
+ });
85
+ this.crypto = new CryptoHelper(this._bridge);
86
+ this.hash = new HashHelper(this._bridge);
87
+ this.media = new MediaHelper(this._bridge);
88
+ this.file = new FileHelper(this._bridge);
89
+ this.ws = null;
90
+ this.i18n = new I18nHelper({
91
+ defaultLocale: this.config.get('app.i18n.default_locale', 'ko'),
92
+ fallback: this.config.get('app.i18n.fallback', 'en'),
93
+ dir: this.config.get('app.i18n.dir', './locales'),
94
+ bridge: this._bridge,
95
+ });
96
+ this.storage = null;
97
+ this._scheduler = null;
98
+ this._queue = null;
99
+ this._view = null;
100
+ this._wsHandlers = new Map();
101
+
102
+ // DB 연결 매니저
103
+ this._connectionManager = new ConnectionManager();
104
+
105
+ // Worker 매니저
106
+ this._workerPool = new WorkerPool(this);
107
+
108
+ /** @type {WorkerPool} public worker 접근 */
109
+ this.worker = this._workerPool;
110
+
111
+ // 에러 핸들러
112
+ this._errorHandler = new ErrorHandler({
113
+ isDev: this.config.get('app.environment', 'development') === 'development',
114
+ logger: (...args) => this.logger.error(...args),
115
+ });
116
+
117
+ // 라이프사이클 상태
118
+ this._booted = false;
119
+ }
120
+
121
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
122
+ // DI — 서비스 컨테이너
123
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
124
+
125
+ register(name, factory) {
126
+ this._factories.set(name, factory);
127
+ }
128
+
129
+ make(name) {
130
+ if (this._services.has(name)) return this._services.get(name);
131
+ const factory = this._factories.get(name);
132
+ if (!factory) throw new Error(`Service '${name}' is not registered`);
133
+ const instance = factory(this);
134
+ this._services.set(name, instance);
135
+ return instance;
136
+ }
137
+
138
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
139
+ // 라우터 접근
140
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
141
+
142
+ /** 라우터 인스턴스 */
143
+ get router() { return this._router; }
144
+
145
+ /**
146
+ * 라우트 파일 로드
147
+ * @param {Function} routeCallback - (r: RouteGroup) => void
148
+ * @see docs/framework/01-routing-controllers.md
149
+ */
150
+ routes(routeCallback) {
151
+ this._router.load(routeCallback);
152
+ }
153
+
154
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
155
+ // 미들웨어
156
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
157
+
158
+ /**
159
+ * 글로벌 미들웨어 등록
160
+ * @param {Function} fn - (ctx, next) => void
161
+ */
162
+ use(fn) {
163
+ this._globalMiddleware.push(fn);
164
+ return this;
165
+ }
166
+
167
+ /**
168
+ * 이름으로 미들웨어 인스턴스 조회
169
+ * @param {string} name - 'auth', 'csrf' 등
170
+ * @returns {Function} - (ctx, next) => void
171
+ */
172
+ resolveMiddleware(name) {
173
+ // 캐시 히트 → zero-allocation (M-1)
174
+ if (this._mwFnCache?.has(name)) return this._mwFnCache.get(name);
175
+
176
+ // 파라미터화: 'role:admin' → name='role', params=['admin']
177
+ const [mwName, ...params] = name.split(':');
178
+ const MwClass = this._middlewareRegistry.get(mwName);
179
+ if (!MwClass) return null;
180
+
181
+ const instance = new MwClass(this);
182
+ const fn = (ctx, next) => instance.handle(ctx, next, ...params);
183
+
184
+ // 캐시 저장
185
+ if (!this._mwFnCache) this._mwFnCache = new Map();
186
+ this._mwFnCache.set(name, fn);
187
+ return fn;
188
+ }
189
+
190
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
191
+ // 이벤트 시스템
192
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
193
+
194
+ on(event, handler) {
195
+ if (!this._eventHandlers.has(event)) {
196
+ this._eventHandlers.set(event, []);
197
+ }
198
+ this._eventHandlers.get(event).push(handler);
199
+ }
200
+
201
+ async emit(event, data, opts) {
202
+ const handlers = this._eventHandlers.get(event);
203
+ if (!handlers) return;
204
+ const meta = { remote: false, server: this.config.get('app.name', 'fuzionx'), timestamp: Date.now() };
205
+ for (const handler of handlers) {
206
+ try { await handler(data, meta); } catch (err) {
207
+ this.logger.error(`Event '${event}' handler error:`, err);
208
+ }
209
+ }
210
+ }
211
+
212
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
213
+ // 에러 핸들러
214
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
215
+
216
+ useError(pathOrHandler, handler) {
217
+ if (typeof pathOrHandler === 'function') {
218
+ this._errorHandlers.push({ path: null, handler: pathOrHandler });
219
+ } else {
220
+ this._errorHandlers.push({ path: pathOrHandler, handler });
221
+ }
222
+ }
223
+
224
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
225
+ // 큐 디스패치
226
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
227
+
228
+ dispatch(taskOrName, data, opts) {
229
+ if (this._queue) this._queue.dispatch(taskOrName, data, opts);
230
+ }
231
+
232
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
233
+ // 부트스트랩
234
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
235
+
236
+ async boot() {
237
+ if (this._booted) return;
238
+
239
+ // 1. .env 로드 (17-config.md)
240
+ this.config.loadEnv(this.baseDir);
241
+ // .env 로드 후 캐시 클리어 (새로운 환경변수 반영)
242
+ this.config._cache?.clear();
243
+
244
+ await this.emit('booting');
245
+
246
+ // 6. i18n 로드 (04-bootstrap-lifecycle.md)
247
+ await this.i18n.load();
248
+
249
+ // View 초기화
250
+ this._view = new View({
251
+ viewsPath: path.resolve(this.baseDir, 'views'),
252
+ theme: this.config.get('themes.default', 'default'),
253
+ bridge: this._bridge,
254
+ });
255
+
256
+ // Scheduler / Queue / Storage 초기화 (04-bootstrap-lifecycle.md)
257
+ this._scheduler = new Scheduler(this);
258
+ this._queue = new Queue(this, { driver: this.config.get('queue.driver', 'memory') });
259
+ this.storage = new Storage({
260
+ driver: this.config.get('storage.driver', 'local'),
261
+ basePath: path.resolve(this.baseDir, this.config.get('storage.path', './storage')),
262
+ fileHelper: this.file,
263
+ });
264
+
265
+ // 자동 스캔 (models, services, middleware, events, jobs, ws, routes)
266
+ const loader = new AutoLoader(this, this.baseDir);
267
+ await loader.load();
268
+
269
+ // DB 연결 매니저 초기화 (database 섹션이 있을 때만)
270
+ const dbConfig = this.config.get('database');
271
+ if (dbConfig && dbConfig.connections) {
272
+ this._connectionManager.configure(dbConfig);
273
+ SqlModel.setConnectionManager(this._connectionManager);
274
+ MongoModel.setConnectionManager(this._connectionManager);
275
+ }
276
+
277
+ this._booted = true;
278
+ await this.emit('booted');
279
+ }
280
+
281
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
282
+ // 서버 시작 — Bridge 연결
283
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
284
+
285
+ /**
286
+ * Bridge 서버 시작
287
+ * FuzionXApp과 연동하여 rawReq를 Context로 변환.
288
+ * @param {number} [port]
289
+ * @param {Function} [callback]
290
+ */
291
+ async listen(port, callback) {
292
+ if (typeof port === 'function') { callback = port; port = undefined; }
293
+ port = port || this.config.get('bridge.server.port', 3000);
294
+
295
+ if (!this._booted) await this.boot();
296
+ await this.emit('ready');
297
+
298
+ // Bridge 연동
299
+ if (FuzionXApp) {
300
+ if (!this._coreApp) {
301
+ this._coreApp = new FuzionXApp({
302
+ config: this.configPath
303
+ ? path.resolve(this.baseDir, this.configPath)
304
+ : undefined,
305
+ port,
306
+ });
307
+ }
308
+ this._registerBridgeRoutes(this._coreApp);
309
+ this._coreApp.listen(port, callback);
310
+ // Bridge가 자체 graceful shutdown 처리 → Framework 중복 등록 불필요
311
+ } else {
312
+ // Bridge 미설치 (테스트 환경)
313
+ this._initControllers();
314
+ if (callback) callback();
315
+ this._setupGracefulShutdown();
316
+ }
317
+
318
+ await this.emit('listening');
319
+ return this;
320
+ }
321
+
322
+ /**
323
+ * 싱글톤 컨트롤러 초기화
324
+ * @private
325
+ */
326
+ _initControllers() {
327
+ this._controllerCache = new Map();
328
+
329
+ for (const route of this._router.getRoutes()) {
330
+ const handler = route.handler;
331
+ if (handler?.__handler__ && handler.controller) {
332
+ const CtrlClass = handler.controller;
333
+ if (!this._controllerCache.has(CtrlClass)) {
334
+ this._controllerCache.set(CtrlClass, new CtrlClass(this));
335
+ }
336
+ }
337
+ }
338
+ }
339
+
340
+ /**
341
+ * 프레임워크 라우트 → Bridge FuzionXApp 라우트 변환
342
+ * @private
343
+ */
344
+ _registerBridgeRoutes(coreApp) {
345
+ this._initControllers();
346
+ const routes = this._router.getRoutes();
347
+
348
+ for (const route of routes) {
349
+ const bridgeHandler = this._createBridgeHandler(route);
350
+ const method = route.method.toLowerCase();
351
+
352
+ if (typeof coreApp[method] === 'function') {
353
+ coreApp[method](route.path, bridgeHandler);
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * 단일 라우트의 Bridge 핸들러 생성
360
+ * rawReq → Context → middleware → controller → toResponse
361
+ * @private
362
+ */
363
+ _createBridgeHandler(route) {
364
+ // 미들웨어 체인 사전 구성
365
+ const middlewareFns = [];
366
+
367
+ // 글로벌 미들웨어
368
+ middlewareFns.push(...this._globalMiddleware);
369
+
370
+ // 라우트 미들웨어 (이름 → 인스턴스 변환)
371
+ if (route.middleware?.length) {
372
+ for (const mwName of route.middleware) {
373
+ const fn = this.resolveMiddleware(mwName);
374
+ if (fn) middlewareFns.push(fn);
375
+ }
376
+ }
377
+
378
+ return (req, res) => {
379
+ // rawReq → Context 변환
380
+ const ctx = new Context({
381
+ method: req.method,
382
+ url: req.url,
383
+ path: req.path,
384
+ query: req.query,
385
+ params: req.params,
386
+ headers: req.headers,
387
+ body: req.body || req.json,
388
+ remoteIp: req.ip,
389
+ handlerId: req.handlerId,
390
+ requestId: req.requestId,
391
+ sessionId: req.sessionId,
392
+ session: req.session?._data || {},
393
+ files: req.files || null,
394
+ formFields: req.formFields || null,
395
+ }, this);
396
+
397
+ // Framework → async 체인 실행 후 직접 sendAsyncResponse
398
+ const promise = this._executeChain(middlewareFns, route, ctx, res);
399
+
400
+ promise.then(() => {
401
+ const response = ctx.toResponse();
402
+ const contentType = response.headers?.['Content-Type'] || 'application/json';
403
+ const headerParts = [];
404
+ if (response.headers) {
405
+ for (const [k, v] of Object.entries(response.headers)) {
406
+ if (k !== 'Content-Type') headerParts.push(`${k}: ${v}`);
407
+ }
408
+ }
409
+ const extraHeaders = headerParts.length > 0 ? headerParts.join('\r\n') + '\r\n' : '';
410
+ try {
411
+ this._coreApp._bridge.sendAsyncResponse(
412
+ req.requestId, response.status,
413
+ response.body || '', contentType, extraHeaders,
414
+ );
415
+ } catch (e) {
416
+ console.error('[fuzionx] sendAsyncResponse failed:', e.message);
417
+ }
418
+ }).catch((err) => {
419
+ console.error('[fuzionx] Handler error:', err.message || err);
420
+ try {
421
+ this._coreApp._bridge.sendAsyncResponse(
422
+ req.requestId, 500,
423
+ JSON.stringify({ error: err.message || 'Internal Server Error' }),
424
+ 'application/json', '',
425
+ );
426
+ } catch (e) {
427
+ console.error('[fuzionx] sendAsyncResponse error failed:', e.message);
428
+ }
429
+ });
430
+
431
+ return { async: true };
432
+ };
433
+ }
434
+
435
+ /**
436
+ * 미들웨어 체인 + 핸들러 실행
437
+ * @private
438
+ */
439
+ async _executeChain(middlewareFns, route, ctx, res) {
440
+ let idx = 0;
441
+
442
+ const next = async () => {
443
+ if (ctx._sent) return;
444
+ if (idx < middlewareFns.length) {
445
+ const fn = middlewareFns[idx++];
446
+ await fn(ctx, next);
447
+ return;
448
+ }
449
+
450
+ // 미들웨어 완료 → 핸들러 실행
451
+ await this._executeHandler(route.handler, ctx);
452
+ };
453
+
454
+ try {
455
+ await next();
456
+ } catch (err) {
457
+ // 커스텀 에러 핸들러 체크 (useError로 등록)
458
+ let handled = false;
459
+ for (const { path: ePath, handler } of this._errorHandlers) {
460
+ if (!ePath || ctx.path?.startsWith(ePath)) {
461
+ try {
462
+ await handler(err, ctx);
463
+ handled = true;
464
+ break;
465
+ } catch { /* 커스텀 핸들러 실패 시 기본으로 */ }
466
+ }
467
+ }
468
+ if (!handled) this._errorHandler.handle(err, ctx);
469
+ }
470
+
471
+ // Context → Bridge res 변환 (이중 응답 방지 — Core res는 _sent 사용)
472
+ if (!res._sent) {
473
+ const response = ctx.toResponse();
474
+ res.status(response.status);
475
+ if (response.headers) {
476
+ for (const [k, v] of Object.entries(response.headers)) {
477
+ res.header(k, v);
478
+ }
479
+ }
480
+ res.send(response.body);
481
+ }
482
+ }
483
+
484
+ /**
485
+ * 핸들러 실행 (싱글톤 컨트롤러 or 일반 함수)
486
+ * @private
487
+ */
488
+ async _executeHandler(handler, ctx) {
489
+ if (handler?.__handler__) {
490
+ // 싱글톤 컨트롤러 메서드 참조
491
+ const instance = this._controllerCache.get(handler.controller);
492
+ if (instance && typeof instance[handler.method] === 'function') {
493
+ await instance[handler.method](ctx);
494
+ return;
495
+ }
496
+ }
497
+
498
+ // 일반 함수 핸들러
499
+ if (typeof handler === 'function') {
500
+ await handler(ctx);
501
+ }
502
+ }
503
+
504
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
505
+ // Graceful Shutdown
506
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
507
+
508
+ _setupGracefulShutdown() {
509
+ let shutdownCalled = false;
510
+
511
+ const shutdown = async (signal) => {
512
+ if (shutdownCalled) return;
513
+ shutdownCalled = true;
514
+
515
+ this.logger.info(`[fuzionx] ${signal} received — shutting down`);
516
+ await this.emit('shutting-down', { signal });
517
+
518
+ // 1. 스케줄러 정지 (04-bootstrap-lifecycle.md)
519
+ if (this._scheduler) this._scheduler.stop();
520
+
521
+ // 2. 큐 drain — 처리 중 Task 완료 대기 (doc 04 순서 3)
522
+ if (this._queue && this._queue.pending > 0) {
523
+ this.logger.info(`[fuzionx] Draining queue (${this._queue.pending} pending)...`);
524
+ const drainTimeout = this.config.get('app.shutdown_timeout', 30000);
525
+ const start = Date.now();
526
+ while (this._queue.pending > 0 && (Date.now() - start) < drainTimeout) {
527
+ await new Promise(r => setTimeout(r, 100));
528
+ }
529
+ }
530
+
531
+ // 3. DB 연결 해제 (ConnectionManager)
532
+ if (this._connectionManager) {
533
+ try { await this._connectionManager.closeAll(); } catch {}
534
+ }
535
+
536
+ // 3-1. Worker 종료
537
+ if (this._workerPool) {
538
+ try { await this._workerPool.terminate(); } catch {}
539
+ }
540
+
541
+ // 4. Bridge 서버 정지
542
+ if (this._coreApp?._bridge) {
543
+ try { this._coreApp._bridge.stopFusionServer(); } catch {}
544
+ }
545
+
546
+ await this.emit('shutdown');
547
+ setTimeout(() => process.exit(0), 2000);
548
+ };
549
+
550
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
551
+ process.once('SIGINT', () => shutdown('SIGINT'));
552
+ }
553
+ }