@fuzionx/framework 0.1.46 → 0.1.48

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 (87) hide show
  1. package/README.md +29 -2
  2. package/cli/index.js +57 -18
  3. package/cli/templates/make/app-spa/controllers/AuthController.js +114 -0
  4. package/cli/templates/make/app-spa/controllers/HomeController.js +66 -0
  5. package/cli/templates/make/app-spa/controllers/PostController.js +191 -0
  6. package/cli/templates/make/app-spa/controllers/UserController.js +43 -0
  7. package/cli/templates/make/app-spa/public/css/style.css +1011 -0
  8. package/cli/templates/make/app-spa/routes/api.js +31 -0
  9. package/cli/templates/make/app-spa/routes/web.js +19 -0
  10. package/cli/templates/make/app-spa/services/AuthService.js +48 -0
  11. package/cli/templates/make/app-spa/services/PostService.js +372 -0
  12. package/cli/templates/make/app-spa/services/UserService.js +48 -0
  13. package/cli/templates/make/app-spa/views/default/errors/404.html +11 -0
  14. package/cli/templates/make/app-spa/views/default/errors/500.html +11 -0
  15. package/cli/templates/make/app-spa/views/default/layouts/main.html +34 -0
  16. package/cli/templates/make/app-spa/views/default/pages/home.html +22 -0
  17. package/cli/templates/make/app-spa/views/default/spa/index.html +13 -0
  18. package/cli/templates/make/app-spa/views/default/spa/package.json +20 -0
  19. package/cli/templates/make/app-spa/views/default/spa/src/App.vue +41 -0
  20. package/cli/templates/make/app-spa/views/default/spa/src/assets/landing.css +220 -0
  21. package/cli/templates/make/app-spa/views/default/spa/src/assets/style.css +1156 -0
  22. package/cli/templates/make/app-spa/views/default/spa/src/components/AlertDialog.vue +179 -0
  23. package/cli/templates/make/app-spa/views/default/spa/src/components/CodeBlock.vue +33 -0
  24. package/cli/templates/make/app-spa/views/default/spa/src/components/EditorToolbar.vue +54 -0
  25. package/cli/templates/make/app-spa/views/default/spa/src/components/FileUpload.vue +161 -0
  26. package/cli/templates/make/app-spa/views/default/spa/src/components/FlashMessage.vue +39 -0
  27. package/cli/templates/make/app-spa/views/default/spa/src/components/LanguageSwitcher.vue +108 -0
  28. package/cli/templates/make/app-spa/views/default/spa/src/components/Lightbox.vue +62 -0
  29. package/cli/templates/make/app-spa/views/default/spa/src/components/Navbar.vue +68 -0
  30. package/cli/templates/make/app-spa/views/default/spa/src/components/Pagination.vue +166 -0
  31. package/cli/templates/make/app-spa/views/default/spa/src/components/ToastContainer.vue +135 -0
  32. package/cli/templates/make/app-spa/views/default/spa/src/composables/useApi.js +129 -0
  33. package/cli/templates/make/app-spa/views/default/spa/src/composables/useClipboard.js +44 -0
  34. package/cli/templates/make/app-spa/views/default/spa/src/composables/useDate.js +73 -0
  35. package/cli/templates/make/app-spa/views/default/spa/src/composables/useDebounce.js +59 -0
  36. package/cli/templates/make/app-spa/views/default/spa/src/composables/useFlash.js +46 -0
  37. package/cli/templates/make/app-spa/views/default/spa/src/composables/useHeartbeat.js +45 -0
  38. package/cli/templates/make/app-spa/views/default/spa/src/composables/useLocalStorage.js +43 -0
  39. package/cli/templates/make/app-spa/views/default/spa/src/composables/useLocale.js +79 -0
  40. package/cli/templates/make/app-spa/views/default/spa/src/composables/useWebSocket.js +93 -0
  41. package/cli/templates/make/app-spa/views/default/spa/src/main.js +106 -0
  42. package/cli/templates/make/app-spa/views/default/spa/src/plugins/alert.js +96 -0
  43. package/cli/templates/make/app-spa/views/default/spa/src/plugins/toast.js +79 -0
  44. package/cli/templates/make/app-spa/views/default/spa/src/router/index.js +29 -0
  45. package/cli/templates/make/app-spa/views/default/spa/src/stores/auth.js +58 -0
  46. package/cli/templates/make/app-spa/views/default/spa/src/views/BoardDetail.vue +169 -0
  47. package/cli/templates/make/app-spa/views/default/spa/src/views/BoardForm.vue +192 -0
  48. package/cli/templates/make/app-spa/views/default/spa/src/views/BoardList.vue +129 -0
  49. package/cli/templates/make/app-spa/views/default/spa/src/views/ChatView.vue +317 -0
  50. package/cli/templates/make/app-spa/views/default/spa/src/views/FeaturesView.vue +242 -0
  51. package/cli/templates/make/app-spa/views/default/spa/src/views/HomeView.vue +215 -0
  52. package/cli/templates/make/app-spa/views/default/spa/src/views/Login.vue +82 -0
  53. package/cli/templates/make/app-spa/views/default/spa/src/views/Profile.vue +85 -0
  54. package/cli/templates/make/app-spa/views/default/spa/src/views/Register.vue +84 -0
  55. package/cli/templates/make/app-spa/views/default/spa/vite.config.js +28 -0
  56. package/cli/templates/make/app-spa/views/default/spa/yarn.lock +633 -0
  57. package/cli/templates/make/app-spa/ws/ChatHandler.js +138 -0
  58. package/cli/templates/make/app-ssr/controllers/AuthController.js +119 -0
  59. package/cli/templates/make/app-ssr/controllers/ChatController.js +15 -0
  60. package/cli/templates/make/app-ssr/controllers/FeaturesController.js +15 -0
  61. package/cli/templates/make/app-ssr/controllers/HomeController.js +21 -0
  62. package/cli/templates/make/app-ssr/controllers/PostController.js +214 -0
  63. package/cli/templates/make/app-ssr/controllers/UserController.js +48 -0
  64. package/cli/templates/make/app-ssr/public/css/fx-ui.css +43 -0
  65. package/cli/templates/make/app-ssr/public/css/landing.css +220 -0
  66. package/cli/templates/make/app-ssr/public/css/style.css +1011 -0
  67. package/cli/templates/make/app-ssr/public/js/fx-client.js +107 -0
  68. package/cli/templates/make/app-ssr/public/js/fx-ui.js +124 -0
  69. package/cli/templates/make/app-ssr/routes/web.js +46 -0
  70. package/cli/templates/make/app-ssr/services/AuthService.js +48 -0
  71. package/cli/templates/make/app-ssr/services/PostService.js +372 -0
  72. package/cli/templates/make/app-ssr/services/UserService.js +48 -0
  73. package/cli/templates/make/app-ssr/views/default/errors/404.html +11 -0
  74. package/cli/templates/make/app-ssr/views/default/errors/500.html +48 -0
  75. package/cli/templates/make/app-ssr/views/default/layouts/main.html +96 -0
  76. package/cli/templates/make/app-ssr/views/default/pages/board/form.html +240 -0
  77. package/cli/templates/make/app-ssr/views/default/pages/board/index.html +73 -0
  78. package/cli/templates/make/app-ssr/views/default/pages/board/show.html +148 -0
  79. package/cli/templates/make/app-ssr/views/default/pages/chat.html +288 -0
  80. package/cli/templates/make/app-ssr/views/default/pages/features.html +373 -0
  81. package/cli/templates/make/app-ssr/views/default/pages/home.html +258 -0
  82. package/cli/templates/make/app-ssr/views/default/pages/login.html +27 -0
  83. package/cli/templates/make/app-ssr/views/default/pages/profile.html +36 -0
  84. package/cli/templates/make/app-ssr/views/default/pages/register.html +35 -0
  85. package/cli/templates/make/app-ssr/views/default/partials/pagination.html +75 -0
  86. package/cli/templates/make/app-ssr/ws/ChatHandler.js +138 -0
  87. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @fuzionx/framework
