@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.
@@ -9,9 +9,11 @@ bridge:
9
9
  per_ip: 1000
10
10
 
11
11
  database:
12
- main:
13
- driver: sqlite
14
- path: ./storage/database.sqlite
12
+ default: main
13
+ connections:
14
+ main:
15
+ driver: sqlite
16
+ database: ./storage/database.sqlite
15
17
 
16
18
  app:
17
19
  name: '{{name}}'
@@ -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 && dbConfig.connections) {
275
- this._connectionManager.configure(dbConfig);
276
- SqlModel.setConnectionManager(this._connectionManager);
277
- MongoModel.setConnectionManager(this._connectionManager);
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 || this.config.get('bridge.server.port', 3000);
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 this._checkPort(port);
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
@@ -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'));
@@ -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
  * 부트 시 자동 호출. 이미 설정된 환경변수는 덮어쓰지 않음.
@@ -313,19 +313,13 @@ export default class Context {
313
313
  ...data,
314
314
  };
315
315
 
316
- // Bridge SSR (i18n.render bridge.ssrRenderString)
317
- if (this.app?._bridge && typeof this.app._bridge.ssrRenderString === 'function') {
318
- try {
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
- if (this.app?._view) {
325
- const html = this.app._view.render(view, globals);
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
- * ctx.theme에 따른 테마 경로 해석.
5
- * Bridge renderTemplateFile N-API로 렌더링.
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 renderTemplateFile N-API 사용
37
+ * 템플릿 렌더링 — Bridge ssrRenderFile N-API 사용
36
38
  *
37
39
  * 테마 경로 해석 (03-views-templates.md):
38
- * 'home' → {viewsPath}/{theme}/pages/home.html
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
- // 파일 경로 해석: pages/{name}.html 우선
64
+ // 후보 이름: pages/{template}.html → {template}.html
50
65
  const candidates = [
51
- join(this.viewsPath, activeTheme, 'pages', `${template}.html`),
52
- join(this.viewsPath, activeTheme, `${template}.html`),
66
+ `pages/${template}.html`,
67
+ `${template}.html`,
53
68
  ];
54
69
 
55
- // Bridge renderTemplateFile N-API 호출
56
- if (this._bridge && typeof this._bridge.renderTemplateFile === 'function') {
57
- const contextJson = JSON.stringify(mergedData);
58
-
59
- for (const filePath of candidates) {
60
- try {
61
- return this._bridge.renderTemplateFile(filePath, contextJson);
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(`Bridge not available cannot render view '${template}'`);
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.9",
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.9",
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",