@fuzionx/framework 0.1.2

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.
Files changed (45) hide show
  1. package/bin/fx.js +12 -0
  2. package/index.js +64 -0
  3. package/lib/core/AppError.js +46 -0
  4. package/lib/core/Application.js +553 -0
  5. package/lib/core/AutoLoader.js +162 -0
  6. package/lib/core/Base.js +64 -0
  7. package/lib/core/Config.js +122 -0
  8. package/lib/core/Context.js +429 -0
  9. package/lib/database/ConnectionManager.js +192 -0
  10. package/lib/database/MariaModel.js +29 -0
  11. package/lib/database/Model.js +247 -0
  12. package/lib/database/ModelRegistry.js +72 -0
  13. package/lib/database/MongoModel.js +232 -0
  14. package/lib/database/Pagination.js +37 -0
  15. package/lib/database/PostgreModel.js +29 -0
  16. package/lib/database/QueryBuilder.js +172 -0
  17. package/lib/database/SQLiteModel.js +27 -0
  18. package/lib/database/SqlModel.js +252 -0
  19. package/lib/database/SqlQueryBuilder.js +309 -0
  20. package/lib/helpers/CryptoHelper.js +48 -0
  21. package/lib/helpers/FileHelper.js +61 -0
  22. package/lib/helpers/HashHelper.js +39 -0
  23. package/lib/helpers/I18nHelper.js +170 -0
  24. package/lib/helpers/Logger.js +105 -0
  25. package/lib/helpers/MediaHelper.js +38 -0
  26. package/lib/http/Controller.js +34 -0
  27. package/lib/http/ErrorHandler.js +135 -0
  28. package/lib/http/Middleware.js +43 -0
  29. package/lib/http/Router.js +109 -0
  30. package/lib/http/Validation.js +124 -0
  31. package/lib/middleware/index.js +286 -0
  32. package/lib/realtime/RoomManager.js +85 -0
  33. package/lib/realtime/WsHandler.js +107 -0
  34. package/lib/schedule/Job.js +34 -0
  35. package/lib/schedule/Queue.js +90 -0
  36. package/lib/schedule/Scheduler.js +161 -0
  37. package/lib/schedule/Task.js +39 -0
  38. package/lib/schedule/WorkerPool.js +225 -0
  39. package/lib/services/EventBus.js +94 -0
  40. package/lib/services/Service.js +261 -0
  41. package/lib/services/Storage.js +112 -0
  42. package/lib/view/OpenAPI.js +231 -0
  43. package/lib/view/View.js +72 -0
  44. package/package.json +52 -0
  45. package/testing/index.js +232 -0
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Service — 비즈니스 로직 기본 클래스
3
+ *
4
+ * 컨트롤러는 얇게, 비즈니스 로직은 서비스에.
5
+ * 트랜잭션, 캐싱, 병렬 실행, 재시도, 뮤텍스 지원.
6
+ *
7
+ * @see docs/framework/07-service-layer.md
8
+ * @see docs/framework/class-design.mm.md (Service)
9
+ */
10
+ import Base from '../core/Base.js';
11
+ import { ServiceError } from '../core/AppError.js';
12
+
13
+ /** @type {Map<string, { value: *, expires: number }>} 전역 캐시 스토어 */
14
+ const _cache = new Map();
15
+
16
+ /** @type {Map<string, Promise>} 전역 뮤텍스 락 */
17
+ const _locks = new Map();
18
+
19
+ export default class Service extends Base {
20
+
21
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22
+ // 트랜잭션
23
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
24
+
25
+ /**
26
+ * SQL 트랜잭션 실행
27
+ * @param {Function} callback - (trx) => Promise<T>
28
+ * @returns {Promise<T>}
29
+ */
30
+ async transaction(callback) {
31
+ const cm = this.app?._connectionManager;
32
+ if (!cm) throw new Error('Database not configured for transactions');
33
+
34
+ const conn = cm.get();
35
+ if (conn.type === 'knex' && conn.db) {
36
+ return conn.db.transaction(callback);
37
+ }
38
+ throw new Error('SQL database not configured for transactions');
39
+ }
40
+
41
+ /**
42
+ * MongoDB 트랜잭션 실행
43
+ * @param {string} connName - MongoDB 연결명
44
+ * @param {Function} callback - (session) => Promise<T>
45
+ * @returns {Promise<T>}
46
+ */
47
+ async mongoTransaction(connName, callback) {
48
+ const cm = this.app?._connectionManager;
49
+ if (!cm) throw new Error('Database not configured');
50
+
51
+ const conn = cm.get(connName);
52
+ if (conn.type !== 'mongo' || !conn.mongoose) {
53
+ throw new Error(`MongoDB connection '${connName}' not found`);
54
+ }
55
+
56
+ const session = await conn.mongoose.startSession();
57
+ try {
58
+ session.startTransaction();
59
+ const result = await callback(session);
60
+ await session.commitTransaction();
61
+ return result;
62
+ } catch (err) {
63
+ await session.abortTransaction();
64
+ throw err;
65
+ } finally {
66
+ session.endSession();
67
+ }
68
+ }
69
+
70
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
71
+ // 캐시
72
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
73
+
74
+ /**
75
+ * TTL 기반 인메모리 캐시로 감싸기
76
+ *
77
+ * @param {string} key - 캐시 키
78
+ * @param {number} ttl - TTL (초)
79
+ * @param {Function} fn - 캐시 미스 시 실행할 함수
80
+ * @returns {Promise<*>}
81
+ *
82
+ * @example
83
+ * const stats = await this.withCache('dashboard:stats', 300, async () => {
84
+ * return this.db.Order.raw('SELECT SUM(total) ...');
85
+ * });
86
+ */
87
+ async withCache(key, ttl, fn) {
88
+ const cached = _cache.get(key);
89
+ if (cached && cached.expires > Date.now()) {
90
+ return cached.value;
91
+ }
92
+
93
+ const value = await fn();
94
+ _cache.set(key, {
95
+ value,
96
+ expires: Date.now() + (ttl * 1000),
97
+ });
98
+ return value;
99
+ }
100
+
101
+ /**
102
+ * 캐시 키 무효화
103
+ * @param {string} key
104
+ */
105
+ invalidateCache(key) {
106
+ _cache.delete(key);
107
+ }
108
+
109
+ /**
110
+ * 전체 캐시 초기화
111
+ */
112
+ clearCache() {
113
+ _cache.clear();
114
+ }
115
+
116
+ /**
117
+ * 캐시 존재 여부
118
+ * @param {string} key
119
+ * @returns {boolean}
120
+ */
121
+ hasCache(key) {
122
+ const cached = _cache.get(key);
123
+ return cached ? cached.expires > Date.now() : false;
124
+ }
125
+
126
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
127
+ // 병렬 실행
128
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
129
+
130
+ /**
131
+ * 여러 함수를 병렬 실행
132
+ *
133
+ * @param {Array<Function>} tasks - () => Promise 형태의 함수 배열
134
+ * @param {object} [opts]
135
+ * @param {boolean} [opts.failFast=false] - true면 하나 실패 시 전체 reject
136
+ * @returns {Promise<Array>} 결과 배열 (failFast=false 시 에러도 포함)
137
+ *
138
+ * @example
139
+ * const [users, orders, stats] = await this.runParallel([
140
+ * () => this.db.User.count(),
141
+ * () => this.db.Order.where('status', 'active').get(),
142
+ * () => this.calculateStats(),
143
+ * ]);
144
+ */
145
+ async runParallel(tasks, opts = {}) {
146
+ const promises = tasks.map(fn => fn());
147
+
148
+ if (opts.failFast) {
149
+ return Promise.all(promises);
150
+ }
151
+
152
+ const results = await Promise.allSettled(promises);
153
+ return results.map(r => {
154
+ if (r.status === 'fulfilled') return r.value;
155
+ // 실패 시 에러 객체 반환 (throw하지 않음)
156
+ return r.reason;
157
+ });
158
+ }
159
+
160
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
161
+ // 재시도
162
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
163
+
164
+ /**
165
+ * 재시도 + 지수 백오프
166
+ *
167
+ * @param {Function} fn - 실행할 함수
168
+ * @param {object} [opts]
169
+ * @param {number} [opts.retries=3] - 재시도 횟수
170
+ * @param {number} [opts.delay=1000] - 초기 지연 (ms)
171
+ * @param {number} [opts.backoff=2] - 백오프 배수
172
+ * @param {Function} [opts.shouldRetry] - (err, attempt) => boolean
173
+ * @returns {Promise<*>}
174
+ *
175
+ * @example
176
+ * const data = await this.retryable(
177
+ * () => fetch('https://api.external.com/data'),
178
+ * { retries: 3, delay: 1000, backoff: 2 }
179
+ * );
180
+ */
181
+ async retryable(fn, opts = {}) {
182
+ const { retries = 3, delay = 1000, backoff = 2, shouldRetry } = opts;
183
+ let lastError;
184
+ let currentDelay = delay;
185
+
186
+ for (let attempt = 0; attempt <= retries; attempt++) {
187
+ try {
188
+ return await fn();
189
+ } catch (err) {
190
+ lastError = err;
191
+
192
+ if (attempt === retries) break;
193
+ if (shouldRetry && !shouldRetry(err, attempt)) break;
194
+
195
+ await new Promise(r => setTimeout(r, currentDelay));
196
+ currentDelay *= backoff;
197
+ }
198
+ }
199
+
200
+ throw lastError;
201
+ }
202
+
203
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
204
+ // 뮤텍스
205
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
206
+
207
+ /**
208
+ * 동시 실행 방지 (in-process lock)
209
+ *
210
+ * 같은 key에 대해 동시에 1개만 실행.
211
+ * 이전 실행 완료 후 다음 실행.
212
+ *
213
+ * @param {string} key - 락 키
214
+ * @param {Function} fn - 실행할 함수
215
+ * @returns {Promise<*>}
216
+ *
217
+ * @example
218
+ * await this.mutex('order:process:' + orderId, async () => {
219
+ * const order = await this.db.Order.findOrFail(orderId);
220
+ * await order.update({ status: 'processing' });
221
+ * });
222
+ */
223
+ async mutex(key, fn) {
224
+ // 이전 락이 있으면 대기
225
+ while (_locks.has(key)) {
226
+ await _locks.get(key);
227
+ }
228
+
229
+ // 새 락 설정
230
+ let releaseLock;
231
+ const lockPromise = new Promise(resolve => { releaseLock = resolve; });
232
+ _locks.set(key, lockPromise);
233
+
234
+ try {
235
+ return await fn();
236
+ } finally {
237
+ _locks.delete(key);
238
+ releaseLock();
239
+ }
240
+ }
241
+
242
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
243
+ // 에러
244
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
245
+
246
+ /**
247
+ * 에러 생성 헬퍼
248
+ * @param {string} message
249
+ * @param {number} [status=400]
250
+ * @returns {ServiceError}
251
+ */
252
+ error(message, status = 400) {
253
+ return new ServiceError(message, status);
254
+ }
255
+ }
256
+
257
+ /**
258
+ * 캐시 스토어 접근 (테스트용)
259
+ * @returns {Map}
260
+ */
261
+ export { _cache, _locks };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Storage — 파일 저장 추상화 (Local / S3)
3
+ *
4
+ * put(path, tempPath) — 임시파일 → 최종 위치 이동.
5
+ * Local: file.move(tempPath, fullPath)
6
+ * S3: Node.js 스트리밍 업로드 (큐 Task 권장)
7
+ *
8
+ * @see docs/framework/15-file-upload.md
9
+ * @see docs/framework/class-design.mm.md (Storage)
10
+ */
11
+ import { promises as fs } from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ export default class Storage {
15
+ /**
16
+ * @param {object} opts
17
+ * @param {string} [opts.driver='local'] - 'local' | 's3'
18
+ * @param {string} [opts.basePath='./storage'] - 로컬 저장 경로
19
+ * @param {object} [opts.s3] - S3 설정 { bucket, region, credentials }
20
+ * @param {import('./FileHelper.js').default} [opts.fileHelper] - FileHelper 인스턴스
21
+ */
22
+ constructor(opts = {}) {
23
+ this.driver = opts.driver || 'local';
24
+ this.basePath = opts.basePath || './storage';
25
+ this._s3 = opts.s3 || null;
26
+ this._file = opts.fileHelper || null;
27
+ }
28
+
29
+ /**
30
+ * 임시파일 → 최종 위치 이동
31
+ * @param {string} filePath - 상대 경로 (e.g. 'uploads/avatar.jpg')
32
+ * @param {string} tempPath - 임시 파일 경로 (/tmp/fuzionx_upload_xxx)
33
+ * @returns {Promise<string>} - 저장된 URL 또는 경로
34
+ */
35
+ async put(filePath, tempPath) {
36
+ if (this.driver === 's3') {
37
+ return this._s3Put(filePath, tempPath);
38
+ }
39
+ const fullPath = path.join(this.basePath, filePath);
40
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
41
+ // FileHelper로 이동 (Bridge file.move → Rust 직접)
42
+ if (this._file) {
43
+ await this._file.move(tempPath, fullPath);
44
+ } else {
45
+ // 폴백: fs.rename (cross-device 시 copy+unlink)
46
+ await fs.rename(tempPath, fullPath).catch(async () => {
47
+ await fs.copyFile(tempPath, fullPath);
48
+ await fs.unlink(tempPath);
49
+ });
50
+ }
51
+ return fullPath;
52
+ }
53
+
54
+ /**
55
+ * 파일 읽기
56
+ * @param {string} filePath
57
+ * @returns {Promise<Buffer>}
58
+ */
59
+ async get(filePath) {
60
+ if (this.driver === 's3') {
61
+ return this._s3Get(filePath);
62
+ }
63
+ return fs.readFile(path.join(this.basePath, filePath));
64
+ }
65
+
66
+ /**
67
+ * 파일 삭제
68
+ * @param {string} filePath
69
+ * @returns {Promise<void>}
70
+ */
71
+ async delete(filePath) {
72
+ if (this.driver === 's3') {
73
+ return this._s3Delete(filePath);
74
+ }
75
+ await fs.unlink(path.join(this.basePath, filePath));
76
+ }
77
+
78
+ /**
79
+ * 파일 존재 여부
80
+ * @param {string} filePath
81
+ * @returns {Promise<boolean>}
82
+ */
83
+ async exists(filePath) {
84
+ try {
85
+ if (this.driver === 's3') {
86
+ return this._s3Exists(filePath);
87
+ }
88
+ await fs.access(path.join(this.basePath, filePath));
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 파일 URL (public 접근용)
97
+ * @param {string} filePath
98
+ * @returns {string}
99
+ */
100
+ url(filePath) {
101
+ if (this.driver === 's3' && this._s3) {
102
+ return `https://${this._s3.bucket}.s3.${this._s3.region}.amazonaws.com/${filePath}`;
103
+ }
104
+ return `/storage/${filePath}`;
105
+ }
106
+
107
+ // ── S3 스텁 (Phase 5+ 구현) ──
108
+ async _s3Put(filePath, tempPath) { throw new Error('S3 driver not implemented'); }
109
+ async _s3Get(filePath) { throw new Error('S3 driver not implemented'); }
110
+ async _s3Delete(filePath) { throw new Error('S3 driver not implemented'); }
111
+ async _s3Exists(filePath) { throw new Error('S3 driver not implemented'); }
112
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * OpenAPI — OpenAPI 3.0 spec 빌더 + Swagger UI
3
+ *
4
+ * 라우트 정의 + Joi 스키마에서 OpenAPI spec 자동 생성.
5
+ *
6
+ * @see docs/framework/21-openapi.md
7
+ */
8
+
9
+ /**
10
+ * Joi 스키마를 JSON Schema로 변환 (간단 버전)
11
+ * @param {object} joiSchema
12
+ * @returns {object}
13
+ */
14
+ function joiToJsonSchema(joiSchema) {
15
+ if (!joiSchema) return {};
16
+
17
+ // Joi는 describe()로 메타 추출 가능
18
+ if (typeof joiSchema.describe === 'function') {
19
+ return convertJoiDescribe(joiSchema.describe());
20
+ }
21
+
22
+ // 이미 plain object면 그대로
23
+ return joiSchema;
24
+ }
25
+
26
+ function convertJoiDescribe(desc) {
27
+ if (!desc) return {};
28
+
29
+ if (desc.type === 'object') {
30
+ const properties = {};
31
+ const required = [];
32
+
33
+ if (desc.keys) {
34
+ for (const [key, child] of Object.entries(desc.keys)) {
35
+ properties[key] = convertJoiDescribe(child);
36
+ if (child.flags?.presence === 'required') {
37
+ required.push(key);
38
+ }
39
+ }
40
+ }
41
+
42
+ return {
43
+ type: 'object',
44
+ properties,
45
+ ...(required.length ? { required } : {}),
46
+ };
47
+ }
48
+
49
+ const result = { type: desc.type || 'string' };
50
+
51
+ if (desc.flags?.description) result.description = desc.flags.description;
52
+ if (desc.rules) {
53
+ for (const rule of desc.rules) {
54
+ if (rule.name === 'min') result.minLength = rule.args.limit;
55
+ if (rule.name === 'max') result.maxLength = rule.args.limit;
56
+ }
57
+ }
58
+ if (desc.allow) result.enum = desc.allow;
59
+ if (desc.flags?.default !== undefined) result.default = desc.flags.default;
60
+
61
+ return result;
62
+ }
63
+
64
+ export default class OpenAPI {
65
+ /**
66
+ * @param {object} opts
67
+ * @param {string} [opts.title='FuzionX API']
68
+ * @param {string} [opts.version='1.0.0']
69
+ * @param {string} [opts.description]
70
+ * @param {Array} [opts.servers]
71
+ */
72
+ constructor(opts = {}) {
73
+ this.title = opts.title || 'FuzionX API';
74
+ this.version = opts.version || '1.0.0';
75
+ this.description = opts.description || '';
76
+ this.servers = opts.servers || [];
77
+ this._spec = null;
78
+ }
79
+
80
+ /**
81
+ * 라우트 목록에서 OpenAPI spec 빌드
82
+ * @param {Array} routes - Router.getRoutes() 결과
83
+ * @returns {object} - OpenAPI 3.0 spec
84
+ */
85
+ build(routes) {
86
+ const paths = {};
87
+
88
+ for (const route of routes) {
89
+ if (!route.docs) continue;
90
+
91
+ const openApiPath = route.path.replace(/:(\w+)/g, '{$1}');
92
+
93
+ if (!paths[openApiPath]) paths[openApiPath] = {};
94
+
95
+ const method = route.method.toLowerCase();
96
+ const operation = {
97
+ summary: route.docs.summary || '',
98
+ tags: route.docs.tags || [],
99
+ };
100
+
101
+ if (route.docs.description) {
102
+ operation.description = route.docs.description;
103
+ }
104
+ if (route.docs.deprecated) {
105
+ operation.deprecated = true;
106
+ }
107
+
108
+ // parameters (query + params)
109
+ const parameters = [];
110
+
111
+ if (route.validate?.params) {
112
+ const schema = joiToJsonSchema(route.validate.params);
113
+ if (schema.properties) {
114
+ for (const [name, prop] of Object.entries(schema.properties)) {
115
+ parameters.push({
116
+ name,
117
+ in: 'path',
118
+ required: true,
119
+ schema: prop,
120
+ });
121
+ }
122
+ }
123
+ }
124
+
125
+ if (route.validate?.query) {
126
+ const schema = joiToJsonSchema(route.validate.query);
127
+ if (schema.properties) {
128
+ for (const [name, prop] of Object.entries(schema.properties)) {
129
+ parameters.push({
130
+ name,
131
+ in: 'query',
132
+ required: schema.required?.includes(name) || false,
133
+ schema: prop,
134
+ });
135
+ }
136
+ }
137
+ }
138
+
139
+ if (parameters.length) operation.parameters = parameters;
140
+
141
+ // requestBody (body)
142
+ if (route.validate?.body) {
143
+ const schema = joiToJsonSchema(route.validate.body);
144
+ operation.requestBody = {
145
+ content: {
146
+ 'application/json': { schema },
147
+ },
148
+ };
149
+ }
150
+
151
+ // responses
152
+ operation.responses = route.docs.responses || { '200': { description: 'OK' } };
153
+
154
+ // security
155
+ if (route.middleware?.includes('auth') || route.middleware?.includes('apiAuth')) {
156
+ const scheme = route.middleware.includes('apiAuth') ? 'bearer' : 'session';
157
+ operation.security = [{ [scheme]: [] }];
158
+ }
159
+
160
+ paths[openApiPath][method] = operation;
161
+ }
162
+
163
+ this._spec = {
164
+ openapi: '3.0.3',
165
+ info: {
166
+ title: this.title,
167
+ version: this.version,
168
+ ...(this.description ? { description: this.description } : {}),
169
+ },
170
+ ...(this.servers.length ? { servers: this.servers } : {}),
171
+ paths,
172
+ components: {
173
+ securitySchemes: {
174
+ bearer: {
175
+ type: 'http',
176
+ scheme: 'bearer',
177
+ bearerFormat: 'JWT',
178
+ },
179
+ session: {
180
+ type: 'apiKey',
181
+ in: 'cookie',
182
+ name: 'sid',
183
+ },
184
+ },
185
+ },
186
+ };
187
+
188
+ return this._spec;
189
+ }
190
+
191
+ /**
192
+ * 캐시된 spec 반환 (JSON)
193
+ */
194
+ toJSON() {
195
+ return this._spec;
196
+ }
197
+
198
+ /**
199
+ * YAML 포맷 (간단 변환)
200
+ */
201
+ toYAML() {
202
+ return jsonToSimpleYaml(this._spec);
203
+ }
204
+ }
205
+
206
+ function jsonToSimpleYaml(obj, indent = 0) {
207
+ const spaces = ' '.repeat(indent);
208
+ let result = '';
209
+
210
+ for (const [key, value] of Object.entries(obj)) {
211
+ if (value === null || value === undefined) continue;
212
+
213
+ if (Array.isArray(value)) {
214
+ result += `${spaces}${key}:\n`;
215
+ for (const item of value) {
216
+ if (typeof item === 'object') {
217
+ result += `${spaces} -\n${jsonToSimpleYaml(item, indent + 4)}`;
218
+ } else {
219
+ result += `${spaces} - ${item}\n`;
220
+ }
221
+ }
222
+ } else if (typeof value === 'object') {
223
+ result += `${spaces}${key}:\n${jsonToSimpleYaml(value, indent + 2)}`;
224
+ } else {
225
+ const v = typeof value === 'string' ? `'${value}'` : value;
226
+ result += `${spaces}${key}: ${v}\n`;
227
+ }
228
+ }
229
+
230
+ return result;
231
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * View — 뷰 렌더링 (테마 + Tera SSR)
3
+ *
4
+ * ctx.theme에 따른 테마 경로 해석.
5
+ * Bridge Tera SSR 또는 JS 폴백.
6
+ *
7
+ * @see docs/framework/03-views-templates.md
8
+ */
9
+ export default class View {
10
+ /**
11
+ * @param {object} opts
12
+ * @param {string} opts.viewsPath - 뷰 파일 루트 경로
13
+ * @param {string} [opts.theme='default'] - 기본 테마
14
+ * @param {object} [opts.bridge] - Bridge SSR 인스턴스 (Tera)
15
+ * @param {object} [opts.globals] - 모든 뷰에 주입되는 전역 변수
16
+ */
17
+ constructor(opts = {}) {
18
+ this.viewsPath = opts.viewsPath || '';
19
+ this.theme = opts.theme || 'default';
20
+ this._bridge = opts.bridge || null;
21
+ this._globals = opts.globals || {};
22
+ }
23
+
24
+ /**
25
+ * 전역 변수 추가
26
+ * @param {string} key
27
+ * @param {*} value
28
+ */
29
+ share(key, value) {
30
+ this._globals[key] = value;
31
+ }
32
+
33
+ /**
34
+ * 템플릿 렌더링
35
+ *
36
+ * 테마 경로 해석 (03-views-templates.md):
37
+ * 'pages/users/index' → {theme}/pages/users/index.html
38
+ *
39
+ * @param {string} template - 'users/index' 등
40
+ * @param {object} [data] - 템플릿 변수
41
+ * @param {string} [theme] - 테마 오버라이드 (ctx.theme에서 전달)
42
+ * @returns {string} - 렌더링된 HTML
43
+ */
44
+ render(template, data = {}, theme) {
45
+ const activeTheme = theme || data.theme || this.theme;
46
+ const mergedData = { ...this._globals, ...data, theme: activeTheme };
47
+
48
+ // Bridge Tera SSR이 있으면 위임
49
+ if (this._bridge) {
50
+ const templatePath = `${activeTheme}/${template}.html`;
51
+ try {
52
+ return this._bridge.render(templatePath, mergedData);
53
+ } catch {
54
+ // SSR 실패 시 폴백
55
+ }
56
+ }
57
+
58
+ // 폴백: 간단한 변수 치환
59
+ return this._simpleRender(template, mergedData);
60
+ }
61
+
62
+ /**
63
+ * @private 간단 치환 (Bridge 없을 때)
64
+ */
65
+ _simpleRender(template, data) {
66
+ let html = `<!-- template: ${template} -->`;
67
+ for (const [key, value] of Object.entries(data)) {
68
+ html += `\n<!-- ${key}: ${JSON.stringify(value)} -->`;
69
+ }
70
+ return html;
71
+ }
72
+ }