@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,309 @@
1
+ /**
2
+ * SqlQueryBuilder — SQL 쿼리 빌더 (SQLite / MariaDB / PostgreSQL 공통)
3
+ *
4
+ * QueryBuilder를 상속하여 터미널 메서드(get/count/update/delete)에서
5
+ * 실제 SQL을 생성하고 실행합니다.
6
+ *
7
+ * @see docs/framework/02-database-orm.md
8
+ */
9
+ import QueryBuilder from './QueryBuilder.js';
10
+
11
+ export default class SqlQueryBuilder extends QueryBuilder {
12
+ constructor(model) {
13
+ super(model);
14
+ }
15
+
16
+ /**
17
+ * 결과 조회 — SQL SELECT 실행
18
+ * @returns {Promise<Array<import('./Model.js').default>>}
19
+ */
20
+ async get() {
21
+ const conn = this._model.getConnection();
22
+
23
+ if (conn.type === 'sqlite' && conn.db) {
24
+ return this._executeSqlite(conn.db, 'select');
25
+ }
26
+
27
+ if (conn.type === 'knex' && conn.db) {
28
+ return this._executeKnex(conn.db, 'select');
29
+ }
30
+
31
+ // Stub: no connection
32
+ return [];
33
+ }
34
+
35
+ /**
36
+ * 단일 결과
37
+ * @returns {Promise<object|null>}
38
+ */
39
+ async first() {
40
+ this._limit = 1;
41
+ const results = await this.get();
42
+ return results[0] || null;
43
+ }
44
+
45
+ /**
46
+ * 카운트
47
+ * @returns {Promise<number>}
48
+ */
49
+ async count() {
50
+ const conn = this._model.getConnection();
51
+
52
+ if (conn.type === 'sqlite' && conn.db) {
53
+ const rows = this._executeSqlite(conn.db, 'count');
54
+ return rows[0]?.cnt ?? 0;
55
+ }
56
+
57
+ if (conn.type === 'knex' && conn.db) {
58
+ let query = conn.db(this._model.table);
59
+ query = this._applyWheres(query);
60
+ query = this._applySoftDelete(query);
61
+ const [result] = await query.count('* as cnt');
62
+ // PostgreSQL returns string, MySQL returns number; key can vary
63
+ const cnt = result?.cnt ?? result?.['count(*)'] ?? result?.count ?? 0;
64
+ return Number(cnt);
65
+ }
66
+
67
+ return 0;
68
+ }
69
+
70
+ /**
71
+ * 조건부 수정
72
+ * @param {object} data
73
+ * @returns {Promise<number>} 수정된 행 수
74
+ */
75
+ async update(data) {
76
+ if (this._model.timestamps && !data.updated_at) {
77
+ const conn = this._model.getConnection();
78
+ const isMysql = conn?.type === 'knex' && (conn.driver === 'mariadb' || conn.driver === 'mysql');
79
+ data.updated_at = isMysql
80
+ ? new Date().toISOString().replace('T', ' ').replace('Z', '')
81
+ : new Date().toISOString();
82
+ }
83
+
84
+ const conn = this._model.getConnection();
85
+
86
+ if (conn.type === 'sqlite' && conn.db) {
87
+ const { sql, params } = this._buildUpdateSql(data);
88
+ const result = conn.db.prepare(sql).run(...params);
89
+ return result.changes;
90
+ }
91
+
92
+ if (conn.type === 'knex' && conn.db) {
93
+ let query = conn.db(this._model.table);
94
+ query = this._applyWheres(query);
95
+ query = this._applySoftDelete(query);
96
+ return query.update(data);
97
+ }
98
+
99
+ return 0;
100
+ }
101
+
102
+ /**
103
+ * 조건부 삭제
104
+ * @returns {Promise<number>}
105
+ */
106
+ async delete() {
107
+ const conn = this._model.getConnection();
108
+
109
+ // softDelete → UPDATE deleted_at
110
+ if (this._softDelete) {
111
+ const isMysql = conn?.type === 'knex' && (conn.driver === 'mariadb' || conn.driver === 'mysql');
112
+ const now = isMysql
113
+ ? new Date().toISOString().replace('T', ' ').replace('Z', '')
114
+ : new Date().toISOString();
115
+ return this.update({ deleted_at: now });
116
+ }
117
+
118
+ if (conn.type === 'sqlite' && conn.db) {
119
+ const { whereClause, whereParams } = this._buildWhereSql();
120
+ const sql = `DELETE FROM ${this._model.table}${whereClause}`;
121
+ const result = conn.db.prepare(sql).run(...whereParams);
122
+ return result.changes;
123
+ }
124
+
125
+ if (conn.type === 'knex' && conn.db) {
126
+ let query = conn.db(this._model.table);
127
+ query = this._applyWheres(query);
128
+ return query.delete();
129
+ }
130
+
131
+ return 0;
132
+ }
133
+
134
+ /**
135
+ * 페이지네이션
136
+ * @param {number} page
137
+ * @param {number} perPage
138
+ */
139
+ async paginate(page = 1, perPage = 20) {
140
+ const total = await this.count();
141
+ this._limit = perPage;
142
+ this._offset = (page - 1) * perPage;
143
+ const data = await this.get();
144
+ return {
145
+ data,
146
+ page,
147
+ perPage,
148
+ total,
149
+ lastPage: Math.ceil(total / perPage),
150
+ };
151
+ }
152
+
153
+ // ━━━━━━━━━━ SQLite 실행 ━━━━━━━━━━
154
+
155
+ /**
156
+ * SQLite SELECT / COUNT 실행
157
+ * @private
158
+ */
159
+ _executeSqlite(db, mode = 'select') {
160
+ const { whereClause, whereParams } = this._buildWhereSql();
161
+
162
+ const selectColumns = mode === 'count'
163
+ ? 'COUNT(*) as cnt'
164
+ : (this._selects ? this._selects.join(', ') : '*');
165
+
166
+ let sql = `SELECT ${selectColumns} FROM ${this._model.table}${whereClause}`;
167
+
168
+ // ORDER BY
169
+ if (this._orders.length > 0 && mode !== 'count') {
170
+ const orderParts = this._orders.map(o => `${o.column} ${o.direction.toUpperCase()}`);
171
+ sql += ` ORDER BY ${orderParts.join(', ')}`;
172
+ }
173
+
174
+ // LIMIT / OFFSET
175
+ if (this._limit != null && mode !== 'count') {
176
+ sql += ` LIMIT ${this._limit}`;
177
+ }
178
+ if (this._offset != null && mode !== 'count') {
179
+ sql += ` OFFSET ${this._offset}`;
180
+ }
181
+
182
+ const rows = db.prepare(sql).all(...whereParams);
183
+
184
+ if (mode === 'count') return rows;
185
+ return rows.map(row => new this._model(row));
186
+ }
187
+
188
+ /**
189
+ * SQL WHERE절 생성
190
+ * @private
191
+ * @returns {{ whereClause: string, whereParams: Array }}
192
+ */
193
+ _buildWhereSql() {
194
+ const parts = [];
195
+ const params = [];
196
+
197
+ // softDelete 자동 필터
198
+ if (this._softDelete && !this._withTrashed) {
199
+ if (this._onlyTrashed) {
200
+ parts.push('deleted_at IS NOT NULL');
201
+ } else {
202
+ parts.push('deleted_at IS NULL');
203
+ }
204
+ }
205
+
206
+ for (const w of this._wheres) {
207
+ const prefix = parts.length > 0
208
+ ? (w.type === 'or' ? ' OR ' : ' AND ')
209
+ : '';
210
+
211
+ if (w.op === 'IN') {
212
+ const placeholders = w.value.map(() => '?').join(', ');
213
+ parts.push(`${prefix}${w.key} IN (${placeholders})`);
214
+ params.push(...w.value);
215
+ } else if (w.op === 'IS NULL') {
216
+ parts.push(`${prefix}${w.key} IS NULL`);
217
+ } else if (w.op === 'IS NOT NULL') {
218
+ parts.push(`${prefix}${w.key} IS NOT NULL`);
219
+ } else {
220
+ parts.push(`${prefix}${w.key} ${w.op} ?`);
221
+ params.push(w.value);
222
+ }
223
+ }
224
+
225
+ const whereClause = parts.length > 0 ? ` WHERE ${parts.join('')}` : '';
226
+ return { whereClause, whereParams: params };
227
+ }
228
+
229
+ /**
230
+ * SQL UPDATE문 생성
231
+ * @private
232
+ */
233
+ _buildUpdateSql(data) {
234
+ const { whereClause, whereParams } = this._buildWhereSql();
235
+ const columns = Object.keys(data);
236
+ const sets = columns.map(c => `${c} = ?`).join(', ');
237
+ const sql = `UPDATE ${this._model.table} SET ${sets}${whereClause}`;
238
+ const params = [...Object.values(data), ...whereParams];
239
+ return { sql, params };
240
+ }
241
+
242
+ // ━━━━━━━━━━ Knex 실행 ━━━━━━━━━━
243
+
244
+ /**
245
+ * Knex SELECT 실행
246
+ * @private
247
+ */
248
+ async _executeKnex(knex, mode = 'select') {
249
+ let query = knex(this._model.table);
250
+
251
+ // SELECT columns
252
+ if (this._selects) {
253
+ query = query.select(...this._selects);
254
+ }
255
+
256
+ // WHERE
257
+ query = this._applyWheres(query);
258
+
259
+ // softDelete
260
+ query = this._applySoftDelete(query);
261
+
262
+ // ORDER BY
263
+ for (const o of this._orders) {
264
+ query = query.orderBy(o.column, o.direction);
265
+ }
266
+
267
+ // LIMIT / OFFSET
268
+ if (this._limit != null) query = query.limit(this._limit);
269
+ if (this._offset != null) query = query.offset(this._offset);
270
+
271
+ const rows = await query;
272
+ return rows.map(row => new this._model(row));
273
+ }
274
+
275
+ /**
276
+ * Knex에 WHERE 조건 적용
277
+ * @private
278
+ */
279
+ _applyWheres(query) {
280
+ for (const w of this._wheres) {
281
+ const method = w.type === 'or' ? 'orWhere' : 'where';
282
+ if (w.op === 'IN') {
283
+ query = query.whereIn(w.key, w.value);
284
+ } else if (w.op === 'IS NULL') {
285
+ query = query.whereNull(w.key);
286
+ } else if (w.op === 'IS NOT NULL') {
287
+ query = query.whereNotNull(w.key);
288
+ } else {
289
+ query = query[method](w.key, w.op, w.value);
290
+ }
291
+ }
292
+ return query;
293
+ }
294
+
295
+ /**
296
+ * softDelete 필터 적용
297
+ * @private
298
+ */
299
+ _applySoftDelete(query) {
300
+ if (this._softDelete && !this._withTrashed) {
301
+ if (this._onlyTrashed) {
302
+ query = query.whereNotNull('deleted_at');
303
+ } else {
304
+ query = query.whereNull('deleted_at');
305
+ }
306
+ }
307
+ return query;
308
+ }
309
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * CryptoHelper — Bridge crypto N-API 래퍼
3
+ *
4
+ * @see docs/framework/19-utilities.md — "1. Crypto (app.crypto)"
5
+ * @see packages/fuzionx/lib/crypto.js (Core 래퍼)
6
+ */
7
+ import { randomUUID, createHash as nodeCreateHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
8
+
9
+ export default class CryptoHelper {
10
+ constructor(bridge) { this._bridge = bridge; }
11
+
12
+ uuid() {
13
+ if (this._bridge?.cryptoUuid) return this._bridge.cryptoUuid();
14
+ return randomUUID();
15
+ }
16
+
17
+ md5(input) {
18
+ if (this._bridge?.cryptoMd5) return this._bridge.cryptoMd5(input);
19
+ return nodeCreateHash('md5').update(input).digest('hex');
20
+ }
21
+
22
+ sha256(input) {
23
+ if (this._bridge?.cryptoSha256) return this._bridge.cryptoSha256(input);
24
+ return nodeCreateHash('sha256').update(input).digest('hex');
25
+ }
26
+
27
+ encrypt(key, plaintext) {
28
+ if (this._bridge?.cryptoEncryptAes) return this._bridge.cryptoEncryptAes(key, plaintext);
29
+ const keyBuf = Buffer.from(key.padEnd(32, '0').slice(0, 32));
30
+ const iv = randomBytes(12);
31
+ const cipher = createCipheriv('aes-256-gcm', keyBuf, iv);
32
+ let encrypted = cipher.update(plaintext, 'utf-8', 'base64');
33
+ encrypted += cipher.final('base64');
34
+ const tag = cipher.getAuthTag().toString('base64');
35
+ return `${iv.toString('base64')}.${encrypted}.${tag}`;
36
+ }
37
+
38
+ decrypt(key, ciphertext) {
39
+ if (this._bridge?.cryptoDecryptAes) return this._bridge.cryptoDecryptAes(key, ciphertext);
40
+ const [ivB64, encB64, tagB64] = ciphertext.split('.');
41
+ const keyBuf = Buffer.from(key.padEnd(32, '0').slice(0, 32));
42
+ const decipher = createDecipheriv('aes-256-gcm', keyBuf, Buffer.from(ivB64, 'base64'));
43
+ decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
44
+ let decrypted = decipher.update(encB64, 'base64', 'utf-8');
45
+ decrypted += decipher.final('utf-8');
46
+ return decrypted;
47
+ }
48
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * FileHelper — Bridge file N-API 래퍼
3
+ *
4
+ * @see docs/framework/19-utilities.md — "4. File (app.file)"
5
+ * @see packages/fuzionx/lib/file.js (Core 래퍼)
6
+ */
7
+ import { promises as fs } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { randomUUID } from 'node:crypto';
10
+
11
+ export default class FileHelper {
12
+ constructor(bridge) { this._bridge = bridge; }
13
+
14
+ async move(src, dst) {
15
+ if (this._bridge?.fileMoveFile) return this._bridge.fileMoveFile(src, dst);
16
+ await fs.mkdir(path.dirname(dst), { recursive: true });
17
+ await fs.rename(src, dst).catch(async () => {
18
+ await fs.copyFile(src, dst);
19
+ await fs.unlink(src);
20
+ });
21
+ }
22
+
23
+ async copy(src, dst) {
24
+ if (this._bridge?.fileCopyFile) return this._bridge.fileCopyFile(src, dst);
25
+ await fs.mkdir(path.dirname(dst), { recursive: true });
26
+ await fs.copyFile(src, dst);
27
+ const stat = await fs.stat(dst);
28
+ return stat.size;
29
+ }
30
+
31
+ async ensureDir(dirPath) {
32
+ if (this._bridge?.fileEnsureDir) return this._bridge.fileEnsureDir(dirPath);
33
+ await fs.mkdir(dirPath, { recursive: true });
34
+ }
35
+
36
+ async size(filePath) {
37
+ if (this._bridge?.fileSize) return this._bridge.fileSize(filePath);
38
+ const stat = await fs.stat(filePath);
39
+ return stat.size;
40
+ }
41
+
42
+ async exists(filePath) {
43
+ if (this._bridge?.fileExists) return this._bridge.fileExists(filePath);
44
+ try { await fs.access(filePath); return true; } catch { return false; }
45
+ }
46
+
47
+ async remove(filePath) {
48
+ if (this._bridge?.fileRemove) return this._bridge.fileRemove(filePath);
49
+ await fs.unlink(filePath);
50
+ }
51
+
52
+ tempPath(prefix = 'fx') {
53
+ if (this._bridge?.fileTempPath) return this._bridge.fileTempPath(prefix);
54
+ return path.join('/tmp', `${prefix}-${randomUUID()}`);
55
+ }
56
+
57
+ extension(filePath) {
58
+ const ext = path.extname(filePath).slice(1).toLowerCase();
59
+ return ext || null;
60
+ }
61
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * HashHelper — Bridge hash N-API 래퍼
3
+ *
4
+ * @see docs/framework/19-utilities.md — "2. Hash (app.hash)"
5
+ * @see packages/fuzionx/lib/hash.js (Core 래퍼)
6
+ */
7
+ import { randomUUID, createHash as nodeCreateHash } from 'node:crypto';
8
+
9
+ export default class HashHelper {
10
+ constructor(bridge) { this._bridge = bridge; }
11
+
12
+ /** ⚠️ JS 폴백: SHA-256 기반 유사 구현 (테스트 전용, 프로덕션은 Bridge 필수) */
13
+ bcrypt(password, cost = 12) {
14
+ if (this._bridge?.hashBcrypt) return this._bridge.hashBcrypt(password, cost);
15
+ const salt = randomUUID().replace(/-/g, '').slice(0, 22);
16
+ return `$2b$${cost}$${salt}${nodeCreateHash('sha256').update(password + salt).digest('hex')}`;
17
+ }
18
+
19
+ bcryptVerify(password, hash) {
20
+ if (this._bridge?.hashBcryptVerify) return this._bridge.hashBcryptVerify(password, hash);
21
+ const salt = hash.slice(7, 29);
22
+ const expected = `$2b$12$${salt}${nodeCreateHash('sha256').update(password + salt).digest('hex')}`;
23
+ return hash === expected;
24
+ }
25
+
26
+ argon2(password) {
27
+ if (this._bridge?.hashArgon2) return this._bridge.hashArgon2(password);
28
+ const salt = randomUUID().replace(/-/g, '');
29
+ return `$argon2id$v=19$m=65536,t=3,p=4$${salt}$${nodeCreateHash('sha256').update(password + salt).digest('hex')}`;
30
+ }
31
+
32
+ argon2Verify(password, hash) {
33
+ if (this._bridge?.hashArgon2Verify) return this._bridge.hashArgon2Verify(password, hash);
34
+ const parts = hash.split('$');
35
+ const salt = parts[4] || '';
36
+ const expected = nodeCreateHash('sha256').update(password + salt).digest('hex');
37
+ return parts[5] === expected;
38
+ }
39
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * I18nHelper — Bridge i18n N-API 위임 + JS 폴백
3
+ *
4
+ * Bridge의 i18NTranslate(locale, key) 직접 호출.
5
+ * Bridge 없으면 JS 파일 기반 폴백.
6
+ *
7
+ * @see docs/framework/18-i18n.md
8
+ * @see packages/fuzionx/lib/i18n.js (Core 래퍼 참조)
9
+ */
10
+ import { promises as fs } from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ export default class I18nHelper {
14
+ /**
15
+ * @param {object} [opts]
16
+ * @param {string} [opts.defaultLocale='ko']
17
+ * @param {string} [opts.fallback='en']
18
+ * @param {string} [opts.dir='./locales']
19
+ * @param {object} [opts.bridge] - Bridge N-API 인스턴스
20
+ */
21
+ constructor(opts = {}) {
22
+ this.defaultLocale = opts.defaultLocale || 'ko';
23
+ this.fallback = opts.fallback || 'en';
24
+ this.dir = opts.dir || './locales';
25
+ this._bridge = opts.bridge || null;
26
+ this._messages = new Map(); // locale → { flat key: value }
27
+ this._loaded = false;
28
+ }
29
+
30
+ /**
31
+ * 번역 파일 로드 (JS 폴백용)
32
+ */
33
+ async load() {
34
+ if (this._loaded) return;
35
+ try {
36
+ const entries = await fs.readdir(this.dir, { withFileTypes: true });
37
+ for (const entry of entries) {
38
+ if (!entry.isFile()) continue;
39
+ const ext = path.extname(entry.name);
40
+ if (ext !== '.yaml' && ext !== '.yml' && ext !== '.json') continue;
41
+ const locale = path.basename(entry.name, ext);
42
+ const content = await fs.readFile(path.join(this.dir, entry.name), 'utf-8');
43
+ const data = ext === '.json' ? JSON.parse(content) : this._parseSimpleYaml(content);
44
+ this._messages.set(locale, this._flatten(data));
45
+ }
46
+ } catch {} // locales/ 없으면 무시
47
+ this._loaded = true;
48
+ }
49
+
50
+ /**
51
+ * 번역 키 조회
52
+ *
53
+ * Bridge가 있으면 bridge.i18NTranslate(locale, key) 호출.
54
+ * Bridge 없으면 JS 메시지 맵에서 조회.
55
+ *
56
+ * @param {string} locale
57
+ * @param {string} key - dot-notation (e.g. 'auth.login_required')
58
+ * @param {object} [vars] - 치환 변수 + { default? }
59
+ * @returns {string}
60
+ */
61
+ translate(locale, key, vars = {}) {
62
+ // ── Bridge N-API 위임 ──
63
+ // Core i18n.js 참조: bridge.i18NTranslate(locale, key)
64
+ if (this._bridge && typeof this._bridge.i18NTranslate === 'function') {
65
+ try {
66
+ const result = this._bridge.i18NTranslate(locale, key);
67
+ if (result != null) return this._substitute(result, vars);
68
+ } catch {} // Bridge 실패 시 JS 폴백
69
+ }
70
+
71
+ // ── JS 폴백 ──
72
+ const messages = this._messages.get(locale)
73
+ || this._messages.get(this.fallback)
74
+ || this._messages.get(this.defaultLocale);
75
+ if (!messages) return vars.default || key;
76
+
77
+ const value = messages[key];
78
+ if (value == null) return vars.default || key;
79
+
80
+ return this._substitute(value, vars);
81
+ }
82
+
83
+ /**
84
+ * 전체 locale 데이터 반환 (뷰 주입용)
85
+ * @param {string} locale
86
+ * @returns {object}
87
+ */
88
+ all(locale) {
89
+ // Bridge: i18NGetLocales() 는 locale 목록만 반환, 전체 데이터 API 없음
90
+ const messages = this._messages.get(locale) || this._messages.get(this.defaultLocale);
91
+ if (!messages) return {};
92
+ return { ...messages };
93
+ }
94
+
95
+ /** getAll alias (ctx.t.all() 호환) */
96
+ getAll(locale) { return this.all(locale); }
97
+
98
+ /**
99
+ * 사용 가능한 locale 목록
100
+ * @returns {string[]}
101
+ */
102
+ locales() {
103
+ if (this._bridge && typeof this._bridge.i18NGetLocales === 'function') {
104
+ try { return this._bridge.i18NGetLocales(); } catch {}
105
+ }
106
+ return [...this._messages.keys()];
107
+ }
108
+
109
+ /**
110
+ * 누락 키 업데이트 (dev 모드)
111
+ * @param {string} key
112
+ * @param {string} value
113
+ */
114
+ updateMissing(key, value) {
115
+ if (this._bridge && typeof this._bridge.i18NUpdateMissingKey === 'function') {
116
+ try { this._bridge.i18NUpdateMissingKey(key, value); } catch {}
117
+ }
118
+ }
119
+
120
+ /** {field} → value 치환 */
121
+ _substitute(template, vars) {
122
+ if (!vars || typeof template !== 'string') return template;
123
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
124
+ if (key === 'locale' || key === 'default') return `{${key}}`; // 예약어
125
+ return vars[key] !== undefined ? String(vars[key]) : `{${key}}`;
126
+ });
127
+ }
128
+
129
+ /** YAML 간단 파서 (중첩 키 → dot-notation flat map) */
130
+ _parseSimpleYaml(content) {
131
+ const result = {};
132
+ const lines = content.split('\n');
133
+ const stack = [];
134
+
135
+ for (const line of lines) {
136
+ if (!line.trim() || line.trim().startsWith('#')) continue;
137
+
138
+ const indent = line.search(/\S/);
139
+ const match = line.match(/^(\s*)(\w[\w.]*)\s*:\s*(.*)$/);
140
+ if (!match) continue;
141
+
142
+ const [, , key, rawValue] = match;
143
+ const level = Math.floor(indent / 2);
144
+
145
+ while (stack.length > level) stack.pop();
146
+ stack[level] = key;
147
+
148
+ const value = rawValue.replace(/^["']|["']$/g, '').trim();
149
+ if (value) {
150
+ const fullKey = stack.filter(Boolean).join('.');
151
+ result[fullKey] = value;
152
+ }
153
+ }
154
+ return result;
155
+ }
156
+
157
+ /** 중첩 객체 → flat dot-notation */
158
+ _flatten(obj, prefix = '') {
159
+ const result = {};
160
+ for (const [key, value] of Object.entries(obj)) {
161
+ const fullKey = prefix ? `${prefix}.${key}` : key;
162
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
163
+ Object.assign(result, this._flatten(value, fullKey));
164
+ } else {
165
+ result[fullKey] = String(value);
166
+ }
167
+ }
168
+ return result;
169
+ }
170
+ }