2
2
 
3
- > Laravel-inspired full-stack MVC framework powered by Rust N-API bridge **500K+ RPS**
3
+ > Laravel-inspired full-stack MVC framework powered by Rust N-API bridge, achieving an unprecedented **618K+ Req/Sec** in the Node.js ecosystem.
4
4
 
5
5
  ```
6
6
  @fuzionx/framework ← npm install (개발자가 설치)
@@ -34,6 +34,33 @@ node app.js
34
34
 
35
35
  ---
36
36
 
37
+ ## 🚀 Why FuzionX? (The Differentiator)
38
+
39
+ FuzionX is not just another framework wrapper. It fundamentally redesigns how HTTP requests are handled in the Node.js ecosystem by shifting heavy networking and parsing logic to Rust.
40
+
41
+ - **Rust N-API Direct TCP Handling**: Bypasses the standard Node.js `http` parser. Raw TCP buffers are processed at native speeds via Rust's LibUV bindings, maximizing I/O efficiency.
42
+ - **Kernel-Level Load Balancing (`SO_REUSEPORT`)**: Completely eliminates the single-thread IPC bottlenecks and PM2 cluster routing overhead. The OS kernel directly distributes incoming connections across all available CPU cores.
43
+ - **Zero-Allocation Dispatch Pipeline**: Reusable Context (`ctx`) design and optimized closures drastically minimize V8 Garbage Collection (GC) pressure, reducing tail latencies under heavy concurrent loads.
44
+ - **Full-Stack with S-Tier Speed**: Offers a robust Laravel-inspired MVC Developer Experience (ORM, i18n, WebSockets, Schedulers) while delivering routing and throughput capabilities normally reserved for hyper-optimized minimalist C++ servers like `uWebSockets.js`.
45
+
46
+ ---
47
+
48
+ ## 📊 Performance Benchmark
49
+
50
+ **Hardware Environment**: Intel® Core™ Ultra 9 285H (16 Cores), 16GB RAM
51
+ **Test Configuration**: `wrk -t4 -c100 -d10s /api/benchmark` (Routing & Framework fully active, Sessions disabled)
52
+
53
+ | Framework | Req/sec | Avg Latency | Max Latency |
54
+ |:---|---:|---:|---:|
55
+ | 🥇 **FuzionX** (SO_REUSEPORT) | **618,521** | **172.07µs** | **13.03ms** |
56
+ | 🥈 Node.js (Pure HTTP + PM2) | 393,264 | 278.33µs | 37.26ms |
57
+ | 🥉 Fastify (PM2) | 278,526 | 393.45µs | 43.65ms |
58
+ | 4th Express (PM2) | 141,597 | 950.00µs | 66.91ms |
59
+
60
+ > FuzionX outperforms **Fastify by +122%** and **Node.js Pure HTTP by +57%**, making it one of the absolute fastest full-stack frameworks available in the global JS ecosystem.
61
+
62
+ ---
63
+
37
64
  ## 프로젝트 구조
38
65
 
39
66
  ```
