@fuzionx/framework 0.1.39 → 0.1.41

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 (80) hide show
  1. package/README.md +501 -501
  2. package/bin/fx.js +12 -12
  3. package/cli/db-sync.js +99 -99
  4. package/cli/index.js +493 -493
  5. package/cli/templates/make/app/controllers/HomeController.js +14 -14
  6. package/cli/templates/make/app/routes/api.js +7 -7
  7. package/cli/templates/make/app/routes/web.js +5 -5
  8. package/cli/templates/make/app/views/default/errors/404.html +11 -11
  9. package/cli/templates/make/app/views/default/errors/500.html +14 -14
  10. package/cli/templates/make/app/views/default/layouts/main.html +22 -22
  11. package/cli/templates/make/app/views/default/pages/home.html +11 -11
  12. package/cli/templates/make/controller.js.tpl +40 -40
  13. package/cli/templates/make/event.js.tpl +8 -8
  14. package/cli/templates/make/job.js.tpl +10 -10
  15. package/cli/templates/make/middleware.js.tpl +10 -10
  16. package/cli/templates/make/model.js.tpl +15 -15
  17. package/cli/templates/make/service.js.tpl +15 -15
  18. package/cli/templates/make/task.js.tpl +15 -15
  19. package/cli/templates/make/test.js.tpl +7 -7
  20. package/cli/templates/make/worker.js.tpl +14 -14
  21. package/cli/templates/make/ws.js.tpl +18 -18
  22. package/index.js +67 -67
  23. package/lib/core/AppError.js +46 -46
  24. package/lib/core/Application.js +1006 -1006
  25. package/lib/core/AutoLoader.js +226 -226
  26. package/lib/core/Base.js +64 -64
  27. package/lib/core/Config.js +228 -228
  28. package/lib/core/Context.js +484 -484
  29. package/lib/database/ConnectionManager.js +208 -208
  30. package/lib/database/MariaModel.js +29 -29
  31. package/lib/database/Model.js +247 -247
  32. package/lib/database/ModelRegistry.js +72 -72
  33. package/lib/database/MongoModel.js +232 -232
  34. package/lib/database/Pagination.js +37 -37
  35. package/lib/database/PostgreModel.js +29 -29
  36. package/lib/database/QueryBuilder.js +172 -172
  37. package/lib/database/SQLiteModel.js +27 -27
  38. package/lib/database/SqlModel.js +257 -257
  39. package/lib/database/SqlQueryBuilder.js +332 -332
  40. package/lib/helpers/CryptoHelper.js +48 -48
  41. package/lib/helpers/FileHelper.js +61 -61
  42. package/lib/helpers/HashHelper.js +39 -39
  43. package/lib/helpers/I18nHelper.js +174 -174
  44. package/lib/helpers/Logger.js +108 -108
  45. package/lib/helpers/MediaHelper.js +84 -84
  46. package/lib/http/Controller.js +34 -34
  47. package/lib/http/ErrorHandler.js +136 -136
  48. package/lib/http/Middleware.js +43 -43
  49. package/lib/http/Router.js +109 -109
  50. package/lib/http/Validation.js +125 -125
  51. package/lib/middleware/apiAuth.js +79 -79
  52. package/lib/middleware/auth.js +42 -42
  53. package/lib/middleware/bodyParser.js +19 -19
  54. package/lib/middleware/cors.js +47 -47
  55. package/lib/middleware/csrf.js +32 -32
  56. package/lib/middleware/index.js +13 -13
  57. package/lib/middleware/session.js +27 -27
  58. package/lib/middleware/theme.js +20 -20
  59. package/lib/realtime/RoomManager.js +85 -85
  60. package/lib/realtime/WsHandler.js +107 -107
  61. package/lib/schedule/Job.js +38 -38
  62. package/lib/schedule/Queue.js +103 -103
  63. package/lib/schedule/Scheduler.js +171 -171
  64. package/lib/schedule/Task.js +39 -39
  65. package/lib/schedule/WorkerPool.js +225 -225
  66. package/lib/services/EventBus.js +94 -94
  67. package/lib/services/Service.js +261 -261
  68. package/lib/services/Storage.js +112 -112
  69. package/lib/utilities/ArrUtil.js +112 -112
  70. package/lib/utilities/DateUtil.js +98 -98
  71. package/lib/utilities/FunctionUtil.js +119 -119
  72. package/lib/utilities/NumUtil.js +75 -75
  73. package/lib/utilities/ObjectUtil.js +170 -170
  74. package/lib/utilities/PaginationUtil.js +81 -81
  75. package/lib/utilities/StrUtil.js +105 -105
  76. package/lib/utilities/index.js +18 -18
  77. package/lib/view/OpenAPI.js +231 -231
  78. package/lib/view/View.js +83 -83
  79. package/package.json +2 -2
  80. package/testing/index.js +232 -232
