@fuzionx/framework 0.1.29 → 0.1.31

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 (63) hide show
  1. package/cli/index.js +230 -114
  2. package/cli/templates/{app/fuzionx → make/app}/controllers/HomeController.js +1 -0
  3. package/cli/templates/{app/tester → make/app}/views/default/errors/404.html +0 -4
  4. package/cli/templates/{app/fuzionx/views/default/errors/404.html → make/app/views/default/errors/500.html} +1 -2
  5. package/cli/templates/make/app/views/default/pages/home.html +11 -0
  6. package/index.js +3 -0
  7. package/lib/core/Application.js +31 -6
  8. package/lib/core/Context.js +30 -1
  9. package/lib/helpers/I18nHelper.js +10 -6
  10. package/lib/middleware/apiAuth.js +79 -0
  11. package/lib/middleware/auth.js +42 -0
  12. package/lib/middleware/bodyParser.js +19 -0
  13. package/lib/middleware/cors.js +47 -0
  14. package/lib/middleware/csrf.js +32 -0
  15. package/lib/middleware/index.js +8 -277
  16. package/lib/middleware/session.js +27 -0
  17. package/lib/middleware/theme.js +20 -0
  18. package/lib/schedule/Job.js +4 -0
  19. package/lib/schedule/Queue.js +20 -8
  20. package/lib/schedule/Scheduler.js +84 -75
  21. package/lib/utilities/ArrUtil.js +112 -0
  22. package/lib/utilities/DateUtil.js +98 -0
  23. package/lib/utilities/FunctionUtil.js +119 -0
  24. package/lib/utilities/NumUtil.js +75 -0
  25. package/lib/utilities/ObjectUtil.js +170 -0
  26. package/lib/utilities/PaginationUtil.js +81 -0
  27. package/lib/utilities/StrUtil.js +105 -0
  28. package/lib/utilities/index.js +18 -0
  29. package/package.json +2 -2
  30. package/cli/templates/app/.env.example.tpl +0 -14
  31. package/cli/templates/app/.gitignore.tpl +0 -4
  32. package/cli/templates/app/app.js.tpl +0 -6
  33. package/cli/templates/app/database/models/User.js +0 -9
  34. package/cli/templates/app/fuzionx/views/default/errors/500.html +0 -14
  35. package/cli/templates/app/fuzionx/views/default/pages/home.html +0 -188
  36. package/cli/templates/app/fuzionx.yaml.tpl +0 -202
  37. package/cli/templates/app/locales/en.json +0 -52
  38. package/cli/templates/app/locales/ko.json +0 -52
  39. package/cli/templates/app/package.json.tpl +0 -16
  40. package/cli/templates/app/shared/events/userEvents.js +0 -10
  41. package/cli/templates/app/shared/jobs/CleanupJob.js +0 -18
  42. package/cli/templates/app/shared/jobs/EmailTask.js +0 -17
  43. package/cli/templates/app/shared/jobs/VideoPreviewTask.js +0 -47
  44. package/cli/templates/app/shared/workers/heavy.js +0 -18
  45. package/cli/templates/app/tester/controllers/FileController.js +0 -288
  46. package/cli/templates/app/tester/controllers/HomeController.js +0 -36
  47. package/cli/templates/app/tester/controllers/UserController.js +0 -43
  48. package/cli/templates/app/tester/middleware/RequestLogger.js +0 -13
  49. package/cli/templates/app/tester/routes/api.js +0 -397
  50. package/cli/templates/app/tester/routes/web.js +0 -8
  51. package/cli/templates/app/tester/services/UserService.js +0 -52
  52. package/cli/templates/app/tester/views/default/errors/500.html +0 -14
  53. package/cli/templates/app/tester/views/default/layouts/main.html +0 -82
  54. package/cli/templates/app/tester/views/default/pages/home.html +0 -56
  55. package/cli/templates/app/tester/views/default/pages/i18n.html +0 -104
  56. package/cli/templates/app/tester/views/default/pages/upload.html +0 -149
  57. package/cli/templates/app/tester/views/default/pages/websocket.html +0 -239
  58. package/cli/templates/app/tester/views/default/partials/footer.html +0 -8
  59. package/cli/templates/app/tester/views/default/partials/header.html +0 -20
  60. package/cli/templates/app/tester/ws/ChatHandler.js +0 -98
  61. /package/cli/templates/{app/fuzionx/routes/api.js.tpl → make/app/routes/api.js} +0 -0
  62. /package/cli/templates/{app/fuzionx/routes/web.js.tpl → make/app/routes/web.js} +0 -0
  63. /package/cli/templates/{app/fuzionx → make/app}/views/default/layouts/main.html +0 -0