@@ -481,7 +508,7 @@ app.js 실행
481
508
 
482
509
  ## 주요 특징
483
510
 
484
- - ⚡ **500K+ RPS** — Rust N-API Bridge (libuv + SO_REUSEPORT)
511
+ - ⚡ **618K+ Req/Sec** — Rust N-API Bridge (libuv + SO_REUSEPORT)
485
512
  - 🦀 **Rust 네이티브** — HTTP, 세션, CORS, Rate Limit, 암호화 모두 Rust 처리
486
513
  - 🎯 **MVC 아키텍처** — Controller, Service, Model 분리
487
514
  - 📦 **자동 스캔** — controllers/, models/, services/ 등 자동 로드
package/cli/index.js CHANGED
@@ -162,16 +162,18 @@ export async function run(args) {
162
162
  if (command === 'make:app') {
163
163
  const typeFlag = rest.find(a => a.startsWith('--type='));
164
164
  const type = typeFlag?.split('=')[1] || rest[0];
165
+ const nameFlag = rest.find(a => a.startsWith('--name='));
165
166
  const validTypes = ['ssr', 'spa'];
166
167
 
167
168
  if (!type || !validTypes.includes(type)) {
168
- console.error(`Usage: fx make:app --type=ssr|spa`);
169
+ console.error(`Usage: fx make:app --type=ssr|spa [--name=<appName>]`);
169
170
  console.error(` Available types: ${validTypes.join(', ')}`);
171
+ console.error(` --name: custom app directory name (default: type name)`);
170
172
  process.exit(1);
171
173
  }
172
174
 
173
- // 이름은 타입에 따라 고정
174
- const appName = type;
175
+ // --name 있으면 커스텀 앱명, 없으면 타입명 사용
176
+ const appName = nameFlag?.split('=')[1] || type;
175
177
  const appDir = path.join('.', 'app', appName);
176
178
 
177
179
  // 기존 앱 확인
@@ -181,16 +183,43 @@ export async function run(args) {
181
183
  process.exit(1);
182
184
  } catch { /* 없으면 정상 */ }
183
185
 
186
+ // 타입별 템플릿 디렉토리 확인
187
+ const appTplDir = path.join(TPL_DIR, `make/app-${type}`);
188
+ let hasTypeTpl = false;
189
+ try {
190
+ await fs.access(appTplDir);
191
+ hasTypeTpl = true;
192
+ } catch { /* 타입별 템플릿 없으면 기본 사용 */ }
193
+
194
+ const tplDir = hasTypeTpl ? appTplDir : path.join(TPL_DIR, 'make/app');
195
+
184
196
  // 빈 디렉토리 생성
185
197
  for (const d of ['services', 'middleware', 'ws']) {
186
198
  const fullDir = path.join(appDir, d);
187
199
  await fs.mkdir(fullDir, { recursive: true });
188
- await fs.writeFile(path.join(fullDir, '.gitkeep'), '');
200
+ const files = await fs.readdir(fullDir).catch(() => []);
201
+ if (files.length === 0) {
202
+ await fs.writeFile(path.join(fullDir, '.gitkeep'), '');
203
+ }
204
+ }
205
+
206
+ // 타입별 템플릿 복사 (public/ 은 프로젝트 루트로 분리)
207
+ const tplEntries = await fs.readdir(tplDir, { withFileTypes: true });
208
+ for (const entry of tplEntries) {
209
+ if (!entry.isDirectory()) continue;
210
+ if (entry.name === 'public') continue; // public은 별도 처리
211
+ await copyDirRecursive(
212
+ path.join(tplDir, entry.name),
213
+ path.join(appDir, entry.name),
214
+ );
189
215
  }
190
216
 
191
- // 기본 파일 복사 (HomeController, routes, views)
192
- const appTplDir = path.join(TPL_DIR, 'make/app');
193
- await copyDirRecursive(appTplDir, appDir);
217
+ // public/ 디렉토리 프로젝트 루트 public/ 으로 복사
218
+ const publicSrc = path.join(tplDir, 'public');
219
+ try {
220
+ await fs.access(publicSrc);
221
+ await copyDirRecursive(publicSrc, path.join('.', 'public'));
222
+ } catch { /* public 없으면 스킵 */ }
194
223
 
195
224
  console.log(`✅ Created app/${appName}/ (type: ${type})`);
196
225
  console.log(`\n fuzionx.yaml의 apps에 추가하세요:`);
@@ -225,18 +254,23 @@ export async function run(args) {
225
254
  // ── fx dev:spa — FuzionX + Vite HMR 동시 실행 ──
226
255
  if (command === 'dev:spa') {
227
256
  const { execSync } = await import('node:child_process');
228
- const spaDir = path.resolve('app/spa/views/default/spa');
257
+ const appFlag = rest.find(a => a.startsWith('--app='));
258
+ const appName = appFlag?.split('=')[1] || 'spa';
259
+ const spaDir = path.resolve(`app/${appName}/views/default/spa`);
229
260
  try {
230
261
  await fs.access(spaDir);
231
262
  } catch {
232
- console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
233
- console.error(' fx make:app --type=spa 로 SPA 앱을 먼저 생성하세요.');
263
+ console.error(`❌ app/${appName}/views/default/spa/ 디렉토리가 없습니다.`);
264
+ console.error(` fx make:app --type=spa 로 SPA 앱을 먼저 생성하세요.`);
265
+ if (appName === 'spa') {
266
+ console.error(` 커스텀 앱명을 사용했다면: fx dev:spa --app=<appName>`);
267
+ }
234
268
  process.exit(1);
235
269
  }
236
- console.log(`🚀 Starting dev server + Vite HMR...`);
270
+ console.log(`🚀 Starting dev server + Vite HMR... (app: ${appName})`);
237
271
  try {
238
272
  execSync(
239
- 'npx concurrently "node --watch app.js" "cd app/spa/views/default/spa && npx vite"',
273
+ `npx concurrently "node --watch app.js" "cd app/${appName}/views/default/spa && npx vite"`,
240
274
  { stdio: 'inherit', cwd: process.cwd() },
241
275
  );
242
276
  } catch {}
@@ -246,14 +280,19 @@ export async function run(args) {
246
280
  // ── fx build:spa — Vite 프로덕션 빌드 ──
247
281
  if (command === 'build:spa') {
248
282
  const { execSync } = await import('node:child_process');
249
- const spaDir = path.resolve('app/spa/views/default/spa');
283
+ const appFlag = rest.find(a => a.startsWith('--app='));
284
+ const appName = appFlag?.split('=')[1] || 'spa';
285
+ const spaDir = path.resolve(`app/${appName}/views/default/spa`);
250
286
  try {
251
287
  await fs.access(spaDir);
252
288
  } catch {
253
- console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
289
+ console.error(`❌ app/${appName}/views/default/spa/ 디렉토리가 없습니다.`);
290
+ if (appName === 'spa') {
291
+ console.error(` 커스텀 앱명을 사용했다면: fx build:spa --app=<appName>`);
292
+ }
254
293
  process.exit(1);
255
294
  }
256
- console.log(`📦 Building SPA for production...`);
295
+ console.log(`📦 Building SPA for production... (app: ${appName})`);
257
296
  try {
258
297
  execSync('npx vite build', { stdio: 'inherit', cwd: spaDir });
259
298
  console.log('\n✅ Build complete → public/dist/');
@@ -470,7 +509,7 @@ export async function run(args) {
470
509
 
471
510
  console.log(`
472
511
  npx create-fuzionx <name> Create new project
473
- fx make:app --type=ssr|spa Create app (fixed name: app/ssr or app/spa)
512
+ fx make:app --type=ssr|spa [--name=<appName>] Create app
474
513
  fx make:controller <Name> --app= Create controller (app-specific)
475
514
  fx make:service <Name> --app= Create service (app-specific)
476
515
  fx make:model <Name> Create model (database/models)
@@ -482,8 +521,8 @@ export async function run(args) {
482
521
  fx make:worker <Name> Create worker (shared/workers)
483
522
  fx make:test <Name> Create test
484
523
  fx dev Start dev server (--watch)
485
- fx dev:spa Start dev server + Vite HMR
486
- fx build:spa Build SPA for production
524
+ fx dev:spa [--app=<name>] Start dev server + Vite HMR
525
+ fx build:spa [--app=<name>] Build SPA for production
487
526
  fx stop Stop server (graceful)
488
527
  fx restart Restart server (graceful)
489
528
  fx test Run tests
@@ -0,0 +1,114 @@
1
+ import { Controller } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SPA AuthController — JSON API 전용 인증
5
+ *
6
+ * 모든 페이지는 Vue에서 렌더링. 서버는 API만 제공.
7
+ * 세션 기반 인증. JSON 요청/응답.
8
+ */
9
+ export default class AuthController extends Controller {
10
+ /** @type {import('@fuzionx/framework').RouteHandler} 로그인 API */
11
+ static login;
12
+ /** @type {import('@fuzionx/framework').RouteHandler} 회원가입 API */
13
+ static register;
14
+ /** @type {import('@fuzionx/framework').RouteHandler} 로그아웃 API */
15
+ static logout;
16
+ /** @type {import('@fuzionx/framework').RouteHandler} 인증 상태 확인 API */
17
+ static check;
18
+ /** @type {import('@fuzionx/framework').RouteHandler} 하트비트 (세션 연장) */
19
+ static heartbeat;
20
+
21
+ /**
22
+ * POST /api/auth/login — 로그인
23
+ *
24
+ * email/password 검증 후 세션에 userId 저장.
25
+ * 성공 → { user }, 실패 → 401 { error }.
26
+ *
27
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
28
+ * @returns {void}
29
+ */
30
+ async login(ctx) {
31
+ const { email, password } = ctx.body;
32
+ const user = await this.service('AuthService').login({ email, password });
33
+
34
+ if (!user) {
35
+ return ctx.status(401).json({ error: ctx.t('auth.login_failed') });
36
+ }
37
+
38
+ ctx.session.set('userId', user.id);
39
+ ctx.json({ user: { id: user.id, name: user.name, email: user.email, role: user.role } });
40
+ }
41
+
42
+ /**
43
+ * POST /api/auth/register — 회원가입
44
+ *
45
+ * name/email/password/password_confirm 검증.
46
+ * 성공 → 201 { user }, 실패 → 400 { error }.
47
+ *
48
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
49
+ * @returns {void}
50
+ */
51
+ async register(ctx) {
52
+ const { name, email, password, password_confirm } = ctx.body;
53
+
54
+ if (password !== password_confirm) {
55
+ return ctx.status(400).json({ error: ctx.t('auth.password_mismatch') });
56
+ }
57
+
58
+ try {
59
+ const user = await this.service('AuthService').register({ name, email, password });
60
+ ctx.session.set('userId', user.id);
61
+ ctx.status(201).json({ user: { id: user.id, name: user.name, email: user.email } });
62
+ } catch (e) {
63
+ ctx.status(e.status || 400).json({ error: ctx.t(e.message) || e.message });
64
+ }
65
+ }
66
+
67
+ /**
68
+ * POST /api/auth/logout — 로그아웃
69
+ *
70
+ * 세션 파기 후 { success: true } 반환.
71
+ *
72
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
73
+ * @returns {void}
74
+ */
75
+ async logout(ctx) {
76
+ ctx.session.destroy();
77
+ ctx.json({ success: true });
78
+ }
79
+
80
+ /**
81
+ * GET /api/auth/check — 인증 상태 확인
82
+ *
83
+ * 세션에 userId 존재 → DB 조회 → { authenticated, user }.
84
+ * 미인증 시 { authenticated: false, user: null }.
85
+ *
86
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
87
+ * @returns {void}
88
+ */
89
+ async check(ctx) {
90
+ const userId = ctx.session.get('userId');
91
+ if (!userId) return ctx.json({ authenticated: false, user: null });
92
+
93
+ try {
94
+ const user = await this.db.User.find(userId);
95
+ if (!user) return ctx.json({ authenticated: false, user: null });
96
+ ctx.json({ authenticated: true, user: user.toJSON() });
97
+ } catch {
98
+ ctx.json({ authenticated: false, user: null });
99
+ }
100
+ }
101
+
102
+ /**
103
+ * GET /api/heartbeat — 세션 연장 (하트비트)
104
+ *
105
+ * 인증된 사용자의 세션 유지. { alive, user }.
106
+ *
107
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
108
+ * @returns {void}
109
+ */
110
+ async heartbeat(ctx) {
111
+ if (!ctx.user) return ctx.status(401).json({ alive: false });
112
+ ctx.json({ alive: true, user: { id: ctx.user.id } });
113
+ }
114
+ }
@@ -0,0 +1,66 @@
1
+ import { Controller } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SPA HomeController — Vue 셸 렌더링
5
+ *
6
+ * 모든 프론트엔드 라우트에 대해 Vue 앱 셸을 서빙.
7
+ * 인증 여부와 관계없이 셸을 렌더링하며,
8
+ * 유저 데이터를 ASP 암호화하여 Vue에 전달.
9
+ * Vue가 user=null이면 로그인 화면, 아니면 대시보드를 표시.
10
+ *
11
+ * bridge.cryptoEncryptCustom(key, plaintext) → WASM decrypt_custom(key, ciphertext)
12
+ */
13
+ export default class HomeController extends Controller {
14
+ /** @type {import('@fuzionx/framework').RouteHandler} SPA 셸 렌더링 */
15
+ static index;
16
+
17
+ /**
18
+ * GET / | /* — Vue SPA 셸 렌더링
19
+ *
20
+ * 세션에서 유저 조회 후 ASP 암호화된 payload와 함께 렌더링.
21
+ * payload: { user, locale, isDev, asp, app }
22
+ *
23
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
24
+ * @returns {void}
25
+ */
26
+ async index(ctx) {
27
+ const clientSecret = this.config.get('app.client_secret');
28
+ const bridge = this.app._bridge;
29
+
30
+ // ASP config에서 masterSecret 가져오기
31
+ const aspConfig = JSON.parse(bridge.getAspConfig());
32
+
33
+ // 세션에서 유저 조회 (인증 안 되어 있으면 null)
34
+ let user = null;
35
+ const userId = ctx.session?.get('userId');
36
+ if (userId && this.db?.User) {
37
+ try {
38
+ const u = await this.db.User.find(userId);
39
+ if (u) user = { id: u.id, name: u.name, email: u.email, role: u.role };
40
+ } catch {}
41
+ }
42
+
43
+ const isDev = this.config.get('app.environment') === 'development';
44
+
45
+ // SPA에 전달할 payload
46
+ const payload = {
47
+ user,
48
+ locale: ctx.locale,
49
+ locales: this.app?.i18n?.locales?.() || [],
50
+ translations: ctx.t.all(),
51
+ isDev,
52
+ asp: { headerSignal: aspConfig.headerSignal || 'Ruxy-Enc-Mode', masterSecret: aspConfig.masterSecret },
53
+ app: { name: this.config.get('app.name'), theme: this.config.get('app.themes.default') },
54
+ };
55
+
56
+ // 암호화 (bridge crypto_encrypt_custom — WASM decrypt_custom과 호환)
57
+ const encryptedPayload = bridge.cryptoEncryptCustom(clientSecret, JSON.stringify(payload));
58
+
59
+ ctx.render('home', {
60
+ __fx__: encryptedPayload,
61
+ __fx__enabled: aspConfig.enabled || this.config.get('app.asp').enabled,
62
+ client_secret: clientSecret,
63
+ isDev,
64
+ });
65
+ }
66
+ }
@@ -0,0 +1,191 @@
1
+ import { Controller, DateUtil } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SPA PostController — 게시판 REST API
5
+ *
6
+ * RESTful CRUD. JSON 요청/응답.
7
+ * r.resource('posts', PostController) 로 자동 라우트 등록.
8
+ */
9
+ export default class PostController extends Controller {
10
+ /** @type {import('@fuzionx/framework').RouteHandler} 게시글 목록 조회 */
11
+ static index;
12
+ /** @type {import('@fuzionx/framework').RouteHandler} 게시글 상세 조회 */
13
+ static show;
14
+ /** @type {import('@fuzionx/framework').RouteHandler} 게시글 작성 */
15
+ static store;
16
+ /** @type {import('@fuzionx/framework').RouteHandler} 게시글 수정 */
17
+ static update;
18
+ /** @type {import('@fuzionx/framework').RouteHandler} 게시글 삭제 */
19
+ static destroy;
20
+
21
+ /**
22
+ * GET /api/posts — 게시글 목록 (페이지네이션)
23
+ *
24
+ * query.page로 페이지 결정. 10건씩 조회.
25
+ * 응답: { data, page, lastPage, hasMore, total, perPage }
26
+ *
27
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
28
+ * @returns {void}
29
+ */
30
+ async index(ctx) {
31
+ const page = parseInt(ctx.query.page || '1');
32
+ const result = await this.service('PostService').list(page, 10);
33
+
34
+ // 게시글 목록의 대표 썸네일 한 번에 조회 (N+1 방지)
35
+ const postIds = result.data.map(p => p.id);
36
+ const thumbMap = await this.service('PostService').getPostThumbnails(postIds);
37
+
38
+ // 작성자 이름 batch 조회 (N+1 방지)
39
+ const userIds = [...new Set(result.data.map(p => p.user_id).filter(Boolean))];
40
+ const userMap = new Map();
41
+ if (userIds.length) {
42
+ const users = await this.db.User.query().whereIn('id', userIds).get();
43
+ for (const u of users) userMap.set(u.id, u.name);
44
+ }
45
+
46
+ // 각 게시글에 thumbUrl, user_name, 포맷된 날짜 추가
47
+ const posts = result.data.map(p => ({
48
+ ...p,
49
+ thumbUrl: thumbMap.get(p.id) || null,
50
+ user_name: userMap.get(p.user_id) || '-',
51
+ created_at: DateUtil.format(p.created_at, 'YYYY-MM-DD HH:mm'),
52
+ }));
53
+
54
+ ctx.json({
55
+ data: posts,
56
+ page: result.page,
57
+ lastPage: result.lastPage,
58
+ hasMore: result.hasMore,
59
+ total: result.total,
60
+ });
61
+ }
62
+
63
+ /**
64
+ * GET /api/posts/:id — 게시글 상세 조회
65
+ *
66
+ * 게시글 + 작성자 정보 반환. 미존재 시 404.
67
+ * 응답: { post, author }
68
+ *
69
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
70
+ * @returns {void}
71
+ */
72
+ async show(ctx) {
73
+ const postService = this.service('PostService');
74
+ const post = await postService.find(ctx.params.id);
75
+ if (!post) return ctx.status(404).json({ error: ctx.t('post.not_found') });
76
+
77
+ const author = await this.db.User.find(post.user_id);
78
+ const files = await postService.getAttachmentsWithThumbs(post.id);
79
+
80
+ ctx.json({
81
+ post: { ...post, created_at: DateUtil.format(post.created_at, 'YYYY-MM-DD HH:mm') },
82
+ author: author ? { id: author.id, name: author.name } : null,
83
+ files,
84
+ });
85
+ }
86
+
87
+ /**
88
+ * POST /api/posts — 게시글 작성
89
+ *
90
+ * JSON body에서 title/content 추출. 현재 사용자의 user_id 자동 설정.
91
+ * 응답: 201 { post }
92
+ *
93
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
94
+ * @returns {void}
95
+ */
96
+ async store(ctx) {
97
+ // 업로드 오류 처리 (파일 타입/크기 제한 등)
98
+ if (ctx.uploadError) {
99
+ return ctx.status(400).json({ error: ctx.uploadError });
100
+ }
101
+ const { title, content } = ctx.body;
102
+ const uploadedFiles = ctx.files || [];
103
+ const post = await this.service('PostService').create(
104
+ { title, content, user_id: ctx.user.id },
105
+ uploadedFiles,
106
+ );
107
+ ctx.status(201).json({ post });
108
+ }
109
+
110
+ /**
111
+ * PUT /api/posts/:id — 게시글 수정
112
+ *
113
+ * 작성자 본인만 수정 가능. 권한 없으면 403.
114
+ * 응답: { post }
115
+ *
116
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
117
+ * @returns {void}
118
+ */
119
+ async update(ctx) {
120
+ // 업로드 오류 처리
121
+ if (ctx.uploadError) {
122
+ return ctx.status(400).json({ error: ctx.uploadError });
123
+ }
124
+ const post = await this.service('PostService').find(ctx.params.id);
125
+ if (!post || post.user_id !== ctx.user.id) {
126
+ return ctx.status(403).json({ error: ctx.t('common.forbidden') });
127
+ }
128
+ const { title, content } = ctx.body;
129
+ const uploadedFiles = ctx.files || [];
130
+ await this.service('PostService').update(ctx.params.id, { title, content }, uploadedFiles);
131
+ const updated = await this.service('PostService').find(ctx.params.id);
132
+ ctx.json({ post: updated });
133
+ }
134
+
135
+ /**
136
+ * DELETE /api/posts/:id — 게시글 삭제
137
+ *
138
+ * 작성자 본인만 삭제 가능. 권한 없으면 403.
139
+ * 응답: { deleted: true }
140
+ *
141
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
142
+ * @returns {void}
143
+ */
144
+ async destroy(ctx) {
145
+ const post = await this.service('PostService').find(ctx.params.id);
146
+ if (!post || post.user_id !== ctx.user.id) return ctx.status(403).json({ error: ctx.t('common.forbidden') });
147
+ await this.service('PostService').remove(ctx.params.id);
148
+ ctx.json({ deleted: true });
149
+ }
150
+
151
+ /**
152
+ * GET /api/posts/status?ids=1,2,3 — 게시글 상태 JSON 반환
153
+ *
154
+ * processing 상태 폴링용. SSR PostController.status와 동일.
155
+ * 응답: { statuses: { 1: 'published', 2: 'processing' } }
156
+ *
157
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
158
+ */
159
+ async status(ctx) {
160
+ const raw = ctx.query.ids || '';
161
+ const ids = raw.split(',').map(Number).filter(Boolean);
162
+ if (!ids.length) return ctx.json({ statuses: {} });
163
+
164
+ const posts = await this.db.Post.query().whereIn('id', ids).get();
165
+ const statuses = {};
166
+ for (const p of posts) {
167
+ statuses[p.id] = p.status || 'published';
168
+ }
169
+ ctx.json({ statuses });
170
+ }
171
+
172
+ /**
173
+ * DELETE /api/posts/attachment/:id — 개별 첨부파일 삭제
174
+ *
175
+ * 수정 화면에서 기존 첨부파일 개별 삭제.
176
+ * 작성자 본인만 삭제 가능.
177
+ *
178
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
179
+ */
180
+ async deleteAttachment(ctx) {
181
+ const att = await this.db.Attachment.find(ctx.params.id);
182
+ if (!att) return ctx.status(404).json({ error: 'Not found' });
183
+
184
+ const post = await this.db.Post.find(att.post_id);
185
+ if (!post || post.user_id !== ctx.user.id) return ctx.status(403).json({ error: 'Forbidden' });
186
+
187
+ await this.service('PostService').removeAttachment(att.id);
188
+ ctx.json({ deleted: true });
189
+ }
190
+
191
+ }
@@ -0,0 +1,43 @@
1
+ import { Controller } from '@fuzionx/framework';
2
+
3
+ /**
4
+ * SPA UserController — 사용자 프로필 REST API
5
+ *
6
+ * 프로필 조회/수정. JSON 요청/응답.
7
+ */
8
+ export default class UserController extends Controller {
9
+ /** @type {import('@fuzionx/framework').RouteHandler} 프로필 조회 */
10
+ static profile;
11
+ /** @type {import('@fuzionx/framework').RouteHandler} 프로필 수정 */
12
+ static updateProfile;
13
+
14
+ /**
15
+ * GET /api/user/profile — 프로필 조회
16
+ *
17
+ * 인증된 사용자의 기본 정보 반환.
18
+ * 응답: { user: { id, name, email, role } }
19
+ *
20
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
21
+ * @returns {void}
22
+ */
23
+ async profile(ctx) {
24
+ const { id, name, email, role } = ctx.user;
25
+ ctx.json({ user: { id, name, email, role } });
26
+ }
27
+
28
+ /**
29
+ * PUT /api/user/profile — 프로필 수정
30
+ *
31
+ * JSON body에서 name/email/password 추출.
32
+ * UserService.update() 호출 후 갱신된 사용자 정보 반환.
33
+ * 응답: { user: { id, name, email, role } }
34
+ *
35
+ * @param {import('@fuzionx/framework').Context} ctx - 요청 컨텍스트
36
+ * @returns {void}
37
+ */
38
+ async updateProfile(ctx) {
39
+ const { name, email, password } = ctx.body;
40
+ const user = await this.service('UserService').update(ctx.user.id, { name, email, password });
41
+ ctx.json({ user: { id: user.id, name: user.name, email: user.email, role: user.role } });
42
+ }
43
+ }