@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.
- package/bin/fx.js +12 -0
- package/index.js +64 -0
- package/lib/core/AppError.js +46 -0
- package/lib/core/Application.js +553 -0
- package/lib/core/AutoLoader.js +162 -0
- package/lib/core/Base.js +64 -0
- package/lib/core/Config.js +122 -0
- package/lib/core/Context.js +429 -0
- package/lib/database/ConnectionManager.js +192 -0
- package/lib/database/MariaModel.js +29 -0
- package/lib/database/Model.js +247 -0
- package/lib/database/ModelRegistry.js +72 -0
- package/lib/database/MongoModel.js +232 -0
- package/lib/database/Pagination.js +37 -0
- package/lib/database/PostgreModel.js +29 -0
- package/lib/database/QueryBuilder.js +172 -0
- package/lib/database/SQLiteModel.js +27 -0
- package/lib/database/SqlModel.js +252 -0
- package/lib/database/SqlQueryBuilder.js +309 -0
- package/lib/helpers/CryptoHelper.js +48 -0
- package/lib/helpers/FileHelper.js +61 -0
- package/lib/helpers/HashHelper.js +39 -0
- package/lib/helpers/I18nHelper.js +170 -0
- package/lib/helpers/Logger.js +105 -0
- package/lib/helpers/MediaHelper.js +38 -0
- package/lib/http/Controller.js +34 -0
- package/lib/http/ErrorHandler.js +135 -0
- package/lib/http/Middleware.js +43 -0
- package/lib/http/Router.js +109 -0
- package/lib/http/Validation.js +124 -0
- package/lib/middleware/index.js +286 -0
- package/lib/realtime/RoomManager.js +85 -0
- package/lib/realtime/WsHandler.js +107 -0
- package/lib/schedule/Job.js +34 -0
- package/lib/schedule/Queue.js +90 -0
- package/lib/schedule/Scheduler.js +161 -0
- package/lib/schedule/Task.js +39 -0
- package/lib/schedule/WorkerPool.js +225 -0
- package/lib/services/EventBus.js +94 -0
- package/lib/services/Service.js +261 -0
- package/lib/services/Storage.js +112 -0
- package/lib/view/OpenAPI.js +231 -0
- package/lib/view/View.js +72 -0
- package/package.json +52 -0
- 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
|
+
}
|