package/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * CLI — fx 명령어 핸들러
3
3
  *
4
- * fx new <name> 스캐폴딩
4
+ * fx make:app --type=ssr|spa 디렉토리 생성 (고정 이름)
5
5
  * fx make:controller <Name> 컨트롤러 생성
6
6
  * fx make:service <Name> 서비스 생성
7
7
  * fx make:model <Name> 모델 생성
@@ -12,6 +12,8 @@
12
12
  * fx make:event <Name> 이벤트 핸들러 생성
13
13
  * fx make:worker <Name> Worker 생성
14
14
  * fx make:test <Name> 테스트 생성
15
+ * fx dev:spa FuzionX + Vite 동시 실행
16
+ * fx build:spa Vite 프로덕션 빌드
15
17
  *
16
18
  * @see docs/framework/16-cli.md
17
19
  */
@@ -120,24 +122,9 @@ export async function makeFile(type, name, baseDir = '.', appName) {
120
122
  }
121
123
 
122
124
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
123
- // fx new <name> — 앱 스캐폴딩
125
+ // 유틸리티
124
126
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
125
127
 
126
- const APP_FILES = [
127
- { tpl: 'app/package.json.tpl', dest: 'package.json' },
128
- { tpl: 'app/fuzionx.yaml.tpl', dest: 'fuzionx.yaml' },
129
- { tpl: 'app/app.js.tpl', dest: 'app.js' },
130
- { tpl: 'app/.env.example.tpl', dest: '.env.example' },
131
- { tpl: 'app/.env.example.tpl', dest: '.env' },
132
- { tpl: 'app/.gitignore.tpl', dest: '.gitignore' },
133
- ];
134
-
135
- /** fuzionx 앱 템플릿 (.tpl 파일은 치환, 그 외는 직접 복사) */
136
- const FUZIONX_TPL_FILES = [
137
- { tpl: 'app/fuzionx/routes/web.js.tpl', dest: 'app/fuzionx/routes/web.js' },
138
- { tpl: 'app/fuzionx/routes/api.js.tpl', dest: 'app/fuzionx/routes/api.js' },
139
- ];
140
-
141
128
  /** 디렉토리 재귀 복사 (템플릿 → 프로젝트) */
142
129
  async function copyDirRecursive(src, dst) {
143
130
  await fs.mkdir(dst, { recursive: true });
@@ -153,91 +140,6 @@ async function copyDirRecursive(src, dst) {
153
140
  }
154
141
  }
155
142
 
