@fuzionx/framework 0.1.37 → 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.
- package/lib/core/Application.js +17 -9
- package/lib/core/Context.js +32 -8
- package/lib/database/SqlQueryBuilder.js +19 -8
- package/lib/helpers/Logger.js +7 -4
- package/lib/http/Validation.js +5 -4
- package/lib/schedule/Queue.js +5 -4
- package/lib/schedule/Scheduler.js +5 -4
- package/lib/schedule/WorkerPool.js +2 -2
- package/package.json +2 -2
- package/cli/fx.js +0 -3
package/lib/core/Application.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
718
|
+
this.logger.debug(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
|
|
712
719
|
inst.onDisconnect(socket);
|
|
713
720
|
});
|
|
714
721
|
|
|
715
|
-
|
|
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
|
package/lib/core/Context.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
251
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
223
|
+
expr = `${safeKey} IN (${placeholders})`;
|
|
226
224
|
params.push(...w.value);
|
|
227
225
|
} else if (w.op === 'IS NULL') {
|
|
228
|
-
|
|
226
|
+
expr = `${safeKey} IS NULL`;
|
|
229
227
|
} else if (w.op === 'IS NOT NULL') {
|
|
230
|
-
|
|
228
|
+
expr = `${safeKey} IS NOT NULL`;
|
|
231
229
|
} else {
|
|
232
|
-
|
|
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
|
|
package/lib/helpers/Logger.js
CHANGED
|
@@ -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:
|
|
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 =
|
|
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 {
|
package/lib/http/Validation.js
CHANGED
|
@@ -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
|
|
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;
|
package/lib/schedule/Queue.js
CHANGED
|
@@ -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 = (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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