create-fuzionx 0.1.30 → 0.1.32
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/fx.js +20 -16
- package/index.js +103 -86
- package/package.json +1 -1
- package/templates/{fuzionx.yaml.tpl → common/fuzionx.yaml.tpl} +3 -1
- package/templates/{package.json.tpl → common/package.json.tpl} +5 -1
- package/templates/spa/controllers/AuthController.js +114 -0
- package/templates/spa/controllers/HomeController.js +68 -0
- package/templates/spa/controllers/PostController.js +191 -0
- package/templates/spa/controllers/UserController.js +43 -0
- package/templates/spa/meta.json +24 -0
- package/templates/spa/routes/api.js +31 -0
- package/templates/spa/routes/web.js +19 -0
- package/templates/spa/services/AuthService.js +48 -0
- package/templates/spa/services/PostService.js +372 -0
- package/templates/spa/services/UserService.js +48 -0
- package/templates/spa/views/default/errors/404.html +11 -0
- package/templates/spa/views/default/errors/500.html +11 -0
- package/templates/spa/views/default/layouts/main.html +34 -0
- package/templates/spa/views/default/pages/home.html +22 -0
- package/templates/spa/views/default/spa/index.html +13 -0
- package/templates/spa/views/default/spa/package.json +20 -0
- package/templates/spa/views/default/spa/src/App.vue +41 -0
- package/templates/spa/views/default/spa/src/assets/landing.css +220 -0
- package/templates/spa/views/default/spa/src/assets/style.css +1156 -0
- package/templates/spa/views/default/spa/src/components/AlertDialog.vue +179 -0
- package/templates/spa/views/default/spa/src/components/CodeBlock.vue +33 -0
- package/templates/spa/views/default/spa/src/components/EditorToolbar.vue +54 -0
- package/templates/spa/views/default/spa/src/components/FileUpload.vue +161 -0
- package/templates/spa/views/default/spa/src/components/FlashMessage.vue +39 -0
- package/templates/spa/views/default/spa/src/components/LanguageSwitcher.vue +108 -0
- package/templates/spa/views/default/spa/src/components/Lightbox.vue +62 -0
- package/templates/spa/views/default/spa/src/components/Navbar.vue +68 -0
- package/templates/spa/views/default/spa/src/components/Pagination.vue +166 -0
- package/templates/spa/views/default/spa/src/components/ToastContainer.vue +135 -0
- package/templates/spa/views/default/spa/src/composables/useApi.js +129 -0
- package/templates/spa/views/default/spa/src/composables/useClipboard.js +44 -0
- package/templates/spa/views/default/spa/src/composables/useDate.js +73 -0
- package/templates/spa/views/default/spa/src/composables/useDebounce.js +59 -0
- package/templates/spa/views/default/spa/src/composables/useFlash.js +46 -0
- package/templates/spa/views/default/spa/src/composables/useHeartbeat.js +45 -0
- package/templates/spa/views/default/spa/src/composables/useLocalStorage.js +43 -0
- package/templates/spa/views/default/spa/src/composables/useLocale.js +79 -0
- package/templates/spa/views/default/spa/src/composables/useWebSocket.js +93 -0
- package/templates/spa/views/default/spa/src/main.js +108 -0
- package/templates/spa/views/default/spa/src/plugins/alert.js +96 -0
- package/templates/spa/views/default/spa/src/plugins/toast.js +79 -0
- package/templates/spa/views/default/spa/src/router/index.js +29 -0
- package/templates/spa/views/default/spa/src/stores/auth.js +58 -0
- package/templates/spa/views/default/spa/src/views/BoardDetail.vue +169 -0
- package/templates/spa/views/default/spa/src/views/BoardForm.vue +192 -0
- package/templates/spa/views/default/spa/src/views/BoardList.vue +129 -0
- package/templates/spa/views/default/spa/src/views/ChatView.vue +327 -0
- package/templates/spa/views/default/spa/src/views/FeaturesView.vue +242 -0
- package/templates/spa/views/default/spa/src/views/HomeView.vue +215 -0
- package/templates/spa/views/default/spa/src/views/Login.vue +82 -0
- package/templates/spa/views/default/spa/src/views/Profile.vue +85 -0
- package/templates/spa/views/default/spa/src/views/Register.vue +84 -0
- package/templates/spa/views/default/spa/vite.config.js +28 -0
- package/templates/spa/views/default/spa/yarn.lock +633 -0
- package/templates/spa/ws/ChatHandler.js +7 -0
- package/templates/ssr/controllers/AuthController.js +119 -0
- package/templates/ssr/controllers/ChatController.js +15 -0
- package/templates/ssr/controllers/FeaturesController.js +15 -0
- package/templates/ssr/controllers/HomeController.js +21 -0
- package/templates/ssr/controllers/PostController.js +214 -0
- package/templates/ssr/controllers/UserController.js +48 -0
- package/templates/ssr/meta.json +11 -0
- package/templates/ssr/public/css/fx-ui.css +43 -0
- package/templates/ssr/public/css/landing.css +220 -0
- package/templates/ssr/public/css/style.css +1011 -0
- package/templates/ssr/public/js/fx-ui.js +124 -0
- package/templates/ssr/routes/web.js +46 -0
- package/templates/ssr/services/AuthService.js +48 -0
- package/templates/ssr/services/PostService.js +372 -0
- package/templates/ssr/services/UserService.js +48 -0
- package/templates/ssr/views/default/errors/404.html +11 -0
- package/templates/ssr/views/default/errors/500.html +48 -0
- package/templates/ssr/views/default/layouts/main.html +93 -0
- package/templates/ssr/views/default/pages/board/form.html +240 -0
- package/templates/ssr/views/default/pages/board/index.html +73 -0
- package/templates/ssr/views/default/pages/board/show.html +148 -0
- package/templates/ssr/views/default/pages/chat.html +288 -0
- package/templates/ssr/views/default/pages/features.html +373 -0
- package/templates/ssr/views/default/pages/home.html +258 -0
- package/templates/ssr/views/default/pages/login.html +27 -0
- package/templates/ssr/views/default/pages/profile.html +36 -0
- package/templates/ssr/views/default/pages/register.html +35 -0
- package/templates/ssr/views/default/partials/pagination.html +75 -0
- package/templates/ssr/ws/ChatHandler.js +138 -0
- package/templates/.env.example.tpl +0 -14
- package/templates/.gitignore.tpl +0 -5
- package/templates/fuzionx/controllers/HomeController.js +0 -13
- package/templates/fuzionx/routes/api.js.tpl +0 -7
- package/templates/fuzionx/routes/web.js.tpl +0 -5
- package/templates/fuzionx/views/default/layouts/main.html +0 -22
- package/templates/fuzionx/views/default/pages/home.html +0 -188
- package/templates/tester/views/default/errors/404.html +0 -15
- package/templates/tester/views/default/errors/500.html +0 -14
- /package/templates/{app.js.tpl → common/app.js.tpl} +0 -0
- /package/templates/{database → common/database}/models/User.js +0 -0
- /package/templates/{locales → common/locales}/en.json +0 -0
- /package/templates/{locales → common/locales}/ko.json +0 -0
- /package/templates/{shared → common/shared}/events/userEvents.js +0 -0
- /package/templates/{shared → common/shared}/jobs/CleanupJob.js +0 -0
- /package/templates/{shared → common/shared}/jobs/EmailTask.js +0 -0
- /package/templates/{shared → common/shared}/jobs/VideoPreviewTask.js +0 -0
- /package/templates/{shared → common/shared}/workers/heavy.js +0 -0
- /package/templates/{tester → common/tester}/controllers/FileController.js +0 -0
- /package/templates/{tester → common/tester}/controllers/HomeController.js +0 -0
- /package/templates/{tester → common/tester}/controllers/UserController.js +0 -0
- /package/templates/{tester → common/tester}/middleware/RequestLogger.js +0 -0
- /package/templates/{tester → common/tester}/routes/api.js +0 -0
- /package/templates/{tester → common/tester}/routes/web.js +0 -0
- /package/templates/{tester → common/tester}/services/UserService.js +0 -0
- /package/templates/{fuzionx → common/tester}/views/default/errors/404.html +0 -0
- /package/templates/{fuzionx → common/tester}/views/default/errors/500.html +0 -0
- /package/templates/{tester → common/tester}/views/default/layouts/main.html +0 -0
- /package/templates/{tester → common/tester}/views/default/pages/home.html +0 -0
- /package/templates/{tester → common/tester}/views/default/pages/i18n.html +0 -0
- /package/templates/{tester → common/tester}/views/default/pages/upload.html +0 -0
- /package/templates/{tester → common/tester}/views/default/pages/websocket.html +0 -0
- /package/templates/{tester → common/tester}/views/default/partials/footer.html +0 -0
- /package/templates/{tester → common/tester}/views/default/partials/header.html +0 -0
- /package/templates/{tester → common/tester}/ws/ChatHandler.js +0 -0
package/fx.js
CHANGED
|
@@ -48,22 +48,26 @@ if (existsSync(localBin) || existsSync(localCli)) {
|
|
|
48
48
|
fx new <name> 새 프로젝트 스캐폴딩
|
|
49
49
|
|
|
50
50
|
프로젝트 내 명령어 (@fuzionx/framework 필요):
|
|
51
|
-
fx make:app
|
|
52
|
-
fx make:controller <Name>
|
|
53
|
-
fx make:
|
|
54
|
-
fx make:
|
|
55
|
-
fx make:middleware <Name>
|
|
56
|
-
fx make:job <Name>
|
|
57
|
-
fx make:task <Name>
|
|
58
|
-
fx make:ws <Name>
|
|
59
|
-
fx make:event <Name>
|
|
60
|
-
fx
|
|
61
|
-
fx
|
|
62
|
-
fx
|
|
63
|
-
fx
|
|
64
|
-
fx
|
|
65
|
-
fx
|
|
66
|
-
fx
|
|
51
|
+
fx make:app --type=ssr|spa Create app (fixed name: app/ssr or app/spa)
|
|
52
|
+
fx make:controller <Name> --app= Create controller (app-specific)
|
|
53
|
+
fx make:service <Name> --app= Create service (app-specific)
|
|
54
|
+
fx make:model <Name> Create model (database/models)
|
|
55
|
+
fx make:middleware <Name> --app= Create middleware (app-specific)
|
|
56
|
+
fx make:job <Name> Create job (shared/jobs)
|
|
57
|
+
fx make:task <Name> Create task (shared/jobs)
|
|
58
|
+
fx make:ws <Name> --app= Create WsHandler (app-specific)
|
|
59
|
+
fx make:event <Name> Create event handler (shared/events)
|
|
60
|
+
fx make:worker <Name> Create worker (shared/workers)
|
|
61
|
+
fx make:test <Name> Create test
|
|
62
|
+
fx dev Start dev server (--watch)
|
|
63
|
+
fx dev:spa Start dev server + Vite HMR
|
|
64
|
+
fx build:spa Build SPA for production
|
|
65
|
+
fx stop Stop server (graceful)
|
|
66
|
+
fx restart Restart server (graceful)
|
|
67
|
+
fx test Run tests
|
|
68
|
+
fx routes Print route table
|
|
69
|
+
fx config Print fuzionx.yaml
|
|
70
|
+
fx db:sync Sync models → DB (--apply)
|
|
67
71
|
`);
|
|
68
72
|
} else {
|
|
69
73
|
console.error(`❌ @fuzionx/framework가 설치되지 않았습니다.`);
|
package/index.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
6
|
* npx create-fuzionx my-app
|
|
7
|
-
* npx create-fuzionx my-app
|
|
7
|
+
* npx create-fuzionx my-app --type=spa
|
|
8
|
+
* npx create-fuzionx my-app --type=ssr
|
|
8
9
|
*/
|
|
9
10
|
import { promises as fs } from 'node:fs';
|
|
10
11
|
import path from 'node:path';
|
|
@@ -24,65 +25,62 @@ async function loadTemplate(relPath, vars) {
|
|
|
24
25
|
return render(raw, vars);
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
/** 디렉토리 재귀 복사 */
|
|
28
|
-
async function copyDirRecursive(src, dst) {
|
|
28
|
+
/** 디렉토리 재귀 복사 — .tpl 파일은 변수 치환 */
|
|
29
|
+
async function copyDirRecursive(src, dst, vars = {}) {
|
|
29
30
|
await fs.mkdir(dst, { recursive: true });
|
|
30
31
|
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
31
32
|
for (const entry of entries) {
|
|
32
33
|
const srcPath = path.join(src, entry.name);
|
|
33
34
|
const dstPath = path.join(dst, entry.name);
|
|
34
35
|
if (entry.isDirectory()) {
|
|
35
|
-
await copyDirRecursive(srcPath, dstPath);
|
|
36
|
+
await copyDirRecursive(srcPath, dstPath, vars);
|
|
37
|
+
} else if (entry.name === 'meta.json') {
|
|
38
|
+
// meta.json은 스캐폴딩에 포함하지 않음
|
|
39
|
+
continue;
|
|
40
|
+
} else if (entry.name.endsWith('.tpl')) {
|
|
41
|
+
// .tpl 파일은 변수 치환 후 확장자 제거
|
|
42
|
+
const content = await fs.readFile(srcPath, 'utf-8');
|
|
43
|
+
const rendered = render(content, vars);
|
|
44
|
+
const destName = entry.name.replace(/\.tpl$/, '');
|
|
45
|
+
await fs.writeFile(path.join(dst, destName), rendered);
|
|
36
46
|
} else {
|
|
37
47
|
await fs.copyFile(srcPath, dstPath);
|
|
38
48
|
}
|
|
39
49
|
}
|
|
40
50
|
}
|
|
41
51
|
|
|
42
|
-
// ──
|
|
52
|
+
// ── 빈 디렉토리 (.gitkeep 포함) ──
|
|
43
53
|
|
|
44
|
-
const
|
|
45
|
-
{ tpl: 'package.json.tpl', dest: 'package.json' },
|
|
46
|
-
{ tpl: 'fuzionx.yaml.tpl', dest: 'fuzionx.yaml' },
|
|
47
|
-
{ tpl: 'app.js.tpl', dest: 'app.js' },
|
|
48
|
-
{ tpl: '.env.example.tpl', dest: '.env.example' },
|
|
49
|
-
{ tpl: '.env.example.tpl', dest: '.env' },
|
|
50
|
-
{ tpl: '.gitignore.tpl', dest: '.gitignore' },
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
/** fuzionx 앱 — .tpl 파일 (치환 필요) */
|
|
54
|
-
const FUZIONX_TPL_FILES = [
|
|
55
|
-
{ tpl: 'fuzionx/routes/web.js.tpl', dest: 'app/fuzionx/routes/web.js' },
|
|
56
|
-
{ tpl: 'fuzionx/routes/api.js.tpl', dest: 'app/fuzionx/routes/api.js' },
|
|
57
|
-
];
|
|
58
|
-
|
|
59
|
-
/** 빈 디렉토리 (.gitkeep 포함) */
|
|
60
|
-
const APP_DIRS = [
|
|
61
|
-
// fuzionx 앱
|
|
62
|
-
'app/fuzionx/services',
|
|
63
|
-
'app/fuzionx/middleware',
|
|
64
|
-
'app/fuzionx/ws',
|
|
65
|
-
// 인프라
|
|
54
|
+
const EMPTY_DIRS = [
|
|
66
55
|
'database/migrations',
|
|
67
56
|
'database/seeds',
|
|
68
57
|
'storage/logs',
|
|
69
58
|
'storage/uploads',
|
|
70
|
-
'public/css',
|
|
71
|
-
'public/js',
|
|
72
59
|
'tests',
|
|
73
60
|
];
|
|
74
61
|
|
|
75
62
|
// ── createApp ──
|
|
76
63
|
|
|
77
|
-
async function createApp(name, targetDir) {
|
|
64
|
+
async function createApp(name, targetDir, type = 'spa') {
|
|
78
65
|
const dir = targetDir || path.resolve(name);
|
|
66
|
+
const appName = type; // 앱 디렉토리명은 타입과 동일
|
|
79
67
|
const vars = {
|
|
80
68
|
name,
|
|
81
69
|
dbName: name.replace(/-/g, '_'),
|
|
82
70
|
};
|
|
83
71
|
|
|
84
|
-
|
|
85
|
-
|
|
72
|
+
const commonDir = path.join(TPL_DIR, 'common');
|
|
73
|
+
const typeDir = path.join(TPL_DIR, type);
|
|
74
|
+
|
|
75
|
+
// meta.json 로드
|
|
76
|
+
let meta = {};
|
|
77
|
+
try {
|
|
78
|
+
const metaRaw = await fs.readFile(path.join(typeDir, 'meta.json'), 'utf-8');
|
|
79
|
+
meta = JSON.parse(metaRaw);
|
|
80
|
+
} catch { /* meta.json 없어도 진행 */ }
|
|
81
|
+
|
|
82
|
+
// 1. 빈 디렉토리 생성
|
|
83
|
+
for (const d of EMPTY_DIRS) {
|
|
86
84
|
const fullDir = path.join(dir, d);
|
|
87
85
|
await fs.mkdir(fullDir, { recursive: true });
|
|
88
86
|
const files = await fs.readdir(fullDir).catch(() => []);
|
|
@@ -91,86 +89,105 @@ async function createApp(name, targetDir) {
|
|
|
91
89
|
}
|
|
92
90
|
}
|
|
93
91
|
|
|
94
|
-
//
|
|
95
|
-
for (const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
await fs.
|
|
99
|
-
|
|
92
|
+
// 앱 전용 빈 디렉토리
|
|
93
|
+
for (const d of ['services', 'middleware', 'ws']) {
|
|
94
|
+
const fullDir = path.join(dir, `app/${appName}`, d);
|
|
95
|
+
await fs.mkdir(fullDir, { recursive: true });
|
|
96
|
+
const files = await fs.readdir(fullDir).catch(() => []);
|
|
97
|
+
if (files.length === 0) {
|
|
98
|
+
await fs.writeFile(path.join(fullDir, '.gitkeep'), '');
|
|
99
|
+
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
102
|
+
// 2. common/ 루트 .tpl 파일 → 프로젝트 루트
|
|
103
|
+
const commonEntries = await fs.readdir(commonDir, { withFileTypes: true });
|
|
104
|
+
for (const entry of commonEntries) {
|
|
105
|
+
if (entry.isFile() && entry.name.endsWith('.tpl')) {
|
|
106
|
+
const content = await fs.readFile(path.join(commonDir, entry.name), 'utf-8');
|
|
107
|
+
const rendered = render(content, vars);
|
|
108
|
+
const destName = entry.name.replace(/\.tpl$/, '');
|
|
109
|
+
await fs.writeFile(path.join(dir, destName), rendered);
|
|
110
|
+
}
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
//
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
// tester 앱
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
113
|
+
// 3. common/ 하위 디렉토리 복사 (database, locales, shared)
|
|
114
|
+
for (const entry of commonEntries) {
|
|
115
|
+
if (!entry.isDirectory()) continue;
|
|
116
|
+
if (entry.name === 'tester') continue; // tester는 별도 처리
|
|
117
|
+
await copyDirRecursive(
|
|
118
|
+
path.join(commonDir, entry.name),
|
|
119
|
+
path.join(dir, entry.name),
|
|
120
|
+
vars,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 4. tester 앱 복사 (common/tester/ → app/tester/)
|
|
125
|
+
const testerSrc = path.join(commonDir, 'tester');
|
|
126
|
+
try {
|
|
127
|
+
await fs.access(testerSrc);
|
|
128
|
+
await copyDirRecursive(testerSrc, path.join(dir, 'app/tester'), vars);
|
|
129
|
+
} catch { /* tester 없으면 스킵 */ }
|
|
130
|
+
|
|
131
|
+
// 5. 타입별 템플릿 복사 → app/{type}/
|
|
132
|
+
// controllers, services, middleware, routes, ws, views
|
|
133
|
+
const typeEntries = await fs.readdir(typeDir, { withFileTypes: true });
|
|
134
|
+
for (const entry of typeEntries) {
|
|
135
|
+
if (!entry.isDirectory()) continue;
|
|
136
|
+
if (entry.name === 'public') continue; // public은 프로젝트 루트로 복사
|
|
137
|
+
await copyDirRecursive(
|
|
138
|
+
path.join(typeDir, entry.name),
|
|
139
|
+
path.join(dir, `app/${appName}`, entry.name),
|
|
140
|
+
vars,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 6. public/ 디렉토리 복사 (SSR의 경우 CSS/JS assets)
|
|
145
|
+
const publicSrc = path.join(typeDir, 'public');
|
|
146
|
+
try {
|
|
147
|
+
await fs.access(publicSrc);
|
|
148
|
+
await copyDirRecursive(publicSrc, path.join(dir, 'public'), vars);
|
|
149
|
+
} catch { /* public 없으면 스킵 */ }
|
|
144
150
|
|
|
145
151
|
return dir;
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
// ── CLI 엔트리 ──
|
|
149
155
|
|
|
150
|
-
const
|
|
151
|
-
const
|
|
156
|
+
const allArgs = process.argv.slice(2);
|
|
157
|
+
const name = allArgs.find(a => !a.startsWith('--'));
|
|
158
|
+
const typeFlag = allArgs.find(a => a.startsWith('--type='));
|
|
159
|
+
const type = typeFlag?.split('=')[1] || 'spa';
|
|
152
160
|
|
|
153
161
|
if (!name) {
|
|
154
162
|
console.error(`
|
|
155
|
-
Usage: npx create-fuzionx <app-name> [
|
|
163
|
+
Usage: npx create-fuzionx <app-name> [--type=ssr|spa]
|
|
156
164
|
|
|
157
165
|
Examples:
|
|
158
|
-
npx create-fuzionx my-app
|
|
159
|
-
npx create-fuzionx my-app
|
|
166
|
+
npx create-fuzionx my-app # default: spa
|
|
167
|
+
npx create-fuzionx my-app --type=ssr # MPA (SSR)
|
|
168
|
+
npx create-fuzionx my-app --type=spa # SPA (Vue.js 3 + SSR)
|
|
160
169
|
`);
|
|
161
170
|
process.exit(1);
|
|
162
171
|
}
|
|
163
172
|
|
|
173
|
+
const validTypes = ['ssr', 'spa'];
|
|
174
|
+
if (!validTypes.includes(type)) {
|
|
175
|
+
console.error(`❌ Unknown type: ${type}. Available: ${validTypes.join(', ')}`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
164
179
|
try {
|
|
165
|
-
const dir = await createApp(name,
|
|
180
|
+
const dir = await createApp(name, null, type);
|
|
181
|
+
const nextCmd = type === 'spa' ? 'npx fx dev:spa' : 'npx fx dev';
|
|
166
182
|
console.log(`
|
|
167
|
-
✅ Created ${name} at ${dir}
|
|
183
|
+
✅ Created ${name} at ${dir} (type: ${type})
|
|
168
184
|
|
|
169
185
|
Next steps:
|
|
170
186
|
|
|
171
187
|
cd ${name}
|
|
172
188
|
npm install
|
|
173
|
-
|
|
189
|
+
${type === 'spa' ? `cd app/spa/views/default/spa && npm install && cd ../../../../..
|
|
190
|
+
` : ''}${nextCmd}
|
|
174
191
|
`);
|
|
175
192
|
} catch (err) {
|
|
176
193
|
console.error(`❌ Failed to create app: ${err.message}`);
|
package/package.json
CHANGED
|
@@ -87,6 +87,8 @@ bridge:
|
|
|
87
87
|
static:
|
|
88
88
|
- url: /public
|
|
89
89
|
path: ./public
|
|
90
|
+
- url: /wasm
|
|
91
|
+
path: ./node_modules/@fuzionx/client
|
|
90
92
|
|
|
91
93
|
# ── 로깅 ──
|
|
92
94
|
logging:
|
|
@@ -125,7 +127,7 @@ bridge:
|
|
|
125
127
|
|
|
126
128
|
# ── ASP 암호화 ──
|
|
127
129
|
asp:
|
|
128
|
-
enabled:
|
|
130
|
+
enabled: true
|
|
129
131
|
master_secret: "${ASP_SECRET:change-me-in-production}"
|
|
130
132
|
header_signal: Ruxy-Enc-Mode
|
|
131
133
|
|
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "fx dev",
|
|
7
|
+
"dev:spa": "fx dev:spa",
|
|
8
|
+
"build:spa": "fx build:spa",
|
|
7
9
|
"test": "vitest run"
|
|
8
10
|
},
|
|
9
11
|
"dependencies": {
|
|
10
|
-
"@fuzionx/framework": "^0.1.
|
|
12
|
+
"@fuzionx/framework": "^0.1.32",
|
|
13
|
+
"@fuzionx/client": "^0.1.32",
|
|
11
14
|
"joi": "^18.1.1"
|
|
12
15
|
},
|
|
13
16
|
"devDependencies": {
|
|
17
|
+
"concurrently": "^9.0.0",
|
|
14
18
|
"vitest": "^3.0.0"
|
|
15
19
|
}
|
|
16
20
|
}
|
|
@@ -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,68 @@
|
|
|
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
|
+
const masterSecret = aspConfig.masterSecret || '';
|
|
33
|
+
|
|
34
|
+
// 세션에서 유저 조회 (인증 안 되어 있으면 null)
|
|
35
|
+
let user = null;
|
|
36
|
+
const userId = ctx.session?.get('userId');
|
|
37
|
+
if (userId && this.db?.User) {
|
|
38
|
+
try {
|
|
39
|
+
const u = await this.db.User.find(userId);
|
|
40
|
+
if (u) user = { id: u.id, name: u.name, email: u.email, role: u.role };
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isDev = this.config.get('app.environment') === 'development';
|
|
45
|
+
|
|
46
|
+
// SPA에 전달할 payload
|
|
47
|
+
const payload = {
|
|
48
|
+
user,
|
|
49
|
+
locale: ctx.locale,
|
|
50
|
+
locales: this.app?.i18n?.locales?.() || [],
|
|
51
|
+
translations: ctx.t.all(),
|
|
52
|
+
isDev,
|
|
53
|
+
asp: { headerSignal: aspConfig.headerSignal || 'Ruxy-Enc-Mode' },
|
|
54
|
+
app: { name: this.config.get('app.name'), theme: this.config.get('app.themes.default') },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// 암호화 (bridge crypto_encrypt_custom — WASM decrypt_custom과 호환)
|
|
58
|
+
const encryptedPayload = bridge.cryptoEncryptCustom(clientSecret, JSON.stringify(payload));
|
|
59
|
+
const encryptedSecret = bridge.cryptoEncryptCustom(clientSecret, masterSecret);
|
|
60
|
+
|
|
61
|
+
ctx.render('home', {
|
|
62
|
+
__fx__: encryptedPayload,
|
|
63
|
+
__fx_secret__: encryptedSecret,
|
|
64
|
+
client_secret: clientSecret,
|
|
65
|
+
isDev,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|