@fuzionx/framework 0.1.38 → 0.1.39

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.
@@ -349,6 +349,14 @@ export default class Application {
349
349
  this._registerDocsRoutes();
350
350
 
351
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
+
352
360
  await this.emit('booted');
353
361
  }
354
362
 
@@ -388,13 +396,12 @@ export default class Application {
388
396
  || headers?.['x-original-host'] || headers?.['X-Original-Host'];
389
397
  const rawHost = forwardedHost || host || '';
390
398
  const hostname = rawHost.split(':')[0]; // 포트 제거
391
- const appsConfig = this.config.get('apps') || {};
392
-
393
399
 
400
+ // 사전 계산된 Map O(1) 조회
401
+ const mapped = this._hostAppMap?.get(hostname);
402
+ if (mapped) return mapped;
394
403
 
395
- // 정확한 도메인 매칭
396
- if (appsConfig[hostname]) return appsConfig[hostname];
397
- // 매칭 없으면 첫 번째 앱 (기본)
404
+ // 매칭 없으면 기본 앱
398
405
  return this._getDefaultAppName();
399
406
  }
400
407
 
@@ -682,7 +689,7 @@ export default class Application {
682
689
  const inst = new HandlerClass(this);
683
690
 
684
691
  wsNs.on('connect', (socket) => {
685
- console.log(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
692
+ this.logger.debug(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
686
693
  inst.onConnect(socket);
687
694
  });
688
695
 
@@ -692,7 +699,7 @@ export default class Application {
692
699
  catch { parsed = { type: 'message', data: rawMessage }; }
693
700
  const eventType = parsed.type || 'message';
694
701
  const eventData = parsed.data || parsed;
695
- console.log(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
702
+ this.logger.debug(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
696
703
  const entry = eventMap.get(eventType);
697
704
  if (entry) {
698
705
  const result = entry.handler.call(inst, socket, eventData);
@@ -708,11 +715,11 @@ export default class Application {
708
715
  });
709
716
 
710
717
  wsNs.on('disconnect', (socket) => {
711
- console.log(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
718
+ this.logger.debug(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
712
719
  inst.onDisconnect(socket);
713
720
  });
714
721
 
715
- console.log(`[WS] 핸들러 등록: ${namespace} (events: ${[...eventMap.keys()].join(', ')})`);
722
+ this.logger.info(`[WS] 핸들러 등록: ${namespace} (events: ${[...eventMap.keys()].join(', ')})`);
716
723
  }
717
724
  }
718
725
  }
@@ -808,6 +815,7 @@ export default class Application {
808
815
  /**
809
816
  * 단일 라우트의 Bridge 핸들러 생성 (앱별)
810
817
  * rawReq → Context → middleware → controller → toResponse
818
+ * @deprecated _createDispatchHandler를 사용하세요. 이 메서드는 테스트 호환용으로만 유지됩니다.
811
819
  * @param {object} route
812
820
  * @param {string} appName - 이 라우트가 속한 앱
813
821
  * @private
@@ -11,6 +11,7 @@
11
11
  * @see packages/fuzionx/lib/context.js (Core SessionProto 참조)
12
12
  */
13
13
  import AppError, { ValidationError } from '../core/AppError.js';
14
+ import { readFileSync } from 'node:fs';
14
15
 
15
16
  export default class Context {
16
17
  /**
@@ -234,26 +235,50 @@ export default class Context {
234
235
 
235
236
  /**
236
237
  * 파일 다운로드 (06-context.md)
237
- * @param {string} filePath
238
+ * Bridge는 문자열 body만 지원하므로 파일 내용을 직접 읽어 base64 전송.
239
+ * @param {string} filePath - 절대 경로
238
240
  * @param {string} [filename]
239
241
  */
240
242
  download(filePath, filename) {
241
243
  const name = filename || filePath.split('/').pop();
242
244
  this._headers['Content-Disposition'] = `attachment; filename="${name}"`;
243
- this._body = filePath; // Application에서 파일 전송 처리
245
+ try {
246
+ const buf = readFileSync(filePath);
247
+ this._body = buf.toString('base64');
248
+ this._headers['Content-Transfer-Encoding'] = 'base64';
249
+ this._headers['Content-Type'] = this._headers['Content-Type'] || 'application/octet-stream';
250
+ } catch (err) {
251
+ this._statusCode = 404;
252
+ this._body = JSON.stringify({ error: `File not found: ${name}` });
253
+ this._headers['Content-Type'] = 'application/json';
254
+ }
244
255
  this._sent = true;
245
256
  return this;
246
257
  }
247
258
 
248
259
  /**
249
260
  * 스트리밍 응답 (06-context.md)
250
- * 대용량 응답 ReadableStream/Buffer를 직접 전송.
251
- * @param {ReadableStream|Buffer} readableStream
261
+ * Bridge는 문자열 body만 지원하므로 Buffer/Stream을 문자열로 변환.
262
+ * Buffer → toString(), ReadableStream → 청크 수집.
263
+ * @param {ReadableStream|Buffer|string} readableStream
252
264
  * @param {string} [contentType='application/octet-stream']
253
265
  */
254
- stream(readableStream, contentType = 'application/octet-stream') {
266
+ async stream(readableStream, contentType = 'application/octet-stream') {
255
267
  this._headers['Content-Type'] = contentType;
256
- this._streamBody = readableStream;
268
+ if (Buffer.isBuffer(readableStream)) {
269
+ this._body = readableStream.toString();
270
+ } else if (typeof readableStream === 'string') {
271
+ this._body = readableStream;
272
+ } else if (readableStream && typeof readableStream[Symbol.asyncIterator] === 'function') {
273
+ // ReadableStream/AsyncIterable → 청크 수집
274
+ const chunks = [];
275
+ for await (const chunk of readableStream) {
276
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
277
+ }
278
+ this._body = Buffer.concat(chunks).toString();
279
+ } else {
280
+ this._body = String(readableStream || '');
281
+ }
257
282
  this._sent = true;
258
283
  return this;
259
284
  }
@@ -452,9 +477,8 @@ export default class Context {
452
477
  }
453
478
  return {
454
479
  status: this._statusCode,
455
- body: this._streamBody || this._body,
480
+ body: this._body,
456
481
  headers: this._headers,
457
- stream: !!this._streamBody,
458
482
  };
459
483
  }
460
484
  }
@@ -216,25 +216,36 @@ export default class SqlQueryBuilder extends QueryBuilder {
216
216
 
217
217
  for (const w of this._wheres) {
218
218
  const safeKey = SqlQueryBuilder._sanitizeName(w.key);
219
- const prefix = parts.length > 0
220
- ? (w.type === 'or' ? ' OR ' : ' AND ')
221
- : '';
219
+ let expr;
222
220
 
223
221
  if (w.op === 'IN') {
224
222
  const placeholders = w.value.map(() => '?').join(', ');
225
- parts.push(`${prefix}${safeKey} IN (${placeholders})`);
223
+ expr = `${safeKey} IN (${placeholders})`;
226
224
  params.push(...w.value);
227
225
  } else if (w.op === 'IS NULL') {
228
- parts.push(`${prefix}${safeKey} IS NULL`);
226
+ expr = `${safeKey} IS NULL`;
229
227
  } else if (w.op === 'IS NOT NULL') {
230
- parts.push(`${prefix}${safeKey} IS NOT NULL`);
228
+ expr = `${safeKey} IS NOT NULL`;
231
229
  } else {
232
- parts.push(`${prefix}${safeKey} ${w.op} ?`);
230
+ expr = `${safeKey} ${w.op} ?`;
233
231
  params.push(w.value);
234
232
  }
233
+
234
+ if (w.type === 'or' && parts.length > 0) {
235
+ // OR 조건: 이전 항목과 괄호로 그룹핑
236
+ const prev = parts.pop();
237
+ // 이전 항목이 이미 괄호 그룹이면 내부에 추가
238
+ if (prev.startsWith('(') && prev.endsWith(')')) {
239
+ parts.push(`${prev.slice(0, -1)} OR ${expr})`);
240
+ } else {
241
+ parts.push(`(${prev} OR ${expr})`);
242
+ }
243
+ } else {
244
+ parts.push(expr);
245
+ }
235
246
  }
236
247
 
237
- const whereClause = parts.length > 0 ? ` WHERE ${parts.join('')}` : '';
248
+ const whereClause = parts.length > 0 ? ` WHERE ${parts.join(' AND ')}` : '';
238
249
  return { whereClause, whereParams: params };
239
250
  }
240
251
 
@@ -56,10 +56,11 @@ export default class Logger {
56
56
  // Error 객체면 전체 스택 포함
57
57
  const isErr = message instanceof Error;
58
58
  const text = isErr ? (message.stack || `${message}`) : `${message}`;
59
- const msg = context ? `${text} ${JSON.stringify(context)}` : text;
60
59
 
61
- // ── Bridge N-API 위임 ──
60
+ // ── Bridge N-API 위임 (context 결합은 Bridge 경로에서만) ──
62
61
  if (this._bridge) {
62
+ // Bridge는 단일 문자열만 받으므로 여기서만 concat
63
+ const msg = context ? `${text} ${JSON.stringify(context)}` : text;
63
64
  try {
64
65
  if (level === 'info' && typeof this._bridge.logInfo === 'function') {
65
66
  this._bridge.logInfo(this._prefix, msg);
@@ -88,13 +89,15 @@ export default class Logger {
88
89
  timestamp,
89
90
  level,
90
91
  target: this._prefix,
91
- message: msg,
92
+ message: text,
92
93
  ...(context || {}),
93
94
  };
94
95
  console[level === 'debug' ? 'log' : level](JSON.stringify(entry));
95
96
  } else {
96
97
  const levelTag = level.toUpperCase().padEnd(5);
97
- const fullMsg = `${timestamp} ${levelTag} [${this._prefix}] ${msg}`;
98
+ const fullMsg = context
99
+ ? `${timestamp} ${levelTag} [${this._prefix}] ${text}`
100
+ : `${timestamp} ${levelTag} [${this._prefix}] ${text}`;
98
101
  if (context && Object.keys(context).length > 0) {
99
102
  console[level === 'debug' ? 'log' : level](fullMsg, context);
100
103
  } else {
@@ -39,9 +39,10 @@ export function parseRules(rules, Joi) {
39
39
  case 'max':
40
40
  schema = schema.max(Number(args[0])); break;
41
41
  case 'email':
42
- schema = Joi.string().email(); break;
42
+ // 기존 schema Joi.any()면 string()으로 교체, 이미 string()이면 유지
43
+ schema = (schema.type === 'any' ? Joi.string() : schema).email(); break;
43
44
  case 'url':
44
- schema = Joi.string().uri(); break;
45
+ schema = (schema.type === 'any' ? Joi.string() : schema).uri(); break;
45
46
  case 'in':
46
47
  schema = schema.valid(...args[0].split(',')); break;
47
48
  case 'integer':
@@ -49,9 +50,9 @@ export function parseRules(rules, Joi) {
49
50
  case 'positive':
50
51
  schema = schema.positive ? schema.positive() : Joi.number().positive(); break;
51
52
  case 'date':
52
- schema = Joi.date(); break;
53
+ schema = schema.type === 'any' ? Joi.date() : schema; break;
53
54
  case 'alpha':
54
- schema = Joi.string().alphanum(); break;
55
+ schema = (schema.type === 'any' ? Joi.string() : schema).alphanum(); break;
55
56
  default:
56
57
  // 알 수 없는 규칙은 무시
57
58
  break;
@@ -69,11 +69,12 @@ export default class Queue {
69
69
  const task = new TaskClass(this.app);
70
70
 
71
71
  try {
72
+ let timer;
72
73
  await Promise.race([
73
- task.handle(job.data),
74
- new Promise((_, reject) =>
75
- setTimeout(() => reject(new Error(`Task timeout (${timeout}ms)`)), timeout)
76
- ),
74
+ task.handle(job.data).finally(() => clearTimeout(timer)),
75
+ new Promise((_, reject) => {
76
+ timer = setTimeout(() => reject(new Error(`Task timeout (${timeout}ms)`)), timeout);
77
+ }),
77
78
  ]);
78
79
  } catch (err) {
79
80
  job.retries++;
@@ -48,11 +48,12 @@ export default class Scheduler {
48
48
  const runJob = async () => {
49
49
  const job = new JobClass(this.app);
50
50
  try {
51
+ let timer;
51
52
  await Promise.race([
52
- job.handle(),
53
- new Promise((_, reject) =>
54
- setTimeout(() => reject(new Error(`Job timeout (${timeout}ms)`)), timeout)
55
- ),
53
+ job.handle().finally(() => clearTimeout(timer)),
54
+ new Promise((_, reject) => {
55
+ timer = setTimeout(() => reject(new Error(`Job timeout (${timeout}ms)`)), timeout);
56
+ }),
56
57
  ]);
57
58
  } catch (err) {
58
59
  if (typeof job.onError === 'function') {
@@ -142,12 +142,12 @@ export default class WorkerPool {
142
142
  let timer;
143
143
  let settled = false;
144
144
 
145
- const settle = (fn, value) => {
145
+ const settle = (cb, value) => {
146
146
  if (settled) return;
147
147
  settled = true;
148
148
  clearTimeout(timer);
149
149
  this._activeWorkers.delete(worker);
150
- fn(value);
150
+ cb(value);
151
151
  };
152
152
 
153
153
  worker.on('message', (result) => settle(resolve, result));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzionx/framework",
3
- "version": "0.1.38",
3
+ "version": "0.1.39",
4
4
  "type": "module",
5
5
  "description": "Full-stack MVC framework built on @fuzionx/core — Controller, Service, Model, Middleware, DI, EventBus",
6
6
  "main": "index.js",
@@ -34,7 +34,7 @@
34
34
  "url": "https://github.com/saytohenry/fuzionx"
35
35
  },
36
36
  "dependencies": {
37
- "@fuzionx/core": "^0.1.38",
37
+ "@fuzionx/core": "^0.1.39",
38
38
  "better-sqlite3": "^12.8.0",
39
39
  "knex": "^3.2.5",
40
40
  "mongoose": "^9.3.2",
package/cli/fx.js DELETED
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
- import { run } from './index.js';
3
- run(process.argv.slice(2));