@fuzionx/framework 0.1.9 → 0.1.21
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/cli/templates/app/fuzionx.yaml.tpl +5 -3
- package/lib/core/Application.js +169 -7
- package/lib/core/AutoLoader.js +46 -0
- package/lib/core/Config.js +103 -0
- package/lib/core/Context.js +5 -11
- package/lib/helpers/MediaHelper.js +46 -0
- package/lib/view/View.js +31 -20
- package/package.json +2 -2
package/lib/core/Application.js
CHANGED
|
@@ -21,6 +21,7 @@ import HashHelper from '../helpers/HashHelper.js';
|
|
|
21
21
|
import MediaHelper from '../helpers/MediaHelper.js';
|
|
22
22
|
import FileHelper from '../helpers/FileHelper.js';
|
|
23
23
|
import View from '../view/View.js';
|
|
24
|
+
import OpenAPI from '../view/OpenAPI.js';
|
|
24
25
|
import Scheduler from '../schedule/Scheduler.js';
|
|
25
26
|
import Queue from '../schedule/Queue.js';
|
|
26
27
|
import Storage from '../services/Storage.js';
|
|
@@ -59,6 +60,11 @@ export default class Application {
|
|
|
59
60
|
this.baseDir = path.resolve(opts.baseDir || process.cwd());
|
|
60
61
|
this.configPath = opts.configPath || null;
|
|
61
62
|
|
|
63
|
+
// configPath 지정 시 YAML 파일에서 database/app/themes 등 로드
|
|
64
|
+
if (this.configPath) {
|
|
65
|
+
this.config.loadYaml(path.resolve(this.baseDir, this.configPath));
|
|
66
|
+
}
|
|
67
|
+
|
|
62
68
|
// Bridge 참조 (listen() 시 FuzionXApp 생성)
|
|
63
69
|
this._bridge = null;
|
|
64
70
|
this._coreApp = null;
|
|
@@ -271,12 +277,32 @@ export default class Application {
|
|
|
271
277
|
|
|
272
278
|
// DB 연결 매니저 초기화 (database 섹션이 있을 때만)
|
|
273
279
|
const dbConfig = this.config.get('database');
|
|
274
|
-
if (dbConfig
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
280
|
+
if (dbConfig) {
|
|
281
|
+
// connections 키가 없는 레거시 형식 호환:
|
|
282
|
+
// database.main: { driver: 'sqlite', ... }
|
|
283
|
+
// → database.connections.main: { driver: 'sqlite', ... }
|
|
284
|
+
if (!dbConfig.connections) {
|
|
285
|
+
const connections = {};
|
|
286
|
+
for (const [key, val] of Object.entries(dbConfig)) {
|
|
287
|
+
if (key !== 'default' && typeof val === 'object' && val !== null) {
|
|
288
|
+
connections[key] = val;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (Object.keys(connections).length > 0) {
|
|
292
|
+
dbConfig.connections = connections;
|
|
293
|
+
if (!dbConfig.default) dbConfig.default = Object.keys(connections)[0];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (dbConfig.connections) {
|
|
297
|
+
this._connectionManager.configure(dbConfig);
|
|
298
|
+
SqlModel.setConnectionManager(this._connectionManager);
|
|
299
|
+
MongoModel.setConnectionManager(this._connectionManager);
|
|
300
|
+
}
|
|
278
301
|
}
|
|
279
302
|
|
|
303
|
+
// OpenAPI / Swagger UI 라우트 등록 (21-openapi.md)
|
|
304
|
+
this._registerDocsRoutes();
|
|
305
|
+
|
|
280
306
|
this._booted = true;
|
|
281
307
|
await this.emit('booted');
|
|
282
308
|
}
|
|
@@ -293,12 +319,15 @@ export default class Application {
|
|
|
293
319
|
*/
|
|
294
320
|
async listen(port, callback) {
|
|
295
321
|
if (typeof port === 'function') { callback = port; port = undefined; }
|
|
296
|
-
port = port
|
|
322
|
+
port = port ?? this.config.get('bridge.server.port') ?? this.config.get('bridge.port', 3000);
|
|
297
323
|
|
|
298
324
|
if (!this._booted) await this.boot();
|
|
299
325
|
|
|
300
|
-
// 포트 사용 여부 확인 —
|
|
301
|
-
await
|
|
326
|
+
// 포트 사용 여부 확인 — primary에서만 (워커는 SO_REUSEPORT로 공유)
|
|
327
|
+
const cluster = await import('node:cluster');
|
|
328
|
+
if (!cluster.default?.isWorker) {
|
|
329
|
+
await this._checkPort(port);
|
|
330
|
+
}
|
|
302
331
|
|
|
303
332
|
await this.emit('ready');
|
|
304
333
|
|
|
@@ -322,8 +351,14 @@ export default class Application {
|
|
|
322
351
|
if (this.file) this.file._bridge = this._bridge;
|
|
323
352
|
if (this.i18n) this.i18n._bridge = this._bridge;
|
|
324
353
|
}
|
|
354
|
+
// WS proxy 연결 (WsHandler에서 this.app.ws 사용)
|
|
355
|
+
this.ws = this._coreApp.ws;
|
|
325
356
|
}
|
|
326
357
|
this._registerBridgeRoutes(this._coreApp);
|
|
358
|
+
|
|
359
|
+
// WsHandler → Bridge WS 이벤트 연결
|
|
360
|
+
this._registerWsHandlers(this._coreApp);
|
|
361
|
+
|
|
327
362
|
this._coreApp.listen(port, callback);
|
|
328
363
|
// Bridge가 자체 graceful shutdown 처리 → Framework 중복 등록 불필요
|
|
329
364
|
} else {
|
|
@@ -367,6 +402,83 @@ export default class Application {
|
|
|
367
402
|
});
|
|
368
403
|
}
|
|
369
404
|
|
|
405
|
+
/**
|
|
406
|
+
* OpenAPI / Swagger UI 라우트 등록 (21-openapi.md)
|
|
407
|
+
*
|
|
408
|
+
* app.docs.enabled = true 시 활성화.
|
|
409
|
+
* 라우트: GET /docs, GET /docs/openapi.json, GET /docs/openapi.yaml
|
|
410
|
+
* @private
|
|
411
|
+
*/
|
|
412
|
+
_registerDocsRoutes() {
|
|
413
|
+
const docsConfig = this.config.get('app.docs');
|
|
414
|
+
if (!docsConfig || docsConfig.enabled === false) return;
|
|
415
|
+
|
|
416
|
+
const docsPath = docsConfig.path || '/docs';
|
|
417
|
+
const routes = this._router.getRoutes();
|
|
418
|
+
|
|
419
|
+
// OpenAPI spec 빌드 (1회, 캐싱)
|
|
420
|
+
this._openapi = new OpenAPI({
|
|
421
|
+
title: docsConfig.title || this.config.get('app.name', 'FuzionX') + ' API',
|
|
422
|
+
version: docsConfig.version || '1.0.0',
|
|
423
|
+
description: docsConfig.description || '',
|
|
424
|
+
servers: docsConfig.servers || [],
|
|
425
|
+
});
|
|
426
|
+
this._openapi.build(routes);
|
|
427
|
+
|
|
428
|
+
// JSON spec
|
|
429
|
+
this._router.get(`${docsPath}/openapi.json`, (ctx) => {
|
|
430
|
+
ctx.json(this._openapi.toJSON());
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// YAML spec
|
|
434
|
+
this._router.get(`${docsPath}/openapi.yaml`, (ctx) => {
|
|
435
|
+
ctx.setHeader('Content-Type', 'text/yaml; charset=utf-8');
|
|
436
|
+
ctx.send(this._openapi.toYAML());
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Swagger UI HTML (CDN — 외부 의존 없음)
|
|
440
|
+
this._router.get(docsPath, (ctx) => {
|
|
441
|
+
const specUrl = `${docsPath}/openapi.json`;
|
|
442
|
+
const title = docsConfig.title || 'API Docs';
|
|
443
|
+
ctx.html(Application._swaggerHtml(title, specUrl));
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
this.logger.info(`[docs] Swagger UI → ${docsPath}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Swagger UI HTML 생성 (CDN 기반)
|
|
451
|
+
* @private
|
|
452
|
+
*/
|
|
453
|
+
static _swaggerHtml(title, specUrl) {
|
|
454
|
+
return `<!DOCTYPE html>
|
|
455
|
+
<html lang="ko">
|
|
456
|
+
<head>
|
|
457
|
+
<meta charset="UTF-8">
|
|
458
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
459
|
+
<title>${title}</title>
|
|
460
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
|
461
|
+
<style>
|
|
462
|
+
body { margin: 0; background: #fafafa; }
|
|
463
|
+
.topbar { display: none; }
|
|
464
|
+
</style>
|
|
465
|
+
</head>
|
|
466
|
+
<body>
|
|
467
|
+
<div id="swagger-ui"></div>
|
|
468
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
469
|
+
<script>
|
|
470
|
+
SwaggerUIBundle({
|
|
471
|
+
url: '${specUrl}',
|
|
472
|
+
dom_id: '#swagger-ui',
|
|
473
|
+
deepLinking: true,
|
|
474
|
+
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
|
475
|
+
layout: 'BaseLayout',
|
|
476
|
+
});
|
|
477
|
+
</script>
|
|
478
|
+
</body>
|
|
479
|
+
</html>`;
|
|
480
|
+
}
|
|
481
|
+
|
|
370
482
|
/**
|
|
371
483
|
* 싱글톤 컨트롤러 초기화
|
|
372
484
|
* @private
|
|
@@ -403,6 +515,56 @@ export default class Application {
|
|
|
403
515
|
}
|
|
404
516
|
}
|
|
405
517
|
|
|
518
|
+
/**
|
|
519
|
+
* WsHandler → Bridge WS 이벤트 연결
|
|
520
|
+
* @private
|
|
521
|
+
*/
|
|
522
|
+
_registerWsHandlers(coreApp) {
|
|
523
|
+
if (!this._wsHandlers || this._wsHandlers.size === 0) return;
|
|
524
|
+
if (!coreApp.ws) return;
|
|
525
|
+
|
|
526
|
+
for (const [namespace, HandlerClass] of this._wsHandlers) {
|
|
527
|
+
const eventMap = HandlerClass.buildEventMap();
|
|
528
|
+
const wsNs = coreApp.ws(namespace);
|
|
529
|
+
|
|
530
|
+
wsNs.on('connect', (socket) => {
|
|
531
|
+
const inst = new HandlerClass(this);
|
|
532
|
+
console.log(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
|
|
533
|
+
inst.onConnect(socket);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
wsNs.on('message', (socket, rawMessage) => {
|
|
537
|
+
const inst = new HandlerClass(this);
|
|
538
|
+
let parsed;
|
|
539
|
+
try { parsed = typeof rawMessage === 'string' ? JSON.parse(rawMessage) : rawMessage; }
|
|
540
|
+
catch { parsed = { type: 'message', data: rawMessage }; }
|
|
541
|
+
const eventType = parsed.type || 'message';
|
|
542
|
+
const eventData = parsed.data || parsed;
|
|
543
|
+
console.log(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
|
|
544
|
+
const entry = eventMap.get(eventType);
|
|
545
|
+
if (entry) {
|
|
546
|
+
const result = entry.handler.call(inst, socket, eventData);
|
|
547
|
+
if (result && typeof result.then === 'function') {
|
|
548
|
+
result.then(r => { if (r) socket.send(JSON.stringify(r)); })
|
|
549
|
+
.catch(e => console.error(`[WS] error: ${eventType}`, e));
|
|
550
|
+
} else if (result) {
|
|
551
|
+
socket.send(JSON.stringify(result));
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
inst.onEvent(socket, eventType, eventData);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
wsNs.on('disconnect', (socket) => {
|
|
559
|
+
const inst = new HandlerClass(this);
|
|
560
|
+
console.log(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
|
|
561
|
+
inst.onDisconnect(socket);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
console.log(`[WS] 핸들러 등록: ${namespace} (events: ${[...eventMap.keys()].join(', ')})`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
406
568
|
/**
|
|
407
569
|
* 단일 라우트의 Bridge 핸들러 생성
|
|
408
570
|
* rawReq → Context → middleware → controller → toResponse
|
package/lib/core/AutoLoader.js
CHANGED
|
@@ -50,6 +50,7 @@ export default class AutoLoader {
|
|
|
50
50
|
*/
|
|
51
51
|
async load() {
|
|
52
52
|
await this.loadModels();
|
|
53
|
+
await this.loadControllers();
|
|
53
54
|
await this.loadServices();
|
|
54
55
|
await this.loadMiddleware();
|
|
55
56
|
await this.loadEvents();
|
|
@@ -72,6 +73,51 @@ export default class AutoLoader {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/**
|
|
77
|
+
* controllers/*.js → 싱글톤 인스턴스 + __handler__ static 등록
|
|
78
|
+
*
|
|
79
|
+
* 문서 01-routing-controllers.md:
|
|
80
|
+
* 프로토타입 메서드를 static 레퍼런스로 자동 등록하여
|
|
81
|
+
* 라우트에서 UserController.index 형태로 사용 가능.
|
|
82
|
+
*/
|
|
83
|
+
async loadControllers() {
|
|
84
|
+
const controllerDir = path.join(this.baseDir, 'controllers');
|
|
85
|
+
const files = await scanDir(controllerDir);
|
|
86
|
+
if (!this.app._controllers) {
|
|
87
|
+
this.app._controllers = new Map();
|
|
88
|
+
}
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
const mod = await import(file);
|
|
91
|
+
const ControllerClass = mod.default;
|
|
92
|
+
if (!ControllerClass) continue;
|
|
93
|
+
|
|
94
|
+
// __handler__ static 레퍼런스 자동 등록
|
|
95
|
+
AutoLoader.registerController(ControllerClass);
|
|
96
|
+
|
|
97
|
+
// 컨트롤러 이름 등록 (싱글톤 인스턴스는 boot 완료 후 생성)
|
|
98
|
+
const name = extractName(file, 'Controller');
|
|
99
|
+
this.app._controllers.set(name, ControllerClass);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 컨트롤러 프로토타입 메서드를 static __handler__ 레퍼런스로 등록
|
|
105
|
+
* @param {Function} ControllerClass
|
|
106
|
+
*/
|
|
107
|
+
static registerController(ControllerClass) {
|
|
108
|
+
const proto = ControllerClass.prototype;
|
|
109
|
+
for (const method of Object.getOwnPropertyNames(proto)) {
|
|
110
|
+
if (method === 'constructor') continue;
|
|
111
|
+
if (typeof proto[method] !== 'function') continue;
|
|
112
|
+
// static property로 __handler__ 디스크립터 등록
|
|
113
|
+
ControllerClass[method] = {
|
|
114
|
+
__handler__: true,
|
|
115
|
+
controller: ControllerClass,
|
|
116
|
+
method,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
75
121
|
/** services/*.js → DI register */
|
|
76
122
|
async loadServices() {
|
|
77
123
|
const files = await scanDir(path.join(this.baseDir, 'services'));
|
package/lib/core/Config.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* dot-notation으로 접근: config.get('app.auth.secret')
|
|
6
6
|
*
|
|
7
7
|
* .env 자동 로드 + ${VAR:default} 환경변수 치환.
|
|
8
|
+
* YAML 파일 직접 파싱 지원 (외부 의존 없이 내장 파서 사용).
|
|
8
9
|
*
|
|
9
10
|
* @see docs/framework/17-config.md
|
|
10
11
|
*/
|
|
@@ -26,6 +27,108 @@ export default class Config {
|
|
|
26
27
|
this._cache = new Map();
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* YAML 파일에서 설정 로드.
|
|
32
|
+
* configPath가 지정된 경우 Application 생성자에서 호출.
|
|
33
|
+
* bridge 섹션은 Rust에서 파싱하므로 JS 쪽은 database/app/themes 등을 로드.
|
|
34
|
+
* @param {string} configPath - 절대 경로
|
|
35
|
+
*/
|
|
36
|
+
loadYaml(configPath) {
|
|
37
|
+
if (!existsSync(configPath)) return;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
41
|
+
const parsed = Config.parseYaml(content);
|
|
42
|
+
|
|
43
|
+
// _raw에 병합 (기존 opts.config 우선)
|
|
44
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
45
|
+
if (this._raw[key] === undefined) {
|
|
46
|
+
this._raw[key] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 섹션 별칭 갱신
|
|
51
|
+
if (!Object.keys(this.bridge).length && parsed.bridge) this.bridge = parsed.bridge;
|
|
52
|
+
if (!Object.keys(this.database).length && parsed.database) this.database = parsed.database;
|
|
53
|
+
if (!Object.keys(this.app).length && parsed.app) this.app = parsed.app;
|
|
54
|
+
|
|
55
|
+
// 캐시 초기화
|
|
56
|
+
this._cache.clear();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(`[Config] YAML 파일 로드 실패 (${configPath}):`, err.message);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 간이 YAML 파서 — 외부 의존 없이 fuzionx.yaml 구조 파싱.
|
|
64
|
+
* 지원: 중첩 객체, 문자열, 숫자, boolean, 배열(인라인), 인용 문자열.
|
|
65
|
+
* @param {string} content
|
|
66
|
+
* @returns {object}
|
|
67
|
+
*/
|
|
68
|
+
static parseYaml(content) {
|
|
69
|
+
const result = {};
|
|
70
|
+
const lines = content.split('\n');
|
|
71
|
+
const stack = [{ indent: -1, obj: result }];
|
|
72
|
+
|
|
73
|
+
for (const rawLine of lines) {
|
|
74
|
+
// 주석/빈 줄 무시
|
|
75
|
+
const line = rawLine.replace(/#.*$/, '');
|
|
76
|
+
if (!line.trim()) continue;
|
|
77
|
+
|
|
78
|
+
const indent = line.search(/\S/);
|
|
79
|
+
const match = line.match(/^(\s*)([-\w.]+)\s*:\s*(.*)$/);
|
|
80
|
+
if (!match) continue;
|
|
81
|
+
|
|
82
|
+
const [, , key, rawValue] = match;
|
|
83
|
+
|
|
84
|
+
// 스택에서 현재 indent보다 깊거나 같은 레벨 제거
|
|
85
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
86
|
+
stack.pop();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const parent = stack[stack.length - 1].obj;
|
|
90
|
+
const value = rawValue.trim();
|
|
91
|
+
|
|
92
|
+
if (!value) {
|
|
93
|
+
// 하위 객체 시작
|
|
94
|
+
parent[key] = {};
|
|
95
|
+
stack.push({ indent, obj: parent[key] });
|
|
96
|
+
} else {
|
|
97
|
+
// 값 파싱
|
|
98
|
+
parent[key] = Config._parseYamlValue(value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* YAML 값 파싱 (문자열, 숫자, boolean, 배열)
|
|
107
|
+
* @private
|
|
108
|
+
*/
|
|
109
|
+
static _parseYamlValue(raw) {
|
|
110
|
+
// 인용 문자열
|
|
111
|
+
const quoted = raw.match(/^(['"])(.*)\1$/);
|
|
112
|
+
if (quoted) return quoted[2];
|
|
113
|
+
|
|
114
|
+
// boolean
|
|
115
|
+
if (raw === 'true') return true;
|
|
116
|
+
if (raw === 'false') return false;
|
|
117
|
+
if (raw === 'null' || raw === '~') return null;
|
|
118
|
+
|
|
119
|
+
// 인라인 배열 [a, b, c]
|
|
120
|
+
if (raw.startsWith('[') && raw.endsWith(']')) {
|
|
121
|
+
return raw.slice(1, -1).split(',').map(s => Config._parseYamlValue(s.trim()));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 숫자
|
|
125
|
+
const num = Number(raw);
|
|
126
|
+
if (!isNaN(num) && raw !== '') return num;
|
|
127
|
+
|
|
128
|
+
// 기본: 문자열
|
|
129
|
+
return raw;
|
|
130
|
+
}
|
|
131
|
+
|
|
29
132
|
/**
|
|
30
133
|
* .env 파일 로드 (17-config.md)
|
|
31
134
|
* 부트 시 자동 호출. 이미 설정된 환경변수는 덮어쓰지 않음.
|
package/lib/core/Context.js
CHANGED
|
@@ -313,19 +313,13 @@ export default class Context {
|
|
|
313
313
|
...data,
|
|
314
314
|
};
|
|
315
315
|
|
|
316
|
-
// Bridge SSR
|
|
317
|
-
if (this.app?.
|
|
318
|
-
|
|
319
|
-
const html = this.app._bridge.ssrRenderString(view, globals, this.locale);
|
|
320
|
-
return this.html(html);
|
|
321
|
-
} catch {} // Bridge SSR 실패 시 View 폴백
|
|
316
|
+
// Bridge Tera SSR — 폴백 없음
|
|
317
|
+
if (!this.app?._view) {
|
|
318
|
+
throw new Error('View not initialized — Bridge not available');
|
|
322
319
|
}
|
|
323
320
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
return this.html(html);
|
|
327
|
-
}
|
|
328
|
-
return this.send(`View '${view}' not found`);
|
|
321
|
+
const html = this.app._view.render(view, globals);
|
|
322
|
+
return this.html(html);
|
|
329
323
|
}
|
|
330
324
|
|
|
331
325
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -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
|
}
|
package/lib/view/View.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* View — 뷰 렌더링 (Bridge SSR)
|
|
2
|
+
* View — 뷰 렌더링 (Bridge Tera SSR)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Bridge의 ssrRenderFile N-API로 Tera 파일 기반 렌더링.
|
|
5
|
+
* {% extends %}, {% block %}, {% include %}, {{ t(key="...") }} 완전 지원.
|
|
6
|
+
*
|
|
7
|
+
* Bridge 없으면 반드시 에러 — 폴백 없음.
|
|
6
8
|
*
|
|
7
9
|
* @see docs/framework/03-views-templates.md
|
|
8
10
|
*/
|
|
@@ -32,10 +34,10 @@ export default class View {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
|
-
* 템플릿 렌더링 — Bridge
|
|
37
|
+
* 템플릿 렌더링 — Bridge ssrRenderFile N-API 사용
|
|
36
38
|
*
|
|
37
39
|
* 테마 경로 해석 (03-views-templates.md):
|
|
38
|
-
* 'home' →
|
|
40
|
+
* 'home' → ssrRenderFile(viewsPath/{theme}, 'pages/home.html', context, locale)
|
|
39
41
|
*
|
|
40
42
|
* @param {string} template - 'home', 'users/index' 등
|
|
41
43
|
* @param {object} [data] - 템플릿 변수
|
|
@@ -43,30 +45,39 @@ export default class View {
|
|
|
43
45
|
* @returns {string} - 렌더링된 HTML
|
|
44
46
|
*/
|
|
45
47
|
render(template, data = {}, theme) {
|
|
48
|
+
if (!this._bridge) {
|
|
49
|
+
throw new Error('Bridge not available — cannot render view');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof this._bridge.ssrRenderFile !== 'function') {
|
|
53
|
+
throw new Error('Bridge ssrRenderFile not available — rebuild with ssr feature');
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
const activeTheme = theme || data.theme || this.theme;
|
|
47
57
|
const mergedData = { ...this._globals, ...data, theme: activeTheme };
|
|
58
|
+
const locale = data.locale || 'ko';
|
|
59
|
+
|
|
60
|
+
// Tera glob 루트: views/{theme}/
|
|
61
|
+
const templateDir = join(this.viewsPath, activeTheme);
|
|
62
|
+
const contextJson = JSON.stringify(mergedData);
|
|
48
63
|
|
|
49
|
-
//
|
|
64
|
+
// 후보 이름: pages/{template}.html → {template}.html
|
|
50
65
|
const candidates = [
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
`pages/${template}.html`,
|
|
67
|
+
`${template}.html`,
|
|
53
68
|
];
|
|
54
69
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
} catch {
|
|
63
|
-
// 파일 없음 → 다음 후보
|
|
70
|
+
for (const templateName of candidates) {
|
|
71
|
+
try {
|
|
72
|
+
return this._bridge.ssrRenderFile(templateDir, templateName, contextJson, locale);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
// 파일 없음 → 다음 후보 (Tera init 에러는 파일 미존재)
|
|
75
|
+
if (!err.message?.includes('not found') && !err.message?.includes('init failed')) {
|
|
76
|
+
throw err; // 렌더링 에러는 그대로 전파
|
|
64
77
|
}
|
|
65
78
|
}
|
|
66
|
-
|
|
67
|
-
throw new Error(`View '${template}' not found. Searched: ${candidates.join(', ')}`);
|
|
68
79
|
}
|
|
69
80
|
|
|
70
|
-
throw new Error(`
|
|
81
|
+
throw new Error(`View '${template}' not found in theme '${activeTheme}'. Searched: ${candidates.join(', ')}`);
|
|
71
82
|
}
|
|
72
83
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzionx/framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
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.21",
|
|
38
38
|
"better-sqlite3": "^12.8.0",
|
|
39
39
|
"knex": "^3.2.5",
|
|
40
40
|
"mongoose": "^9.3.2",
|