@@ -1,48 +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
- }
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
+ }
@@ -1,61 +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
- }
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
+ }
@@ -1,39 +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
- }
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
+ }
@@ -1,174 +1,174 @@
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
- if (this._bridge && typeof this._bridge.i18NTranslate === 'function') {
64
- try {
65
- const result = this._bridge.i18NTranslate(locale, key);
66
- if (result != null) return this._substitute(result, vars);
67
- } catch {} // Bridge 실패 시 JS 폴백
68
- }
69
-
70
- // ── JS 폴백 ──
71
- const messages = this._messages.get(locale)
72
- || this._messages.get(this.fallback)
73
- || this._messages.get(this.defaultLocale);
74
-
75
- const value = messages?.[key];
76
- if (value != null) return this._substitute(value, vars);
77
-
78
- // ── auto_complete: 누락 키 자동 등록 ──
79
- // default 값이 제공된 경우, Bridge에 누락 키를 보고하여
80
- // 모든 locale 파일에 자동으로 추가한다.
81
- if (vars.default) {
82
- this.updateMissing(key, vars.default);
83
- }
84
-
85
- return vars.default || key;
86
- }
87
-
88
- /**
89
- * 전체 locale 데이터 반환 (뷰 주입용)
90
- * @param {string} locale
91
- * @returns {object}
92
- */
93
- all(locale) {
94
- const messages = this._messages.get(locale) || this._messages.get(this.defaultLocale);
95
- if (!messages) return {};
96
- return { ...messages };
97
- }
98
-
99
- /** getAll alias (ctx.t.all() 호환) */
100
- getAll(locale) { return this.all(locale); }
101
-
102
- /**
103
- * 사용 가능한 locale 목록
104
- * @returns {string[]}
105
- */
106
- locales() {
107
- if (this._bridge && typeof this._bridge.i18NGetLocales === 'function') {
108
- try { return this._bridge.i18NGetLocales(); } catch {}
109
- }
110
- return [...this._messages.keys()];
111
- }
112
-
113
- /**
114
- * 누락 키 업데이트 (dev 모드)
115
- * @param {string} key
116
- * @param {string} value
117
- */
118
- updateMissing(key, value) {
119
- if (this._bridge && typeof this._bridge.i18NUpdateMissingKey === 'function') {
120
- try { this._bridge.i18NUpdateMissingKey(key, value); } catch {}
121
- }
122
- }
123
-
124
- /** {field} → value 치환 */
125
- _substitute(template, vars) {
126
- if (!vars || typeof template !== 'string') return template;
127
- return template.replace(/\{(\w+)\}/g, (_, key) => {
128
- if (key === 'locale' || key === 'default') return `{${key}}`; // 예약어
129
- return vars[key] !== undefined ? String(vars[key]) : `{${key}}`;
130
- });
131
- }
132
-
133
- /** YAML 간단 파서 (중첩 키 → dot-notation flat map) */
134
- _parseSimpleYaml(content) {
135
- const result = {};
136
- const lines = content.split('\n');
137
- const stack = [];
138
-
139
- for (const line of lines) {
140
- if (!line.trim() || line.trim().startsWith('#')) continue;
141
-
142
- const indent = line.search(/\S/);
143
- const match = line.match(/^(\s*)(\w[\w.]*)\s*:\s*(.*)$/);
144
- if (!match) continue;
145
-
146
- const [, , key, rawValue] = match;
147
- const level = Math.floor(indent / 2);
148
-
149
- while (stack.length > level) stack.pop();
150
- stack[level] = key;
151
-
152
- const value = rawValue.replace(/^["']|["']$/g, '').trim();
153
- if (value) {
154
- const fullKey = stack.filter(Boolean).join('.');
155
- result[fullKey] = value;
156
- }
157
- }
158
- return result;
159
- }
160
-
161
- /** 중첩 객체 → flat dot-notation */
162
- _flatten(obj, prefix = '') {
163
- const result = {};
164
- for (const [key, value] of Object.entries(obj)) {
165
- const fullKey = prefix ? `${prefix}.${key}` : key;
166
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
167
- Object.assign(result, this._flatten(value, fullKey));
168
- } else {
169
- result[fullKey] = String(value);
170
- }
171
- }
172
- return result;
173
- }
174
- }
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
+ if (this._bridge && typeof this._bridge.i18NTranslate === 'function') {
64
+ try {
65
+ const result = this._bridge.i18NTranslate(locale, key);
66
+ if (result != null) return this._substitute(result, vars);
67
+ } catch {} // Bridge 실패 시 JS 폴백
68
+ }
69
+
70
+ // ── JS 폴백 ──
71
+ const messages = this._messages.get(locale)
72
+ || this._messages.get(this.fallback)
73
+ || this._messages.get(this.defaultLocale);
74
+
75
+ const value = messages?.[key];
76
+ if (value != null) return this._substitute(value, vars);
77
+
78
+ // ── auto_complete: 누락 키 자동 등록 ──
79
+ // default 값이 제공된 경우, Bridge에 누락 키를 보고하여
80
+ // 모든 locale 파일에 자동으로 추가한다.
81
+ if (vars.default) {
82
+ this.updateMissing(key, vars.default);
83
+ }
84
+
85
+ return vars.default || key;
86
+ }
87
+
88
+ /**
89
+ * 전체 locale 데이터 반환 (뷰 주입용)
90
+ * @param {string} locale
91
+ * @returns {object}
92
+ */
93
+ all(locale) {
94
+ const messages = this._messages.get(locale) || this._messages.get(this.defaultLocale);
95
+ if (!messages) return {};
96
+ return { ...messages };
97
+ }
98
+
99
+ /** getAll alias (ctx.t.all() 호환) */
100
+ getAll(locale) { return this.all(locale); }
101
+
102
+ /**
103
+ * 사용 가능한 locale 목록
104
+ * @returns {string[]}
105
+ */
106
+ locales() {
107
+ if (this._bridge && typeof this._bridge.i18NGetLocales === 'function') {
108
+ try { return this._bridge.i18NGetLocales(); } catch {}
109
+ }
110
+ return [...this._messages.keys()];
111
+ }
112
+
113
+ /**
114
+ * 누락 키 업데이트 (dev 모드)
115
+ * @param {string} key
116
+ * @param {string} value
117
+ */
118
+ updateMissing(key, value) {
119
+ if (this._bridge && typeof this._bridge.i18NUpdateMissingKey === 'function') {
120
+ try { this._bridge.i18NUpdateMissingKey(key, value); } catch {}
121
+ }
122
+ }
123
+
124
+ /** {field} → value 치환 */
125
+ _substitute(template, vars) {
126
+ if (!vars || typeof template !== 'string') return template;
127
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
128
+ if (key === 'locale' || key === 'default') return `{${key}}`; // 예약어
129
+ return vars[key] !== undefined ? String(vars[key]) : `{${key}}`;
130
+ });
131
+ }
132
+
133
+ /** YAML 간단 파서 (중첩 키 → dot-notation flat map) */
134
+ _parseSimpleYaml(content) {
135
+ const result = {};
136
+ const lines = content.split('\n');
137
+ const stack = [];
138
+
139
+ for (const line of lines) {
140
+ if (!line.trim() || line.trim().startsWith('#')) continue;
141
+
142
+ const indent = line.search(/\S/);
143
+ const match = line.match(/^(\s*)(\w[\w.]*)\s*:\s*(.*)$/);
144
+ if (!match) continue;
145
+
146
+ const [, , key, rawValue] = match;
147
+ const level = Math.floor(indent / 2);
148
+
149
+ while (stack.length > level) stack.pop();
150
+ stack[level] = key;
151
+
152
+ const value = rawValue.replace(/^["']|["']$/g, '').trim();
153
+ if (value) {
154
+ const fullKey = stack.filter(Boolean).join('.');
155
+ result[fullKey] = value;
156
+ }
157
+ }
158
+ return result;
159
+ }
160
+
161
+ /** 중첩 객체 → flat dot-notation */
162
+ _flatten(obj, prefix = '') {
163
+ const result = {};
164
+ for (const [key, value] of Object.entries(obj)) {
165
+ const fullKey = prefix ? `${prefix}.${key}` : key;
166
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
167
+ Object.assign(result, this._flatten(value, fullKey));
168
+ } else {
169
+ result[fullKey] = String(value);
170
+ }
171
+ }
172
+ return result;
173
+ }
174
+ }