156
- /** 스캐폴딩 디렉토리 (빈 디렉토리 + .gitkeep) */
157
- const APP_DIRS = [
158
- // fuzionx 앱 (개발자 작업 영역)
159
- 'app/fuzionx/services',
160
- 'app/fuzionx/middleware',
161
- 'app/fuzionx/ws',
162
- // tester 앱 (빈 폴더가 아닌 것은 copyDirRecursive로 처리)
163
- // 인프라
164
- 'database/migrations',
165
- 'database/seeds',
166
- 'storage/logs',
167
- 'storage/uploads',
168
- 'public/css',
169
- 'public/js',
170
- 'tests',
171
- ];
172
-
173
- /**
174
- * fx new <name> — 앱 스캐폴딩 (fuzionx + tester 2-app 구조)
175
- * @param {string} name - 프로젝트 이름
176
- * @param {string} [targetDir] - 기본: ./<name>
177
- */
178
- export async function createApp(name, targetDir) {
179
- const dir = targetDir || path.resolve(name);
180
- const vars = {
181
- name,
182
- dbName: name.replace(/-/g, '_'),
183
- };
184
-
185
- // 빈 디렉토리 생성 (.gitkeep 포함)
186
- for (const d of APP_DIRS) {
187
- const fullDir = path.join(dir, d);
188
- await fs.mkdir(fullDir, { recursive: true });
189
- const files = await fs.readdir(fullDir).catch(() => []);
190
- if (files.length === 0) {
191
- await fs.writeFile(path.join(fullDir, '.gitkeep'), '');
192
- }
193
- }
194
-
195
- // 루트 템플릿 파일 생성 (.tpl 치환)
196
- for (const { tpl, dest } of APP_FILES) {
197
- const content = await loadTemplate(tpl, vars);
198
- const fullPath = path.join(dir, dest);
199
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
200
- await fs.writeFile(fullPath, content);
201
- }
202
-
203
- // fuzionx 앱 — .tpl 파일 (치환 필요)
204
- for (const { tpl, dest } of FUZIONX_TPL_FILES) {
205
- const content = await loadTemplate(tpl, vars);
206
- const fullPath = path.join(dir, dest);
207
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
208
- await fs.writeFile(fullPath, content);
209
- }
210
-
211
- // fuzionx 앱 — 직접 복사 (controllers, views)
212
- const fuzionxSrc = path.join(TPL_DIR, 'app/fuzionx');
213
- await copyDirRecursive(
214
- path.join(fuzionxSrc, 'controllers'),
215
- path.join(dir, 'app/fuzionx/controllers'),
216
- );
217
- await copyDirRecursive(
218
- path.join(fuzionxSrc, 'views'),
219
- path.join(dir, 'app/fuzionx/views'),
220
- );
221
-
222
- // tester 앱 — 전체 복사
223
- const testerSrc = path.join(TPL_DIR, 'app/tester');
224
- await copyDirRecursive(testerSrc, path.join(dir, 'app/tester'));
225
-
226
- // shared — 전체 복사
227
- const sharedSrc = path.join(TPL_DIR, 'app/shared');
228
- await copyDirRecursive(sharedSrc, path.join(dir, 'shared'));
229
-
230
- // database/models — 복사
231
- const dbSrc = path.join(TPL_DIR, 'app/database');
232
- await copyDirRecursive(dbSrc, path.join(dir, 'database'));
233
-
234
- // locales — 복사
235
- const localesSrc = path.join(TPL_DIR, 'app/locales');
236
- await copyDirRecursive(localesSrc, path.join(dir, 'locales'));
237
-
238
- return dir;
239
- }
240
-
241
143
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
242
144
  // CLI 엔트리
