@fuzionx/core 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.
package/lib/app.js CHANGED
@@ -1,433 +1,433 @@
1
- import { createRequire } from 'module';
2
- import path from 'path';
3
- import cluster from 'cluster';
4
- import os from 'os';
5
- import { Router } from './router.js';
6
- import { runMiddlewareChain, runErrorChain } from './middleware.js';
7
- import { createReq, createRes } from './context.js';
8
- import { createSession } from './session.js';
9
- import { createI18n } from './i18n.js';
10
- import { createWs } from './ws.js';
11
- import { createCrypto } from './crypto.js';
12
- import { createFile } from './file.js';
13
- import { createHash } from './hash.js';
14
- import { createMedia } from './media.js';
15
- import { createLogger, interceptConsole } from './logger.js';
16
-
17
- const require = createRequire(import.meta.url);
18
-
19
- /**
20
- * fuzionx-bridge native module 로드.
21
- * 순서: @fuzionx/bridge (npm) → fuzionx-bridge (로컬 dev) → .node 파일 직접
22
- */
23
- function loadBridge() {
24
- // 1. npm 설치된 @fuzionx/bridge
25
- try { return require('@fuzionx/bridge'); } catch (_) {}
26
-
27
- // 2. 로컬 개발 환경 (file: 또는 workspace 의존성)
28
- try { return require('fuzionx-bridge'); } catch (_) {}
29
-
30
- throw new Error(
31
- 'fuzionx-bridge native module을 찾을 수 없습니다. ' +
32
- 'npm install @fuzionx/bridge 또는 package.json에 workspace 의존성을 추가하세요.'
33
- );
34
- }
35
-
36
- export class FuzionXApp {
37
- /**
38
- * @param {object} options
39
- * @param {string} options.config - YAML 설정 파일 경로
40
- * @param {number} [options.port=3000] - 서버 포트
41
- */
42
- constructor(options = {}) {
43
- this._bridge = loadBridge();
44
- this._router = new Router(this._bridge);
45
- this._middlewares = [];
46
- this._errorHandlers = [];
47
- this._options = options;
48
- this._booted = false;
49
- this._port = options.port || 3000;
50
-
51
- // 사전 계산된 미들웨어 체인 캐시 (handlerId → handler[])
52
- this._chainCache = null;
53
- this._globalChain = null;
54
- this._errorChain = null;
55
-
56
- // 헬퍼 모듈 — 직통 N-API 호출, 오버헤드 없음
57
- this.crypto = createCrypto(this._bridge);
58
- this.file = createFile(this._bridge);
59
- this.hash = createHash(this._bridge);
60
- this.media = createMedia(this._bridge); // utils-media feature 필요
61
- this._ws = null; // lazy — get ws() 에서 초기화
62
- this.i18n = null; // boot 후 초기화
63
- }
64
-
65
- /** YAML boot — 설정 파일 로드 */
66
- _boot() {
67
- if (this._booted) return;
68
- const configPath = this._options.config;
69
- if (!configPath) {
70
- throw new Error('config 옵션이 필요합니다 (YAML 파일 경로)');
71
- }
72
- this._bridge.bootSystem({ configPath: path.resolve(configPath) });
73
- this._booted = true;
74
- this.i18n = createI18n(this._bridge);
75
-
76
- // 통합 로거 초기화
77
- this.logger = createLogger(this._bridge);
78
-
79
- // console.* 가로채기 (YAML intercept_console 설정에 따름)
80
- const cfg = this._bridge.getConfig();
81
- if (cfg.interceptConsole !== false) {
82
- this._restoreConsole = interceptConsole(this._bridge);
83
- }
84
-
85
- // 앱 로그 파일 초기화 (YAML logging.file.enabled 시)
86
- if (cfg.logFilePath) {
87
- try {
88
- this._bridge.initAppFileLogger(cfg.logFilePath);
89
- } catch (e) {
90
- // 파일 로거 실패해도 서버는 계속 동작
91
- this.logger.warn(`앱 파일 로거 초기화 실패: ${e.message}`);
92
- }
93
- }
94
- }
95
-
96
- /** WebSocket 매니저 (lazy init — listen() 전에도 사용 가능) */
97
- get ws() {
98
- this._boot();
99
- if (!this._ws) {
100
- this._ws = createWs(this._bridge);
101
- }
102
- return this._ws;
103
- }
104
-
105
- /** 서버 설정값 */
106
- get config() {
107
- this._boot();
108
- return this._bridge.getConfig();
109
- }
110
-
111
- /** 앱 커스텀 설정값 */
112
- get appConfig() {
113
- this._boot();
114
- return this._bridge.getAppConfig();
115
- }
116
-
117
- /** ASP (Alloy Stealth Protocol) 설정값 */
118
- get aspConfig() {
119
- this._boot();
120
- return JSON.parse(this._bridge.getAspConfig());
121
- }
122
-
123
- /**
124
- * Tera SSR 파일 렌더링 ({% extends %}, {% block %}, {% include %} 완전 지원)
125
- * @param {string} templateDir - 테마 디렉토리 (views/{theme}/)
126
- * @param {string} templateName - 템플릿 이름 (pages/home.html)
127
- * @param {object} [context={}] - 템플릿 변수
128
- * @param {string} [locale='ko'] - 로케일
129
- * @returns {string} 렌더링된 HTML
130
- */
131
- renderFile(templateDir, templateName, context = {}, locale = 'ko') {
132
- this._boot();
133
- return this._bridge.ssrRenderFile(templateDir, templateName, JSON.stringify(context), locale);
134
- }
135
-
136
- /**
137
- * 경량 템플릿 파일 렌더링 (bridge에서 파일 읽기 + {{key}} 치환)
138
- * @param {string} filePath - 템플릿 파일 경로
139
- * @param {object} [context={}] - 치환 변수
140
- * @returns {string} 렌더링된 HTML
141
- */
142
- render(filePath, context = {}) {
143
- this._boot();
144
- return this._bridge.renderTemplateFile(filePath, JSON.stringify(context));
145
- }
146
-
147
- // ── HTTP Method 라우트 등록 ──
148
-
149
- get(routePath, ...handlers) { this._router.add('GET', routePath, handlers); return this; }
150
- post(routePath, ...handlers) { this._router.add('POST', routePath, handlers); return this; }
151
- put(routePath, ...handlers) { this._router.add('PUT', routePath, handlers); return this; }
152
- delete(routePath, ...handlers) { this._router.add('DELETE', routePath, handlers); return this; }
153
- patch(routePath, ...handlers) { this._router.add('PATCH', routePath, handlers); return this; }
154
-
155
- /**
156
- * 미들웨어 등록
157
- * @param {string|Function} pathOrHandler
158
- * @param {...Function} handlers
159
- */
160
- use(pathOrHandler, ...handlers) {
161
- if (typeof pathOrHandler === 'function') {
162
- const all = [pathOrHandler, ...handlers];
163
- for (const h of all) {
164
- const target = h.length === 4 ? this._errorHandlers : this._middlewares;
165
- target.push({ path: null, handler: h });
166
- }
167
- } else {
168
- for (const h of handlers) {
169
- const target = h.length === 4 ? this._errorHandlers : this._middlewares;
170
- target.push({ path: pathOrHandler, handler: h });
171
- }
172
- }
173
- return this;
174
- }
175
-
176
- /**
177
- * Fusion 서버 시작
178
- * @param {number} [port]
179
- * @param {Function} [callback]
180
- */
181
- listen(port, callback) {
182
- if (typeof port === 'function') { callback = port; port = this._port; }
183
- port = port || this._port;
184
-
185
- this._boot();
186
-
187
- // ── 워커 수 결정 (0=auto/cpus, 1=단일 프로세스) ──
188
- const configWorkers = this._bridge.getWorkerCount();
189
- const numWorkers = configWorkers > 0 ? configWorkers : os.cpus().length;
190
-
191
- // ── Primary: cluster.fork() ──
192
- if (numWorkers > 1 && cluster.isPrimary) {
193
- console.log(`[fuzionx] Primary PID=${process.pid}, spawning ${numWorkers} workers on :${port}`);
194
-
195
- for (let i = 0; i < numWorkers; i++) {
196
- cluster.fork();
197
- }
198
-
199
- let shuttingDown = false;
200
-
201
- cluster.on('exit', (worker, code) => {
202
- console.log(`[fuzionx] Worker ${worker.process.pid} exited (code=${code})`);
203
- if (!shuttingDown) {
204
- console.log('[fuzionx] Restarting worker...');
205
- cluster.fork();
206
- }
207
- });
208
-
209
- // ── Primary Graceful Shutdown ──
210
- const gracefulShutdown = (signal) => {
211
- if (shuttingDown) return;
212
- shuttingDown = true;
213
- console.log(`[fuzionx] ${signal} received — shutting down all workers`);
214
- for (const id in cluster.workers) {
215
- cluster.workers[id].process.kill('SIGTERM');
216
- }
217
- const shutdownTimeout = this._appConfig?.shutdown_timeout || 5000;
218
- setTimeout(() => process.exit(0), shutdownTimeout);
219
- };
220
- process.once('SIGTERM', () => gracefulShutdown('SIGTERM'));
221
- process.once('SIGINT', () => gracefulShutdown('SIGINT'));
222
-
223
- // ── Primary Hot Reload: SIGHUP → 모든 워커에 전파 ──
224
- process.on('SIGHUP', () => {
225
- console.log('[fuzionx] SIGHUP — forwarding to all workers');
226
- for (const id in cluster.workers) {
227
- cluster.workers[id].process.kill('SIGHUP');
228
- }
229
- });
230
-
231
- if (callback) setImmediate(() => callback());
232
- return this;
233
- }
234
-
235
- // ── Worker (또는 단일 프로세스): Fusion 서버 시작 ──
236
- if (cluster.isWorker) {
237
- // 워커 프로세스는 독립적으로 boot 필요
238
- this._boot();
239
- }
240
-
241
- this._router.build();
242
- this._buildMiddlewareCache();
243
- this._bridge.startFusionServer(port, (rawReq) => this._handleRequest(rawReq));
244
-
245
- if (cluster.isWorker) {
246
- console.log(`[fuzionx] Worker PID=${process.pid} on :${port}`);
247
- }
248
-
249
- // ── Worker Graceful Shutdown (SIGTERM/SIGINT) ──
250
- let shutdownCalled = false;
251
- const gracefulShutdown = (signal) => {
252
- if (shutdownCalled) return;
253
- shutdownCalled = true;
254
- console.log(`[fuzionx] ${signal} received — graceful shutdown (PID=${process.pid})`);
255
- this._bridge.stopFusionServer();
256
- const shutdownTimeout = this._appConfig?.shutdown_timeout || 5000;
257
- setTimeout(() => process.exit(0), shutdownTimeout);
258
- };
259
- process.once('SIGTERM', () => gracefulShutdown('SIGTERM'));
260
- process.once('SIGINT', () => gracefulShutdown('SIGINT'));
261
-
262
- // ── Hot Reload (SIGHUP) ──
263
- process.on('SIGHUP', () => {
264
- try {
265
- this._bridge.reloadConfig();
266
- this._bridge.reloadFusionConfig();
267
- console.log(`[fuzionx] SIGHUP — config hot-reload complete (PID=${process.pid})`);
268
- } catch (e) {
269
- console.error(`[fuzionx] SIGHUP reload failed (PID=${process.pid}):`, e.message);
270
- }
271
- });
272
-
273
- // ── Idle Cleanup은 Rust uv_timer에서 자동 실행 (YAML websocket.check_interval/timeout) ──
274
-
275
- // ── Error Recovery ──
276
- process.on('uncaughtException', (err) => {
277
- console.error('[fuzionx] uncaughtException:', err.message, err.stack);
278
- // 불안정 상태 방지: 짧은 유예 후 프로세스 종료
279
- setTimeout(() => process.exit(1), 1000);
280
- });
281
- process.on('unhandledRejection', (reason) => {
282
- console.error('[fuzionx] unhandledRejection:', reason);
283
- });
284
-
285
- if (callback) setImmediate(() => callback());
286
- return this;
287
- }
288
-
289
- /**
290
- * 미들웨어 체인 사전 계산.
291
- * listen() 시점에 라우트별로 적용 가능한 미들웨어 + 핸들러를 합쳐서 캐시.
292
- * _handleRequest()에서 .filter()/.map() 없이 O(1) 조회.
293
- */
294
- _buildMiddlewareCache() {
295
- // 글로벌 미들웨어 (path === null)
296
- const globalMw = [];
297
- // 경로별 미들웨어 (path !== null)
298
- const pathMw = [];
299
-
300
- for (const m of this._middlewares) {
301
- if (m.path === null) {
302
- globalMw.push(m.handler);
303
- } else {
304
- pathMw.push(m);
305
- }
306
- }
307
-
308
- this._globalChain = globalMw;
309
- this._chainCache = new Map();
310
-
311
- // 에러 핸들러도 사전 계산
312
- this._errorChain = this._errorHandlers.map(m => m.handler);
313
-
314
- // 각 라우트별 체인 캐시
315
- for (const route of this._router._routes) {
316
- const chain = [...globalMw];
317
- for (const m of pathMw) {
318
- if (route.path === m.path ||
319
- route.path.startsWith(m.path.endsWith('/') ? m.path : m.path + '/')) {
320
- chain.push(m.handler);
321
- }
322
- }
323
- chain.push(...route.handlers);
324
- this._chainCache.set(route.handlerId, chain);
325
- }
326
-
327
- // 404 fallback 체인 (글로벌 미들웨어 + 404 핸들러)
328
- this._notFoundChain = [...globalMw, (_req, _res) => {
329
- _res.status(404).json({ error: 'Not Found', path: _req.path });
330
- }];
331
- }
332
-
333
- /** 요청 처리 — 사전 계산된 체인으로 O(1) 조회 */
334
- _handleRequest(rawReq) {
335
- // sync 경로: 공유 인스턴스 재사용 (할당 0)
336
- const req = createReq(rawReq, this, true);
337
- const res = createRes(true);
338
-
339
- // O(1) 체인 조회 — .filter()/.map() 없음
340
- const chain = this._chainCache.get(rawReq.handlerId) || this._notFoundChain;
341
-
342
- let result;
343
- try {
344
- result = runMiddlewareChain(chain, req, res);
345
- } catch (err) {
346
- this._handleError(err, req, res);
347
- return res._toFusionResponse();
348
- }
349
-
350
- // Framework 등 외부 핸들러가 직접 { async: true } 반환 시
351
- // Rust에 그대로 전달하여 pending_async 등록
352
- if (result && result.async === true && typeof result.then !== 'function') {
353
- return { async: true };
354
- }
355
-
356
- // 핸들러가 Promise를 리턴하면 — 비동기 경로
357
- // async 핸들러는 응답 전까지 req/res가 유지되어야 하므로 새 인스턴스 생성
358
- if (result && typeof result.then === 'function') {
359
- const asyncReq = createReq(rawReq, this, false);
360
- const asyncRes = createRes(false);
361
- // sync 결과를 asyncRes에 복사 (미들웨어가 이미 설정한 값)
362
- asyncRes._statusCode = res._statusCode;
363
- asyncRes._body = res._body;
364
- asyncRes._sent = res._sent;
365
-
366
- result
367
- .then(() => {
368
- this._sendAsyncResponse(rawReq.requestId, asyncRes);
369
- })
370
- .catch((err) => {
371
- this._handleError(err, asyncReq, asyncRes);
372
- this._sendAsyncResponse(rawReq.requestId, asyncRes);
373
- });
374
-
375
- return { async: true };
376
- }
377
-
378
- return res._toFusionResponse();
379
- }
380
-
381
- /** 에러 핸들링 — 에러 핸들러 체인 or 500 */
382
- _handleError(err, req, res) {
383
- if (this._errorChain && this._errorChain.length > 0) {
384
- try {
385
- runErrorChain(this._errorChain, err, req, res);
386
- } catch (_) {
387
- if (!res._sent) {
388
- res.status(500).json({ error: 'Internal Server Error' });
389
- }
390
- }
391
- } else {
392
- console.error('[fuzionx] Handler error:', err.message || err);
393
- if (!res._sent) {
394
- res.status(500).json({ error: err.message || 'Internal Server Error' });
395
- }
396
- }
397
- }
398
-
399
- /** 비동기 응답 전송 — bridge.sendAsyncResponse 호출 */
400
- _sendAsyncResponse(requestId, res) {
401
- const resp = res._toFusionResponse();
402
- const contentType = resp.headers?.['Content-Type'] || 'application/json';
403
-
404
- // Content-Type 외 추가 헤더 → 배열 join (문자열 반복 연결 제거)
405
- const headerParts = [];
406
- if (resp.headers) {
407
- const keys = Object.keys(resp.headers);
408
- for (let i = 0; i < keys.length; i++) {
409
- const k = keys[i];
410
- if (k !== 'Content-Type') {
411
- headerParts.push(k + ': ' + resp.headers[k]);
412
- }
413
- }
414
- }
415
- const extraHeaders = headerParts.length > 0 ? headerParts.join('\r\n') + '\r\n' : '';
416
-
417
- try {
418
- this._bridge.sendAsyncResponse(
419
- requestId,
420
- resp.status,
421
- resp.body,
422
- contentType,
423
- extraHeaders,
424
- );
425
- } catch (e) {
426
- console.error('[fuzionx] sendAsyncResponse failed:', e.message);
427
- }
428
- }
429
- }
430
-
431
- export function createApp(options) {
432
- return new FuzionXApp(options);
433
- }
1
+ import { createRequire } from 'module';
2
+ import path from 'path';
3
+ import cluster from 'cluster';
4
+ import os from 'os';
5
+ import { Router } from './router.js';
6
+ import { runMiddlewareChain, runErrorChain } from './middleware.js';
7
+ import { createReq, createRes } from './context.js';
8
+ import { createSession } from './session.js';
9
+ import { createI18n } from './i18n.js';
10
+ import { createWs } from './ws.js';
11
+ import { createCrypto } from './crypto.js';
12
+ import { createFile } from './file.js';
13
+ import { createHash } from './hash.js';
14
+ import { createMedia } from './media.js';
15
+ import { createLogger, interceptConsole } from './logger.js';
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ /**
20
+ * fuzionx-bridge native module 로드.
21
+ * 순서: @fuzionx/bridge (npm) → fuzionx-bridge (로컬 dev) → .node 파일 직접
22
+ */
23
+ function loadBridge() {
24
+ // 1. npm 설치된 @fuzionx/bridge
25
+ try { return require('@fuzionx/bridge'); } catch (_) {}
26
+
27
+ // 2. 로컬 개발 환경 (file: 또는 workspace 의존성)
28
+ try { return require('fuzionx-bridge'); } catch (_) {}
29
+
30
+ throw new Error(
31
+ 'fuzionx-bridge native module을 찾을 수 없습니다. ' +
32
+ 'npm install @fuzionx/bridge 또는 package.json에 workspace 의존성을 추가하세요.'
33
+ );
34
+ }
35
+
36
+ export class FuzionXApp {
37
+ /**
38
+ * @param {object} options
39
+ * @param {string} options.config - YAML 설정 파일 경로
40
+ * @param {number} [options.port=3000] - 서버 포트
41
+ */
42
+ constructor(options = {}) {
43
+ this._bridge = loadBridge();
44
+ this._router = new Router(this._bridge);
45
+ this._middlewares = [];
46
+ this._errorHandlers = [];
47
+ this._options = options;
48
+ this._booted = false;
49
+ this._port = options.port || 3000;
50
+
51
+ // 사전 계산된 미들웨어 체인 캐시 (handlerId → handler[])
52
+ this._chainCache = null;
53
+ this._globalChain = null;
54
+ this._errorChain = null;
55
+
56
+ // 헬퍼 모듈 — 직통 N-API 호출, 오버헤드 없음
57
+ this.crypto = createCrypto(this._bridge);
58
+ this.file = createFile(this._bridge);
59
+ this.hash = createHash(this._bridge);
60
+ this.media = createMedia(this._bridge); // utils-media feature 필요
61
+ this._ws = null; // lazy — get ws() 에서 초기화
62
+ this.i18n = null; // boot 후 초기화
63
+ }
64
+
65
+ /** YAML boot — 설정 파일 로드 */
66
+ _boot() {
67
+ if (this._booted) return;
68
+ const configPath = this._options.config;
69
+ if (!configPath) {
70
+ throw new Error('config 옵션이 필요합니다 (YAML 파일 경로)');
71
+ }
72
+ this._bridge.bootSystem({ configPath: path.resolve(configPath) });
73
+ this._booted = true;
74
+ this.i18n = createI18n(this._bridge);
75
+
76
+ // 통합 로거 초기화
77
+ this.logger = createLogger(this._bridge);
78
+
79
+ // console.* 가로채기 (YAML intercept_console 설정에 따름)
80
+ const cfg = this._bridge.getConfig();
81
+ if (cfg.interceptConsole !== false) {
82
+ this._restoreConsole = interceptConsole(this._bridge);
83
+ }
84
+
85
+ // 앱 로그 파일 초기화 (YAML logging.file.enabled 시)
86
+ if (cfg.logFilePath) {
87
+ try {
88
+ this._bridge.initAppFileLogger(cfg.logFilePath);
89
+ } catch (e) {
90
+ // 파일 로거 실패해도 서버는 계속 동작
91
+ this.logger.warn(`앱 파일 로거 초기화 실패: ${e.message}`);
92
+ }
93
+ }
94
+ }
95
+
96
+ /** WebSocket 매니저 (lazy init — listen() 전에도 사용 가능) */
97
+ get ws() {
98
+ this._boot();
99
+ if (!this._ws) {
100
+ this._ws = createWs(this._bridge);
101
+ }
102
+ return this._ws;
103
+ }
104
+
105
+ /** 서버 설정값 */
106
+ get config() {
107
+ this._boot();
108
+ return this._bridge.getConfig();
109
+ }
110
+
111
+ /** 앱 커스텀 설정값 */
112
+ get appConfig() {
113
+ this._boot();
114
+ return this._bridge.getAppConfig();
115
+ }
116
+
117
+ /** ASP (Alloy Stealth Protocol) 설정값 */
118
+ get aspConfig() {
119
+ this._boot();
120
+ return JSON.parse(this._bridge.getAspConfig());
121
+ }
122
+
123
+ /**
124
+ * Tera SSR 파일 렌더링 ({% extends %}, {% block %}, {% include %} 완전 지원)
125
+ * @param {string} templateDir - 테마 디렉토리 (views/{theme}/)
126
+ * @param {string} templateName - 템플릿 이름 (pages/home.html)
127
+ * @param {object} [context={}] - 템플릿 변수
128
+ * @param {string} [locale='ko'] - 로케일
129
+ * @returns {string} 렌더링된 HTML
130
+ */
131
+ renderFile(templateDir, templateName, context = {}, locale = 'ko') {
132
+ this._boot();
133
+ return this._bridge.ssrRenderFile(templateDir, templateName, JSON.stringify(context), locale);
134
+ }
135
+
136
+ /**
137
+ * 경량 템플릿 파일 렌더링 (bridge에서 파일 읽기 + {{key}} 치환)
138
+ * @param {string} filePath - 템플릿 파일 경로
139
+ * @param {object} [context={}] - 치환 변수
140
+ * @returns {string} 렌더링된 HTML
141
+ */
142
+ render(filePath, context = {}) {
143
+ this._boot();
144
+ return this._bridge.renderTemplateFile(filePath, JSON.stringify(context));
145
+ }
146
+
147
+ // ── HTTP Method 라우트 등록 ──
148
+
149
+ get(routePath, ...handlers) { this._router.add('GET', routePath, handlers); return this; }
150
+ post(routePath, ...handlers) { this._router.add('POST', routePath, handlers); return this; }
151
+ put(routePath, ...handlers) { this._router.add('PUT', routePath, handlers); return this; }
152
+ delete(routePath, ...handlers) { this._router.add('DELETE', routePath, handlers); return this; }
153
+ patch(routePath, ...handlers) { this._router.add('PATCH', routePath, handlers); return this; }
154
+
155
+ /**
156
+ * 미들웨어 등록
157
+ * @param {string|Function} pathOrHandler
158
+ * @param {...Function} handlers
159
+ */
160
+ use(pathOrHandler, ...handlers) {
161
+ if (typeof pathOrHandler === 'function') {
162
+ const all = [pathOrHandler, ...handlers];
163
+ for (const h of all) {
164
+ const target = h.length === 4 ? this._errorHandlers : this._middlewares;
165
+ target.push({ path: null, handler: h });
166
+ }
167
+ } else {
168
+ for (const h of handlers) {
169
+ const target = h.length === 4 ? this._errorHandlers : this._middlewares;
170
+ target.push({ path: pathOrHandler, handler: h });
171
+ }
172
+ }
173
+ return this;
174
+ }
175
+
176
+ /**
177
+ * Fusion 서버 시작
178
+ * @param {number} [port]
179
+ * @param {Function} [callback]
180
+ */
181
+ listen(port, callback) {
182
+ if (typeof port === 'function') { callback = port; port = this._port; }
183
+ port = port || this._port;
184
+
185
+ this._boot();
186
+
187
+ // ── 워커 수 결정 (0=auto/cpus, 1=단일 프로세스) ──
188
+ const configWorkers = this._bridge.getWorkerCount();
189
+ const numWorkers = configWorkers > 0 ? configWorkers : os.cpus().length;
190
+
191
+ // ── Primary: cluster.fork() ──
192
+ if (numWorkers > 1 && cluster.isPrimary) {
193
+ console.log(`[fuzionx] Primary PID=${process.pid}, spawning ${numWorkers} workers on :${port}`);
194
+
195
+ for (let i = 0; i < numWorkers; i++) {
196
+ cluster.fork();
197
+ }
198
+
199
+ let shuttingDown = false;
200
+
201
+ cluster.on('exit', (worker, code) => {
202
+ console.log(`[fuzionx] Worker ${worker.process.pid} exited (code=${code})`);
203
+ if (!shuttingDown) {
204
+ console.log('[fuzionx] Restarting worker...');
205
+ cluster.fork();
206
+ }
207
+ });
208
+
209
+ // ── Primary Graceful Shutdown ──
210
+ const gracefulShutdown = (signal) => {
211
+ if (shuttingDown) return;
212
+ shuttingDown = true;
213
+ console.log(`[fuzionx] ${signal} received — shutting down all workers`);
214
+ for (const id in cluster.workers) {
215
+ cluster.workers[id].process.kill('SIGTERM');
216
+ }
217
+ const shutdownTimeout = this._appConfig?.shutdown_timeout || 5000;
218
+ setTimeout(() => process.exit(0), shutdownTimeout);
219
+ };
220
+ process.once('SIGTERM', () => gracefulShutdown('SIGTERM'));
221
+ process.once('SIGINT', () => gracefulShutdown('SIGINT'));
222
+
223
+ // ── Primary Hot Reload: SIGHUP → 모든 워커에 전파 ──
224
+ process.on('SIGHUP', () => {
225
+ console.log('[fuzionx] SIGHUP — forwarding to all workers');
226
+ for (const id in cluster.workers) {
227
+ cluster.workers[id].process.kill('SIGHUP');
228
+ }
229
+ });
230
+
231
+ if (callback) setImmediate(() => callback());
232
+ return this;
233
+ }
234
+
235
+ // ── Worker (또는 단일 프로세스): Fusion 서버 시작 ──
236
+ if (cluster.isWorker) {
237
+ // 워커 프로세스는 독립적으로 boot 필요
238
+ this._boot();
239
+ }
240
+
241
+ this._router.build();
242
+ this._buildMiddlewareCache();
243
+ this._bridge.startFusionServer(port, (rawReq) => this._handleRequest(rawReq));
244
+
245
+ if (cluster.isWorker) {
246
+ console.log(`[fuzionx] Worker PID=${process.pid} on :${port}`);
247
+ }
248
+
249
+ // ── Worker Graceful Shutdown (SIGTERM/SIGINT) ──
250
+ let shutdownCalled = false;
251
+ const gracefulShutdown = (signal) => {
252
+ if (shutdownCalled) return;
253
+ shutdownCalled = true;
254
+ console.log(`[fuzionx] ${signal} received — graceful shutdown (PID=${process.pid})`);
255
+ this._bridge.stopFusionServer();
256
+ const shutdownTimeout = this._appConfig?.shutdown_timeout || 5000;
257
+ setTimeout(() => process.exit(0), shutdownTimeout);
258
+ };
259
+ process.once('SIGTERM', () => gracefulShutdown('SIGTERM'));
260
+ process.once('SIGINT', () => gracefulShutdown('SIGINT'));
261
+
262
+ // ── Hot Reload (SIGHUP) ──
263
+ process.on('SIGHUP', () => {
264
+ try {
265
+ this._bridge.reloadConfig();
266
+ this._bridge.reloadFusionConfig();
267
+ console.log(`[fuzionx] SIGHUP — config hot-reload complete (PID=${process.pid})`);
268
+ } catch (e) {
269
+ console.error(`[fuzionx] SIGHUP reload failed (PID=${process.pid}):`, e.message);
270
+ }
271
+ });
272
+
273
+ // ── Idle Cleanup은 Rust uv_timer에서 자동 실행 (YAML websocket.check_interval/timeout) ──
274
+
275
+ // ── Error Recovery ──
276
+ process.on('uncaughtException', (err) => {
277
+ console.error('[fuzionx] uncaughtException:', err.message, err.stack);
278
+ // 불안정 상태 방지: 짧은 유예 후 프로세스 종료
279
+ setTimeout(() => process.exit(1), 1000);
280
+ });
281
+ process.on('unhandledRejection', (reason) => {
282
+ console.error('[fuzionx] unhandledRejection:', reason);
283
+ });
284
+
285
+ if (callback) setImmediate(() => callback());
286
+ return this;
287
+ }
288
+
289
+ /**
290
+ * 미들웨어 체인 사전 계산.
291
+ * listen() 시점에 라우트별로 적용 가능한 미들웨어 + 핸들러를 합쳐서 캐시.
292
+ * _handleRequest()에서 .filter()/.map() 없이 O(1) 조회.
293
+ */
294
+ _buildMiddlewareCache() {
295
+ // 글로벌 미들웨어 (path === null)
296
+ const globalMw = [];
297
+ // 경로별 미들웨어 (path !== null)
298
+ const pathMw = [];
299
+
300
+ for (const m of this._middlewares) {
301
+ if (m.path === null) {
302
+ globalMw.push(m.handler);
303
+ } else {
304
+ pathMw.push(m);
305
+ }
306
+ }
307
+
308
+ this._globalChain = globalMw;
309
+ this._chainCache = new Map();
310
+
311
+ // 에러 핸들러도 사전 계산
312
+ this._errorChain = this._errorHandlers.map(m => m.handler);
313
+
314
+ // 각 라우트별 체인 캐시
315
+ for (const route of this._router._routes) {
316
+ const chain = [...globalMw];
317
+ for (const m of pathMw) {
318
+ if (route.path === m.path ||
319
+ route.path.startsWith(m.path.endsWith('/') ? m.path : m.path + '/')) {
320
+ chain.push(m.handler);
321
+ }
322
+ }
323
+ chain.push(...route.handlers);
324
+ this._chainCache.set(route.handlerId, chain);
325
+ }
326
+
327
+ // 404 fallback 체인 (글로벌 미들웨어 + 404 핸들러)
328
+ this._notFoundChain = [...globalMw, (_req, _res) => {
329
+ _res.status(404).json({ error: 'Not Found', path: _req.path });
330
+ }];
331
+ }
332
+
333
+ /** 요청 처리 — 사전 계산된 체인으로 O(1) 조회 */
334
+ _handleRequest(rawReq) {
335
+ // sync 경로: 공유 인스턴스 재사용 (할당 0)
336
+ const req = createReq(rawReq, this, true);
337
+ const res = createRes(true);
338
+
339
+ // O(1) 체인 조회 — .filter()/.map() 없음
340
+ const chain = this._chainCache.get(rawReq.handlerId) || this._notFoundChain;
341
+
342
+ let result;
343
+ try {
344
+ result = runMiddlewareChain(chain, req, res);
345
+ } catch (err) {
346
+ this._handleError(err, req, res);
347
+ return res._toFusionResponse();
348
+ }
349
+
350
+ // Framework 등 외부 핸들러가 직접 { async: true } 반환 시
351
+ // Rust에 그대로 전달하여 pending_async 등록
352
+ if (result && result.async === true && typeof result.then !== 'function') {
353
+ return { async: true };
354
+ }
355
+
356
+ // 핸들러가 Promise를 리턴하면 — 비동기 경로
357
+ // async 핸들러는 응답 전까지 req/res가 유지되어야 하므로 새 인스턴스 생성
358
+ if (result && typeof result.then === 'function') {
359
+ const asyncReq = createReq(rawReq, this, false);
360
+ const asyncRes = createRes(false);
361
+ // sync 결과를 asyncRes에 복사 (미들웨어가 이미 설정한 값)
362
+ asyncRes._statusCode = res._statusCode;
363
+ asyncRes._body = res._body;
364
+ asyncRes._sent = res._sent;
365
+
366
+ result
367
+ .then(() => {
368
+ this._sendAsyncResponse(rawReq.requestId, asyncRes);
369
+ })
370
+ .catch((err) => {
371
+ this._handleError(err, asyncReq, asyncRes);
372
+ this._sendAsyncResponse(rawReq.requestId, asyncRes);
373
+ });
374
+
375
+ return { async: true };
376
+ }
377
+
378
+ return res._toFusionResponse();
379
+ }
380
+
381
+ /** 에러 핸들링 — 에러 핸들러 체인 or 500 */
382
+ _handleError(err, req, res) {
383
+ if (this._errorChain && this._errorChain.length > 0) {
384
+ try {
385
+ runErrorChain(this._errorChain, err, req, res);
386
+ } catch (_) {
387
+ if (!res._sent) {
388
+ res.status(500).json({ error: 'Internal Server Error' });
389
+ }
390
+ }
391
+ } else {
392
+ console.error('[fuzionx] Handler error:', err.message || err);
393
+ if (!res._sent) {
394
+ res.status(500).json({ error: err.message || 'Internal Server Error' });
395
+ }
396
+ }
397
+ }
398
+
399
+ /** 비동기 응답 전송 — bridge.sendAsyncResponse 호출 */
400
+ _sendAsyncResponse(requestId, res) {
401
+ const resp = res._toFusionResponse();
402
+ const contentType = resp.headers?.['Content-Type'] || 'application/json';
403
+
404
+ // Content-Type 외 추가 헤더 → 배열 join (문자열 반복 연결 제거)
405
+ const headerParts = [];
406
+ if (resp.headers) {
407
+ const keys = Object.keys(resp.headers);
408
+ for (let i = 0; i < keys.length; i++) {
409
+ const k = keys[i];
410
+ if (k !== 'Content-Type') {
411
+ headerParts.push(k + ': ' + resp.headers[k]);
412
+ }
413
+ }
414
+ }
415
+ const extraHeaders = headerParts.length > 0 ? headerParts.join('\r\n') + '\r\n' : '';
416
+
417
+ try {
418
+ this._bridge.sendAsyncResponse(
419
+ requestId,
420
+ resp.status,
421
+ resp.body,
422
+ contentType,
423
+ extraHeaders,
424
+ );
425
+ } catch (e) {
426
+ console.error('[fuzionx] sendAsyncResponse failed:', e.message);
427
+ }
428
+ }
429
+ }
430
+
431
+ export function createApp(options) {
432
+ return new FuzionXApp(options);
433
+ }