@fuzionx/framework 0.1.20 → 0.1.22

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.
@@ -245,6 +245,18 @@ export default class Application {
245
245
  async boot() {
246
246
  if (this._booted) return;
247
247
 
248
+ // 0. Bridge 초기화 — boot 로직 전에 bridge 확보
249
+ // Helper(crypto/hash/media/file/i18n/logger/view)가 boot 중 bridge 필요
250
+ if (FuzionXApp && !this._coreApp) {
251
+ this._coreApp = new FuzionXApp({
252
+ config: this.configPath
253
+ ? path.resolve(this.baseDir, this.configPath)
254
+ : undefined,
255
+ });
256
+ this._bridge = this._coreApp._bridge;
257
+ this._propagateBridge();
258
+ }
259
+
248
260
  // 1. .env 로드 (17-config.md)
249
261
  this.config.loadEnv(this.baseDir);
250
262
  // .env 로드 후 캐시 클리어 (새로운 환경변수 반영)
@@ -307,6 +319,21 @@ export default class Application {
307
319
  await this.emit('booted');
308
320
  }
309
321
 
322
+ /**
323
+ * Bridge 참조를 모든 Helper 인스턴스에 전파
324
+ * @private
325
+ */
326
+ _propagateBridge() {
327
+ if (!this._bridge) return;
328
+ if (this._view) this._view._bridge = this._bridge;
329
+ if (this.logger) this.logger._bridge = this._bridge;
330
+ if (this.crypto) this.crypto._bridge = this._bridge;
331
+ if (this.hash) this.hash._bridge = this._bridge;
332
+ if (this.media) this.media._bridge = this._bridge;
333
+ if (this.file) this.file._bridge = this._bridge;
334
+ if (this.i18n) this.i18n._bridge = this._bridge;
335
+ }
336
+
310
337
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
311
338
  // 서버 시작 — Bridge 연결
312
339
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -323,34 +350,22 @@ export default class Application {
323
350
 
324
351
  if (!this._booted) await this.boot();
325
352
 
326
- // 포트 사용 여부 확인 — 충돌 명확한 에러
327
- await this._checkPort(port);
353
+ // 포트 사용 여부 확인 — primary에서만 (워커는 SO_REUSEPORT로 공유)
354
+ const cluster = await import('node:cluster');
355
+ if (!cluster.default?.isWorker) {
356
+ await this._checkPort(port);
357
+ }
328
358
 
329
359
  await this.emit('ready');
330
360
 
331
- // Bridge 연동
332
- if (FuzionXApp) {
333
- if (!this._coreApp) {
334
- this._coreApp = new FuzionXApp({
335
- config: this.configPath
336
- ? path.resolve(this.baseDir, this.configPath)
337
- : undefined,
338
- port,
339
- });
340
- // FuzionXApp 생성 후 Bridge 전파 — boot() 시점에는 null이었음
341
- this._bridge = this._coreApp._bridge;
342
- if (this._bridge) {
343
- if (this._view) this._view._bridge = this._bridge;
344
- if (this.logger) this.logger._bridge = this._bridge;
345
- if (this.crypto) this.crypto._bridge = this._bridge;
346
- if (this.hash) this.hash._bridge = this._bridge;
347
- if (this.media) this.media._bridge = this._bridge;
348
- if (this.file) this.file._bridge = this._bridge;
349
- if (this.i18n) this.i18n._bridge = this._bridge;
350
- }
351
- // WS proxy 연결 (WsHandler에서 this.app.ws 사용)
352
- this.ws = this._coreApp.ws;
353
- }
361
+ // Bridge 연동 — _coreApp은 boot()에서 이미 생성됨
362
+ if (this._coreApp) {
363
+ // boot()에서 port 없이 생성됐으므로 port 설정
364
+ this._coreApp._port = port;
365
+
366
+ // WS proxy 연결 (WsHandler에서 this.app.ws 사용)
367
+ if (!this.ws) this.ws = this._coreApp.ws;
368
+
354
369
  this._registerBridgeRoutes(this._coreApp);
355
370
 
356
371
  // WsHandler → Bridge WS 이벤트 연결
@@ -524,14 +539,15 @@ export default class Application {
524
539
  const eventMap = HandlerClass.buildEventMap();
525
540
  const wsNs = coreApp.ws(namespace);
526
541
 
542
+ // 싱글톤 인스턴스 — 연결/메시지/해제에서 동일 인스턴스 사용
543
+ const inst = new HandlerClass(this);
544
+
527
545
  wsNs.on('connect', (socket) => {
528
- const inst = new HandlerClass(this);
529
546
  console.log(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
530
547
  inst.onConnect(socket);
531
548
  });
532
549
 
533
550
  wsNs.on('message', (socket, rawMessage) => {
534
- const inst = new HandlerClass(this);
535
551
  let parsed;
536
552
  try { parsed = typeof rawMessage === 'string' ? JSON.parse(rawMessage) : rawMessage; }
537
553
  catch { parsed = { type: 'message', data: rawMessage }; }
@@ -553,7 +569,6 @@ export default class Application {
553
569
  });
554
570
 
555
571
  wsNs.on('disconnect', (socket) => {
556
- const inst = new HandlerClass(this);
557
572
  console.log(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
558
573
  inst.onDisconnect(socket);
559
574
  });
@@ -602,7 +617,7 @@ export default class Application {
602
617
  }, this);
603
618
 
604
619
  // Framework → async 체인 실행 후 직접 sendAsyncResponse
605
- const promise = this._executeChain(middlewareFns, route, ctx, res);
620
+ const promise = this._executeChain(middlewareFns, route, ctx);
606
621
 
607
622
  promise.then(() => {
608
623
  const response = ctx.toResponse();
@@ -643,7 +658,7 @@ export default class Application {
643
658
  * 미들웨어 체인 + 핸들러 실행
644
659
  * @private
645
660
  */
646
- async _executeChain(middlewareFns, route, ctx, res) {
661
+ async _executeChain(middlewareFns, route, ctx) {
647
662
  let idx = 0;
648
663
 
649
664
  const next = async () => {
@@ -674,18 +689,7 @@ export default class Application {
674
689
  }
675
690
  if (!handled) this._errorHandler.handle(err, ctx);
676
691
  }
677
-
678
- // Context → Bridge res 변환 (이중 응답 방지 — Core res는 _sent 사용)
679
- if (!res._sent) {
680
- const response = ctx.toResponse();
681
- res.status(response.status);
682
- if (response.headers) {
683
- for (const [k, v] of Object.entries(response.headers)) {
684
- res.header(k, v);
685
- }
686
- }
687
- res.send(response.body);
688
- }
692
+ // 응답은 _createBridgeHandler의 .then()에서 sendAsyncResponse로 전송
689
693
  }
690
694
 
691
695
  /**
@@ -106,8 +106,13 @@ export default class AutoLoader {
106
106
  */
107
107
  static registerController(ControllerClass) {
108
108
  const proto = ControllerClass.prototype;
109
+ // Base 프로토타입 메서드 스킵 (Controller.register와 동일 로직)
110
+ const baseNames = new Set(Object.getOwnPropertyNames(
111
+ Object.getPrototypeOf(proto) // Base.prototype (Controller extends Base)
112
+ ));
109
113
  for (const method of Object.getOwnPropertyNames(proto)) {
110
114
  if (method === 'constructor') continue;
115
+ if (baseNames.has(method)) continue;
111
116
  if (typeof proto[method] !== 'function') continue;
112
117
  // static property로 __handler__ 디스크립터 등록
113
118
  ControllerClass[method] = {
@@ -140,7 +145,7 @@ export default class AutoLoader {
140
145
  const mod = await import(file);
141
146
  const MwClass = mod.default;
142
147
  if (!MwClass) continue;
143
- const mwName = MwClass.name || extractName(file, 'Middleware').toLowerCase();
148
+ const mwName = MwClass.alias || extractName(file, 'Middleware').toLowerCase();
144
149
  this.app._middlewareRegistry.set(mwName, MwClass);
145
150
  }
146
151
  }
@@ -38,7 +38,8 @@ export default class Config {
38
38
 
39
39
  try {
40
40
  const content = readFileSync(configPath, 'utf-8');
41
- const parsed = Config.parseYaml(content);
41
+ const raw = Config.parseYaml(content);
42
+ const parsed = Config.resolveEnvVars(raw);
42
43
 
43
44
  // _raw에 병합 (기존 opts.config 우선)
44
45
  for (const [key, value] of Object.entries(parsed)) {
@@ -175,9 +175,14 @@ export default class Context {
175
175
  return this;
176
176
  }
177
177
 
178
- send(text) {
179
- this._body = String(text);
180
- this._headers['Content-Type'] = 'text/html; charset=utf-8';
178
+ send(data) {
179
+ if (typeof data === 'object' && data !== null) {
180
+ return this.json(data);
181
+ }
182
+ this._body = String(data);
183
+ if (!this._headers['Content-Type']) {
184
+ this._headers['Content-Type'] = 'text/html; charset=utf-8';
185
+ }
181
186
  this._sent = true;
182
187
  return this;
183
188
  }
@@ -139,7 +139,23 @@ export default class ConnectionManager {
139
139
  case 'mongo': {
140
140
  const mongoose = tryRequire('mongoose');
141
141
  if (!mongoose) return this._createStub('mongo', config);
142
- return { type: 'mongo', mongoose, config };
142
+ // Lazy connect promise 캐싱으로 이중 연결 방지
143
+ const conn = { type: 'mongo', mongoose, config, _connected: false, _connectPromise: null };
144
+ conn.connect = () => {
145
+ if (conn._connected) return Promise.resolve();
146
+ if (conn._connectPromise) return conn._connectPromise;
147
+ const uri = config.uri || config.url
148
+ || `mongodb://${config.host || '127.0.0.1'}:${config.port || 27017}/${config.database || ''}`;
149
+ conn._connectPromise = mongoose.connect(uri, config.options || {}).then(() => {
150
+ conn._connected = true;
151
+ });
152
+ return conn._connectPromise;
153
+ };
154
+ // 즉시 연결 시작 (await 없이 — 백그라운드)
155
+ conn.connect().catch(err => {
156
+ console.error(`[ConnectionManager] MongoDB 연결 실패: ${err.message}`);
157
+ });
158
+ return conn;
143
159
  }
144
160
 
145
161
  default:
@@ -94,9 +94,10 @@ export default class SqlModel extends Model {
94
94
  }
95
95
 
96
96
  if (conn.type === 'sqlite' && conn.db) {
97
- const columns = Object.keys(insertData);
97
+ const columns = Object.keys(insertData).map(c => SqlQueryBuilder._sanitizeName(c));
98
98
  const placeholders = columns.map(() => '?').join(', ');
99
- const sql = `INSERT INTO ${this.table} (${columns.join(', ')}) VALUES (${placeholders})`;
99
+ const safeTable = SqlQueryBuilder._sanitizeName(this.table);
100
+ const sql = `INSERT INTO ${safeTable} (${columns.join(', ')}) VALUES (${placeholders})`;
100
101
  const result = conn.db.prepare(sql).run(...Object.values(insertData));
101
102
  const id = result.lastInsertRowid;
102
103
  return new this({ ...insertData, [this.primaryKey]: Number(id) });
@@ -207,9 +208,11 @@ export default class SqlModel extends Model {
207
208
  const conn = this.constructor.getConnection();
208
209
 
209
210
  if (conn.type === 'sqlite' && conn.db) {
210
- const columns = Object.keys(data);
211
+ const columns = Object.keys(data).map(c => SqlQueryBuilder._sanitizeName(c));
212
+ const pk = SqlQueryBuilder._sanitizeName(this.constructor.primaryKey);
213
+ const safeTable = SqlQueryBuilder._sanitizeName(this.constructor.table);
211
214
  const sets = columns.map(c => `${c} = ?`).join(', ');
212
- const sql = `UPDATE ${this.constructor.table} SET ${sets} WHERE ${pk} = ?`;
215
+ const sql = `UPDATE ${safeTable} SET ${sets} WHERE ${pk} = ?`;
213
216
  conn.db.prepare(sql).run(...Object.values(data), id);
214
217
  } else if (conn.type === 'knex' && conn.db) {
215
218
  await conn.db(this.constructor.table).where(pk, id).update(data);
@@ -244,7 +247,9 @@ export default class SqlModel extends Model {
244
247
  const conn = this.constructor.getConnection();
245
248
 
246
249
  if (conn.type === 'sqlite' && conn.db) {
247
- conn.db.prepare(`DELETE FROM ${this.constructor.table} WHERE ${pk} = ?`).run(id);
250
+ const safeTable = SqlQueryBuilder._sanitizeName(this.constructor.table);
251
+ const safePk = SqlQueryBuilder._sanitizeName(pk);
252
+ conn.db.prepare(`DELETE FROM ${safeTable} WHERE ${safePk} = ?`).run(id);
248
253
  } else if (conn.type === 'knex' && conn.db) {
249
254
  await conn.db(this.constructor.table).where(pk, id).delete();
250
255
  }
@@ -7,12 +7,26 @@
7
7
  * @see docs/framework/02-database-orm.md
8
8
  */
9
9
  import QueryBuilder from './QueryBuilder.js';
10
+ import Pagination from './Pagination.js';
10
11
 
11
12
  export default class SqlQueryBuilder extends QueryBuilder {
12
13
  constructor(model) {
13
14
  super(model);
14
15
  }
15
16
 
17
+ /**
18
+ * SQL 식별자(컬럼명/테이블명) 유효성 검증 — SQL Injection 방지
19
+ * @param {string} name
20
+ * @returns {string}
21
+ * @private
22
+ */
23
+ static _sanitizeName(name) {
24
+ if (typeof name !== 'string' || !/^[\w.]+$/.test(name)) {
25
+ throw new Error(`Invalid SQL identifier: '${name}'`);
26
+ }
27
+ return name;
28
+ }
29
+
16
30
  /**
17
31
  * 결과 조회 — SQL SELECT 실행
18
32
  * @returns {Promise<Array<import('./Model.js').default>>}
@@ -117,7 +131,8 @@ export default class SqlQueryBuilder extends QueryBuilder {
117
131
 
118
132
  if (conn.type === 'sqlite' && conn.db) {
119
133
  const { whereClause, whereParams } = this._buildWhereSql();
120
- const sql = `DELETE FROM ${this._model.table}${whereClause}`;
134
+ const safeTable = SqlQueryBuilder._sanitizeName(this._model.table);
135
+ const sql = `DELETE FROM ${safeTable}${whereClause}`;
121
136
  const result = conn.db.prepare(sql).run(...whereParams);
122
137
  return result.changes;
123
138
  }
@@ -141,13 +156,7 @@ export default class SqlQueryBuilder extends QueryBuilder {
141
156
  this._limit = perPage;
142
157
  this._offset = (page - 1) * perPage;
143
158
  const data = await this.get();
144
- return {
145
- data,
146
- page,
147
- perPage,
148
- total,
149
- lastPage: Math.ceil(total / perPage),
150
- };
159
+ return new Pagination(data, total, page, perPage);
151
160
  }
152
161
 
153
162
  // ━━━━━━━━━━ SQLite 실행 ━━━━━━━━━━
@@ -161,13 +170,15 @@ export default class SqlQueryBuilder extends QueryBuilder {
161
170
 
162
171
  const selectColumns = mode === 'count'
163
172
  ? 'COUNT(*) as cnt'
164
- : (this._selects ? this._selects.join(', ') : '*');
173
+ : (this._selects ? this._selects.map(c => SqlQueryBuilder._sanitizeName(c)).join(', ') : '*');
174
+
175
+ const safeTable = SqlQueryBuilder._sanitizeName(this._model.table);
165
176
 
166
- let sql = `SELECT ${selectColumns} FROM ${this._model.table}${whereClause}`;
177
+ let sql = `SELECT ${selectColumns} FROM ${safeTable}${whereClause}`;
167
178
 
168
179
  // ORDER BY
169
180
  if (this._orders.length > 0 && mode !== 'count') {
170
- const orderParts = this._orders.map(o => `${o.column} ${o.direction.toUpperCase()}`);
181
+ const orderParts = this._orders.map(o => `${SqlQueryBuilder._sanitizeName(o.column)} ${o.direction.toUpperCase()}`);
171
182
  sql += ` ORDER BY ${orderParts.join(', ')}`;
172
183
  }
173
184
 
@@ -204,20 +215,21 @@ export default class SqlQueryBuilder extends QueryBuilder {
204
215
  }
205
216
 
206
217
  for (const w of this._wheres) {
218
+ const safeKey = SqlQueryBuilder._sanitizeName(w.key);
207
219
  const prefix = parts.length > 0
208
220
  ? (w.type === 'or' ? ' OR ' : ' AND ')
209
221
  : '';
210
222
 
211
223
  if (w.op === 'IN') {
212
224
  const placeholders = w.value.map(() => '?').join(', ');
213
- parts.push(`${prefix}${w.key} IN (${placeholders})`);
225
+ parts.push(`${prefix}${safeKey} IN (${placeholders})`);
214
226
  params.push(...w.value);
215
227
  } else if (w.op === 'IS NULL') {
216
- parts.push(`${prefix}${w.key} IS NULL`);
228
+ parts.push(`${prefix}${safeKey} IS NULL`);
217
229
  } else if (w.op === 'IS NOT NULL') {
218
- parts.push(`${prefix}${w.key} IS NOT NULL`);
230
+ parts.push(`${prefix}${safeKey} IS NOT NULL`);
219
231
  } else {
220
- parts.push(`${prefix}${w.key} ${w.op} ?`);
232
+ parts.push(`${prefix}${safeKey} ${w.op} ?`);
221
233
  params.push(w.value);
222
234
  }
223
235
  }
@@ -233,8 +245,8 @@ export default class SqlQueryBuilder extends QueryBuilder {
233
245
  _buildUpdateSql(data) {
234
246
  const { whereClause, whereParams } = this._buildWhereSql();
235
247
  const columns = Object.keys(data);
236
- const sets = columns.map(c => `${c} = ?`).join(', ');
237
- const sql = `UPDATE ${this._model.table} SET ${sets}${whereClause}`;
248
+ const sets = columns.map(c => `${SqlQueryBuilder._sanitizeName(c)} = ?`).join(', ');
249
+ const sql = `UPDATE ${SqlQueryBuilder._sanitizeName(this._model.table)} SET ${sets}${whereClause}`;
238
250
  const params = [...Object.values(data), ...whereParams];
239
251
  return { sql, params };
240
252
  }
@@ -35,4 +35,50 @@ export default class MediaHelper {
35
35
  if (this._bridge?.mediaVideoInfo) return this._bridge.mediaVideoInfo(filePath);
36
36
  throw new Error('Video info requires ffprobe + Bridge.');
37
37
  }
38
+
39
+ /**
40
+ * 이미지에 워터마크를 합성한다.
41
+ * @param {string} targetPath - 대상 이미지 경로
42
+ * @param {string} watermarkPath - 워터마크 이미지 경로
43
+ * @param {number} [opacity=50] - 불투명도 (0-100)
44
+ * @param {string} [format] - 출력 포맷 (기본: 원본 확장자)
45
+ * @param {number} [quality=90] - 출력 품질
46
+ */
47
+ applyWatermark(targetPath, watermarkPath, opacity = 50, format, quality = 90) {
48
+ if (this._bridge?.mediaApplyWatermark) {
49
+ return this._bridge.mediaApplyWatermark(targetPath, watermarkPath, opacity, format, quality);
50
+ }
51
+ throw new Error('Watermark requires Bridge (Rust).');
52
+ }
53
+
54
+ /**
55
+ * 비디오에서 N초 간격으로 다중 썸네일을 추출한다.
56
+ * @param {string} inputPath - 비디오 경로
57
+ * @param {string} outputDir - 출력 디렉토리
58
+ * @param {number} [interval=5] - 초 간격
59
+ * @param {number} [width=320] - 썸네일 가로 크기
60
+ * @param {string} [format='jpeg'] - 포맷
61
+ * @returns {string[]} 생성된 파일 경로 목록
62
+ */
63
+ videoThumbnails(inputPath, outputDir, interval = 5, width = 320, format = 'jpeg') {
64
+ if (this._bridge?.mediaVideoThumbnails) {
65
+ return this._bridge.mediaVideoThumbnails(inputPath, outputDir, interval, width, format);
66
+ }
67
+ throw new Error('Video thumbnails requires ffmpeg + Bridge.');
68
+ }
69
+
70
+ /**
71
+ * 썸네일 목록을 스프라이트 시트(그리드)로 합성한다.
72
+ * @param {string[]} thumbPaths - 썸네일 경로 목록
73
+ * @param {string} outputPath - 출력 스프라이트 시트 경로
74
+ * @param {number} [cols=10] - 그리드 열 수
75
+ * @param {number} [thumbWidth=160] - 각 썸네일 가로
76
+ * @param {number} [thumbHeight=0] - 각 썸네일 세로 (0=자동)
77
+ */
78
+ videoPreviewSheet(thumbPaths, outputPath, cols = 10, thumbWidth = 160, thumbHeight = 0) {
79
+ if (this._bridge?.mediaVideoPreviewSheet) {
80
+ return this._bridge.mediaVideoPreviewSheet(thumbPaths, outputPath, cols, thumbWidth, thumbHeight);
81
+ }
82
+ throw new Error('Preview sheet requires Bridge (Rust).');
83
+ }
38
84
  }
@@ -8,7 +8,7 @@ import Base from '../core/Base.js';
8
8
 
9
9
  export default class Middleware extends Base {
10
10
  /** @type {string} 미들웨어 등록 이름 (라우트에서 참조) */
11
- static name = '';
11
+ static alias = '';
12
12
 
13
13
  /**
14
14
  * 미들웨어 핸들러 (서브클래스에서 오버라이드)
@@ -4,6 +4,7 @@
4
4
  * @see docs/framework/12-middleware.md
5
5
  * @see docs/framework/14-authentication.md
6
6
  */
7
+ import { createHmac, timingSafeEqual } from 'node:crypto';
7
8
 
8
9
  /**
9
10
  * JSON/Form body 파싱 미들웨어
@@ -200,7 +201,6 @@ function decodeJwtPayload(token, secret) {
200
201
  const [headerB64, payloadB64, signatureB64] = parts;
201
202
 
202
203
  // ── HMAC-SHA256 서명 검증 ──
203
- const { createHmac, timingSafeEqual } = require('node:crypto');
204
204
  const signingInput = `${headerB64}.${payloadB64}`;
205
205
  const expectedSig = createHmac('sha256', secret)
206
206
  .update(signingInput)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzionx/framework",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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.20",
37
+ "@fuzionx/core": "^0.1.22",
38
38
  "better-sqlite3": "^12.8.0",
39
39
  "knex": "^3.2.5",
40
40
  "mongoose": "^9.3.2",