243
145
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -249,14 +151,49 @@ export async function createApp(name, targetDir) {
249
151
  export async function run(args) {
250
152
  const [command, ...rest] = args;
251
153
 
154
+ // ── fx new → create-fuzionx 안내 ──
252
155
  if (command === 'new') {
253
- const name = rest[0];
254
- if (!name) { console.error('Usage: fx new <name>'); process.exit(1); }
255
- const dir = await createApp(name);
256
- console.log(`✅ Created ${name} at ${dir}`);
257
- console.log(`\n cd ${name}`);
258
- console.log(` npm install`);
259
- console.log(` npm run dev\n`);
156
+ console.log(`\n 프로젝트 생성은 create-fuzionx를 사용하세요:\n`);
157
+ console.log(` npx create-fuzionx <name>\n`);
158
+ return;
159
+ }
160
+
161
+ if (command === 'make:app') {
162
+ const typeFlag = rest.find(a => a.startsWith('--type='));
163
+ const type = typeFlag?.split('=')[1] || rest[0];
164
+ const validTypes = ['ssr', 'spa'];
165
+
166
+ if (!type || !validTypes.includes(type)) {
167
+ console.error(`Usage: fx make:app --type=ssr|spa`);
168
+ console.error(` Available types: ${validTypes.join(', ')}`);
169
+ process.exit(1);
170
+ }
171
+
172
+ // 앱 이름은 타입에 따라 고정
173
+ const appName = type;
174
+ const appDir = path.join('.', 'app', appName);
175
+
176
+ // 기존 앱 확인
177
+ try {
178
+ await fs.access(appDir);
179
+ console.error(`❌ app/${appName}/ already exists.`);
180
+ process.exit(1);
181
+ } catch { /* 없으면 정상 */ }
182
+
183
+ // 빈 디렉토리 생성
184
+ for (const d of ['services', 'middleware', 'ws']) {
185
+ const fullDir = path.join(appDir, d);
186
+ await fs.mkdir(fullDir, { recursive: true });
187
+ await fs.writeFile(path.join(fullDir, '.gitkeep'), '');
188
+ }
189
+
190
+ // 기본 파일 복사 (HomeController, routes, views)
191
+ const appTplDir = path.join(TPL_DIR, 'make/app');
192
+ await copyDirRecursive(appTplDir, appDir);
193
+
194
+ console.log(`✅ Created app/${appName}/ (type: ${type})`);
195
+ console.log(`\n fuzionx.yaml의 apps에 추가하세요:`);
196
+ console.log(` "127.0.0.1:49080": ${appName}\n`);
260
197
  return;
261
198
  }
262
199
 
@@ -284,6 +221,108 @@ export async function run(args) {
284
221
  return;
285
222
  }
286
223
 
224
+ // ── fx dev:spa — FuzionX + Vite HMR 동시 실행 ──
225
+ if (command === 'dev:spa') {
226
+ const { execSync } = await import('node:child_process');
227
+ const spaDir = path.resolve('app/spa/views/default/spa');
228
+ try {
229
+ await fs.access(spaDir);
230
+ } catch {
231
+ console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
232
+ console.error(' fx make:app --type=spa 로 SPA 앱을 먼저 생성하세요.');
233
+ process.exit(1);
234
+ }
235
+ console.log(`🚀 Starting dev server + Vite HMR...`);
236
+ try {
237
+ execSync(
238
+ 'npx concurrently "node --watch app.js" "cd app/spa/views/default/spa && npx vite"',
239
+ { stdio: 'inherit', cwd: process.cwd() },
240
+ );
241
+ } catch {}
242
+ return;
243
+ }
244
+
245
+ // ── fx build:spa — Vite 프로덕션 빌드 ──
246
+ if (command === 'build:spa') {
247
+ const { execSync } = await import('node:child_process');
248
+ const spaDir = path.resolve('app/spa/views/default/spa');
249
+ try {
250
+ await fs.access(spaDir);
251
+ } catch {
252
+ console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
253
+ process.exit(1);
254
+ }
255
+ console.log(`📦 Building SPA for production...`);
256
+ try {
257
+ execSync('npx vite build', { stdio: 'inherit', cwd: spaDir });
258
+ console.log('\n✅ Build complete → public/dist/');
259
+ } catch { process.exit(1); }
260
+ return;
261
+ }
262
+
263
+ // ── fx stop — 서버 종료 ──
264
+ if (command === 'stop') {
265
+ const pidFile = path.resolve('fuzionx.pid');
266
+ try {
267
+ const pid = parseInt(await fs.readFile(pidFile, 'utf-8'), 10);
268
+ console.log(`🛑 Stopping PID ${pid}...`);
269
+ process.kill(pid, 'SIGTERM');
270
+ for (let i = 0; i < 50; i++) {
271
+ await new Promise(r => setTimeout(r, 100));
272
+ try { process.kill(pid, 0); } catch { break; }
273
+ }
274
+ await fs.unlink(pidFile).catch(() => {});
275
+ console.log('✅ Server stopped.');
276
+ } catch (err) {
277
+ if (err.code === 'ENOENT') {
278
+ console.log('⚠️ fuzionx.pid 없음 — 서버가 실행 중이 아닙니다.');
279
+ } else if (err.code === 'ESRCH') {
280
+ await fs.unlink(pidFile).catch(() => {});
281
+ console.log('⚠️ PID 프로세스가 이미 종료됨. PID 파일 삭제.');
282
+ } else {
283
+ console.error('❌ 종료 실패:', err.message);
284
+ }
285
+ }
286
+ return;
287
+ }
288
+
289
+ // ── fx restart — 서버 재시작 ──
290
+ if (command === 'restart') {
291
+ const pidFile = path.resolve('fuzionx.pid');
292
+ try {
293
+ const pid = parseInt(await fs.readFile(pidFile, 'utf-8'), 10);
294
+ console.log(`🔄 Stopping PID ${pid}...`);
295
+ process.kill(pid, 'SIGTERM');
296
+ for (let i = 0; i < 50; i++) {
297
+ await new Promise(r => setTimeout(r, 100));
298
+ try { process.kill(pid, 0); } catch { break; }
299
+ }
300
+ } catch (err) {
301
+ if (err.code === 'ENOENT') {
302
+ console.log('⚠️ fuzionx.pid 없음 — 새로 시작합니다.');
303
+ } else if (err.code === 'ESRCH') {
304
+ console.log('⚠️ PID 프로세스가 이미 종료됨.');
305
+ } else {
306
+ console.error('❌ 종료 실패:', err.message);
307
+ }
308
+ }
309
+ // 백그라운드로 재시작
310
+ const { spawn } = await import('node:child_process');
311
+ const logDir = path.resolve('storage/logs');
312
+ await fs.mkdir(logDir, { recursive: true });
313
+ const logFile = path.join(logDir, 'server.log');
314
+ const { openSync } = await import('node:fs');
315
+ const out = openSync(logFile, 'a');
316
+ const child = spawn('node', ['app.js'], {
317
+ cwd: process.cwd(),
318
+ detached: true,
319
+ stdio: ['ignore', out, out],
320
+ });
321
+ child.unref();
322
+ console.log(`🚀 Restarted (PID ${child.pid}) — logs: storage/logs/server.log`);
323
+ return;
324
+ }
325
+
287
326
  // ── fx test — 테스트 실행 ──
288
327
  if (command === 'test') {
289
328
  const { execSync } = await import('node:child_process');
@@ -337,18 +376,90 @@ export async function run(args) {
337
376
  const modelsDir = path.resolve('database/models');
338
377
  const files = await fs.readdir(modelsDir);
339
378
  const jsFiles = files.filter(f => f.endsWith('.js'));
379
+ const apply = rest.includes('--apply');
380
+
381
+ // fuzionx.yaml에서 DB 경로 읽기
382
+ let dbPath = './storage/database.sqlite';
383
+ try {
384
+ const yaml = await fs.readFile(path.resolve('fuzionx.yaml'), 'utf8');
385
+ const dbMatch = yaml.match(/database:\s*(.+\.sqlite)/);
386
+ if (dbMatch) dbPath = dbMatch[1].trim();
387
+ } catch {}
388
+
340
389
  console.log('\n📊 Model Schema Status:\n');
390
+ const models = [];
391
+
341
392
  for (const file of jsFiles) {
342
393
  const mod = await import(path.join(modelsDir, file));
343
394
  const Model = mod.default;
344
395
  if (!Model) continue;
345
396
  const name = path.basename(file, '.js');
346
397
  const table = Model.table || name.toLowerCase() + 's';
347
- const cols = Object.keys(Model.columns || {});
348
- const status = rest.includes('--apply') ? '✅ synced' : '⏳ pending';
349
- console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${cols.length} ${status}`);
398
+ const cols = Model.columns || {};
399
+ models.push({ name, table, cols });
350
400
  }
351
- if (!rest.includes('--apply')) {
401
+
402
+ if (apply) {
403
+ // SQLite DB 열기
404
+ const resolvedDbPath = path.resolve(dbPath);
405
+ await fs.mkdir(path.dirname(resolvedDbPath), { recursive: true });
406
+ const { default: Database } = await import('better-sqlite3');
407
+ const db = new Database(resolvedDbPath);
408
+
409
+ for (const { name, table, cols } of models) {
410
+ const colDefs = [];
411
+ for (const [col, def] of Object.entries(cols)) {
412
+ if (def.type === 'increments') {
413
+ colDefs.push(`\`${col}\` INTEGER PRIMARY KEY AUTOINCREMENT`);
414
+ } else if (def.type === 'integer') {
415
+ colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
416
+ } else if (def.type === 'text') {
417
+ colDefs.push(`\`${col}\` TEXT NOT NULL DEFAULT ''`);
418
+ } else if (def.type === 'datetime') {
419
+ colDefs.push(`\`${col}\` DATETIME DEFAULT CURRENT_TIMESTAMP`);
420
+ } else if (def.type === 'boolean') {
421
+ colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
422
+ } else {
423
+ // string, etc.
424
+ const unique = def.unique ? ' UNIQUE' : '';
425
+ const dflt = def.default != null ? ` DEFAULT '${def.default}'` : " DEFAULT ''";
426
+ colDefs.push(`\`${col}\` TEXT NOT NULL${dflt}${unique}`);
427
+ }
428
+ }
429
+ const sql = `CREATE TABLE IF NOT EXISTS \`${table}\` (\n ${colDefs.join(',\n ')}\n)`;
430
+ db.exec(sql);
431
+
432
+ // ── 기존 테이블에 누락된 컬럼 추가 (ALTER TABLE) ──
433
+ const existingCols = db.pragma(`table_info(${table})`).map(c => c.name);
434
+ let addedCols = 0;
435
+ for (const [col, def] of Object.entries(cols)) {
436
+ if (existingCols.includes(col)) continue;
437
+
438
+ let colSql;
439
+ if (def.type === 'integer') {
440
+ colSql = `INTEGER NOT NULL DEFAULT 0`;
441
+ } else if (def.type === 'text') {
442
+ colSql = `TEXT NOT NULL DEFAULT ''`;
443
+ } else if (def.type === 'datetime') {
444
+ colSql = `DATETIME DEFAULT NULL`;
445
+ } else if (def.type === 'boolean') {
446
+ colSql = `INTEGER NOT NULL DEFAULT 0`;
447
+ } else {
448
+ const dflt = def.default != null ? `'${def.default}'` : "''";
449
+ colSql = `TEXT DEFAULT ${dflt}`;
450
+ }
451
+ db.exec(`ALTER TABLE \`${table}\` ADD COLUMN \`${col}\` ${colSql}`);
452
+ addedCols++;
453
+ }
454
+ const status = addedCols > 0 ? `✅ synced (+${addedCols} columns)` : '✅ synced';
455
+ console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ${status}`);
456
+ }
457
+ db.close();
458
+ console.log(`\n Database: ${path.resolve(dbPath)}`);
459
+ } else {
460
+ for (const { name, table, cols } of models) {
461
+ console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ⏳ pending`);
462
+ }
352
463
  console.log('\n Run with --apply to sync changes to database.');
353
464
  }
354
465
  console.log('');
@@ -357,7 +468,8 @@ export async function run(args) {
357
468
  }
358
469
 
359
470
  console.log(`
360
- fx new <name> Create new app (multi-app structure)
471
+ npx create-fuzionx <name> Create new project
472
+ fx make:app --type=ssr|spa Create app (fixed name: app/ssr or app/spa)
361
473
  fx make:controller <Name> --app= Create controller (app-specific)
362
474
  fx make:service <Name> --app= Create service (app-specific)
363
475
  fx make:model <Name> Create model (database/models)
@@ -369,6 +481,10 @@ export async function run(args) {
369
481
  fx make:worker <Name> Create worker (shared/workers)
370
482
  fx make:test <Name> Create test
371
483
  fx dev Start dev server (--watch)
484
+ fx dev:spa Start dev server + Vite HMR
485
+ fx build:spa Build SPA for production
486
+ fx stop Stop server (graceful)
487
+ fx restart Restart server (graceful)
372
488
  fx test Run tests
373
489
  fx routes Print route table
374
490
  fx config Print fuzionx.yaml
@@ -7,6 +7,7 @@ import { Controller } from '@fuzionx/framework';
7
7
  export default class HomeController extends Controller {
8
8
 
9
9
  /** 홈 페이지 */
10
+ static index;
10
11
  async index(ctx) {
11
12
  ctx.render('home');
12
13
  }
@@ -6,10 +6,6 @@
6
6
  <div style="text-align:center;padding:80px 20px;">
7
7
  <h1 style="font-size:72px;color:#e74c3c;">{{ error.code }}</h1>
8
8
  <p style="font-size:20px;margin:16px 0;">{{ error.message }}</p>
9
- <p style="color:#888;">요청: {{ request.url }}</p>
10
- {% if config.debug %}
11
- <pre style="text-align:left;max-width:600px;margin:24px auto;background:#f5f5f5;padding:16px;border-radius:8px;overflow:auto;">{{ error.stack }}</pre>
12
- {% endif %}
13
9
  <a href="/" style="display:inline-block;margin-top:24px;padding:12px 24px;background:#e74c3c;color:#fff;text-decoration:none;border-radius:6px;">홈으로 돌아가기</a>
14
10
  </div>
15
11
  {% endblock %}
@@ -1,12 +1,11 @@
1
1
  {% extends "layouts/main.html" %}
2
2
 
3
- {% block title %}404찾을 수 없습니다{% endblock %}
3
+ {% block title %}500서버 오류{% endblock %}
4
4
 
5
5
  {% block content %}
6
6
  <div style="text-align:center;padding:80px 20px;">
7
7
  <h1 style="font-size:72px;color:#e74c3c;">{{ error.code }}</h1>
8
8
  <p style="font-size:20px;margin:16px 0;">{{ error.message }}</p>
9
- <p style="color:#888;">요청: {{ request.url }}</p>
10
9
  {% if config.debug %}
11
10
  <pre style="text-align:left;max-width:600px;margin:24px auto;background:#f5f5f5;padding:16px;border-radius:8px;overflow:auto;">{{ error.stack }}</pre>
12
11
  {% endif %}
@@ -0,0 +1,11 @@
1
+ {% extends "layouts/main.html" %}
2
+
3
+ {% block title %}Welcome{% endblock %}
4
+
5
+ {% block content %}
6
+ <div style="text-align:center;padding:80px 20px;">
7
+ <h1 style="font-size:48px;color:#333;">🚀 Welcome</h1>
8
+ <p style="font-size:18px;color:#666;margin:16px 0;">Your new app is ready.</p>
9
+ <p style="color:#999;font-size:14px;">Edit <code style="background:#f0f0f0;padding:2px 8px;border-radius:4px;">routes/web.js</code> to get started</p>
10
+ </div>
11
+ {% endblock %}
package/index.js CHANGED
@@ -60,5 +60,8 @@ export { default as FileHelper } from './lib/helpers/FileHelper.js';
60
60
  export { default as View } from './lib/view/View.js';
61
61
  export { default as OpenAPI } from './lib/view/OpenAPI.js';
62
62
 
63
+ // ── Utilities (Stateless) ──
64
+ export { PaginationUtil, StrUtil, NumUtil, DateUtil, ArrUtil, FunctionUtil, ObjectUtil } from './lib/utilities/index.js';
65
+
63
66
  // ── Built-in Middleware ──
64
67
  export { bodyParser, cors, auth, apiAuth, csrf, session, theme } from './lib/middleware/index.js';
@@ -8,6 +8,8 @@
8
8
  * @see docs/framework/04-bootstrap-lifecycle.md
9
9
  */
10
10
  import path from 'node:path';
11
+ import { promises as fs } from 'node:fs';
12
+ import cluster from 'node:cluster';
11
13
  import Config from './Config.js';
12
14
  import Context from './Context.js';
13
15
  import Router from '../http/Router.js';
@@ -427,10 +429,19 @@ export default class Application {
427
429
 
428
430
  if (!this._booted) await this.boot();
429
431
 
430
- // 포트 사용 여부 확인 — primary에서만 (워커는 SO_REUSEPORT로 공유)
431
- const cluster = await import('node:cluster');
432
- if (!cluster.default?.isWorker) {
432
+ // PID 파일 생성 (primary)
433
+ if (!cluster.isWorker) {
433
434
  await this._checkPort(port);
435
+ const pidPath = path.join(this.baseDir, 'fuzionx.pid');
436
+ await fs.writeFile(pidPath, String(process.pid));
437
+ }
438
+
439
+ // process.title 설정 (ps에서 보이는 프로세스 이름)
440
+ const appName = this.config.get('app.name', 'fuzionx');
441
+ if (cluster.isWorker) {
442
+ process.title = `${appName}-worker-${cluster.worker.id}`;
443
+ } else {
444
+ process.title = `${appName}-primary`;
434
445
  }
435
446
 
436
447
  await this.emit('ready');
@@ -458,6 +469,14 @@ export default class Application {
458
469
  }
459
470
 
460
471
  await this.emit('listening');
472
+
473
+ // Scheduler 시작 — primary 프로세스에서만 (워커 중복 실행 방지)
474
+ // fuzionx 상위 레이어가 cluster.fork()로 워커를 생성하므로
475
+ // cluster.isPrimary로 단일 프로세스 보장
476
+ if (this._scheduler && this._scheduler._jobs.length > 0 && !cluster.isWorker) {
477
+ this._scheduler.start();
478
+ }
479
+
461
480
  return this;
462
481
  }
463
482
 
@@ -619,9 +638,15 @@ export default class Application {
619
638
  // 미들웨어 체인 사전 구성
620
639
  const middlewareFns = [...this._globalMiddleware];
621
640
  if (route.middleware?.length) {
622
- for (const mwName of route.middleware) {
623
- const fn = this.resolveMiddleware(mwName, appName);
624
- if (fn) middlewareFns.push(fn);
641
+ for (const mw of route.middleware) {
642
+ // 함수면 직접 사용 (built-in: auth(), cors() 등)
643
+ // 문자열이면 resolveMiddleware로 클래스 기반 조회
644
+ if (typeof mw === 'function') {
645
+ middlewareFns.push(mw);
646
+ } else if (typeof mw === 'string') {
647
+ const fn = this.resolveMiddleware(mw, appName);
648
+ if (fn) middlewareFns.push(fn);
649
+ }
625
650
  }
626
651
  }
627
652
 
@@ -28,6 +28,7 @@ export default class Context {
28
28
  this.body = rawReq.body || null;
29
29
  this.ip = rawReq.remoteIp || '';
30
30
  this.files = rawReq.files || null;
31
+ this.uploadError = rawReq.uploadError || null;
31
32
  this.formFields = rawReq.formFields || null;
32
33
  this.handlerId = rawReq.handlerId;
33
34
 
@@ -306,6 +307,22 @@ export default class Context {
306
307
 
307
308
  render(view, data) {
308
309
  // 글로벌 변수 주입 (03-views-templates.md)
310
+ const bridge = this.app?._bridge;
311
+ let aspVars = {};
312
+ if (bridge?.getAspConfig && bridge?.cryptoEncryptCustom) {
313
+ try {
314
+ const aspCfg = JSON.parse(bridge.getAspConfig());
315
+ const clientSecret = this.app?.config?.get('app.client_secret') || '';
316
+ if (clientSecret && aspCfg.masterSecret) {
317
+ aspVars = {
318
+ _fx_client_secret: clientSecret,
319
+ _fx_asp_secret: bridge.cryptoEncryptCustom(clientSecret, aspCfg.masterSecret),
320
+ _fx_asp_header: aspCfg.headerSignal || 'Ruxy-Enc-Mode',
321
+ };
322
+ }
323
+ } catch {}
324
+ }
325
+
309
326
  const globals = {
310
327
  session: this._rawSession,
311
328
  auth: { user: this.user },
@@ -315,6 +332,7 @@ export default class Context {
315
332
  flash: this.session?.getFlash() || {},
316
333
  theme: this.theme || this.app?.config?.get('themes.default', 'default') || 'default',
317
334
  locale: this.locale,
335
+ ...aspVars,
318
336
  ...data,
319
337
  };
320
338
 
@@ -336,11 +354,18 @@ export default class Context {
336
354
  /** @private */
337
355
  _createSession(rawReq, app) {
338
356
  const data = rawReq.session || {};
357
+ // Flash 추출 + 세션에서 삭제 (one-time read)
339
358
  const flash = { ...(data._flash || {}) };
359
+ const hadFlash = !!data._flash;
340
360
  delete data._flash;
341
361
  const sessionId = rawReq.sessionId || null;
342
362
  const bridge = app?._bridge || null;
343
363
 
364
+ // Flash 소비 후 세션 저장소에 즉시 반영
365
+ if (hadFlash && sessionId && bridge?.sessionSet) {
366
+ try { bridge.sessionSet(sessionId, { ...data }); } catch {}
367
+ }
368
+
344
369
  return {
345
370
  /** 세션 값 조회 */
346
371
  get: (key) => key ? (data[key] ?? null) : { ...data },
@@ -350,7 +375,11 @@ export default class Context {
350
375
  data[key] = value;
351
376
  // Core SessionProto 참조: bridge.sessionSet(id, { ...data })
352
377
  if (sessionId && bridge?.sessionSet) {
353
- try { bridge.sessionSet(sessionId, { ...data }); } catch {}
378
+ try {
379
+ bridge.sessionSet(sessionId, { ...data });
380
+ } catch (e) {
381
+ console.error('[Session] sessionSet failed:', e?.message || e);
382
+ }
354
383
  }
355
384
  },
356
385
 
@@ -60,7 +60,6 @@ export default class I18nHelper {
60
60
  */
61
61
  translate(locale, key, vars = {}) {
62
62
  // ── Bridge N-API 위임 ──
63
- // Core i18n.js 참조: bridge.i18NTranslate(locale, key)
64
63
  if (this._bridge && typeof this._bridge.i18NTranslate === 'function') {
65
64
  try {
66
65
  const result = this._bridge.i18NTranslate(locale, key);
@@ -72,12 +71,18 @@ export default class I18nHelper {
72
71
  const messages = this._messages.get(locale)
73
72
  || this._messages.get(this.fallback)
74
73
  || this._messages.get(this.defaultLocale);
75
- if (!messages) return vars.default || key;
76
74
 
77
- const value = messages[key];
78
- if (value == null) return vars.default || key;
75
+ const value = messages?.[key];
76
+ if (value != null) return this._substitute(value, vars);
79
77
 
80
- return this._substitute(value, vars);
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;
81
86
  }
82
87
 
83
88
  /**
@@ -86,7 +91,6 @@ export default class I18nHelper {
86
91
  * @returns {object}
87
92
  */
88
93
  all(locale) {
89
- // Bridge: i18NGetLocales() 는 locale 목록만 반환, 전체 데이터 API 없음
90
94
  const messages = this._messages.get(locale) || this._messages.get(this.defaultLocale);
91
95
  if (!messages) return {};
92
96
  return { ...messages };