@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.
- package/cli/index.js +230 -114
- package/cli/templates/{app/fuzionx → make/app}/controllers/HomeController.js +1 -0
- package/cli/templates/{app/tester → make/app}/views/default/errors/404.html +0 -4
- package/cli/templates/{app/fuzionx/views/default/errors/404.html → make/app/views/default/errors/500.html} +1 -2
- package/cli/templates/make/app/views/default/pages/home.html +11 -0
- package/index.js +3 -0
- package/lib/core/Application.js +31 -6
- package/lib/core/Context.js +30 -1
- package/lib/helpers/I18nHelper.js +10 -6
- package/lib/middleware/apiAuth.js +79 -0
- package/lib/middleware/auth.js +42 -0
- package/lib/middleware/bodyParser.js +19 -0
- package/lib/middleware/cors.js +47 -0
- package/lib/middleware/csrf.js +32 -0
- package/lib/middleware/index.js +8 -277
- package/lib/middleware/session.js +27 -0
- package/lib/middleware/theme.js +20 -0
- package/lib/schedule/Job.js +4 -0
- package/lib/schedule/Queue.js +20 -8
- package/lib/schedule/Scheduler.js +84 -75
- package/lib/utilities/ArrUtil.js +112 -0
- package/lib/utilities/DateUtil.js +98 -0
- package/lib/utilities/FunctionUtil.js +119 -0
- package/lib/utilities/NumUtil.js +75 -0
- package/lib/utilities/ObjectUtil.js +170 -0
- package/lib/utilities/PaginationUtil.js +81 -0
- package/lib/utilities/StrUtil.js +105 -0
- package/lib/utilities/index.js +18 -0
- package/package.json +2 -2
- package/cli/templates/app/.env.example.tpl +0 -14
- package/cli/templates/app/.gitignore.tpl +0 -4
- package/cli/templates/app/app.js.tpl +0 -6
- package/cli/templates/app/database/models/User.js +0 -9
- package/cli/templates/app/fuzionx/views/default/errors/500.html +0 -14
- package/cli/templates/app/fuzionx/views/default/pages/home.html +0 -188
- package/cli/templates/app/fuzionx.yaml.tpl +0 -202
- package/cli/templates/app/locales/en.json +0 -52
- package/cli/templates/app/locales/ko.json +0 -52
- package/cli/templates/app/package.json.tpl +0 -16
- package/cli/templates/app/shared/events/userEvents.js +0 -10
- package/cli/templates/app/shared/jobs/CleanupJob.js +0 -18
- package/cli/templates/app/shared/jobs/EmailTask.js +0 -17
- package/cli/templates/app/shared/jobs/VideoPreviewTask.js +0 -47
- package/cli/templates/app/shared/workers/heavy.js +0 -18
- package/cli/templates/app/tester/controllers/FileController.js +0 -288
- package/cli/templates/app/tester/controllers/HomeController.js +0 -36
- package/cli/templates/app/tester/controllers/UserController.js +0 -43
- package/cli/templates/app/tester/middleware/RequestLogger.js +0 -13
- package/cli/templates/app/tester/routes/api.js +0 -397
- package/cli/templates/app/tester/routes/web.js +0 -8
- package/cli/templates/app/tester/services/UserService.js +0 -52
- package/cli/templates/app/tester/views/default/errors/500.html +0 -14
- package/cli/templates/app/tester/views/default/layouts/main.html +0 -82
- package/cli/templates/app/tester/views/default/pages/home.html +0 -56
- package/cli/templates/app/tester/views/default/pages/i18n.html +0 -104
- package/cli/templates/app/tester/views/default/pages/upload.html +0 -149
- package/cli/templates/app/tester/views/default/pages/websocket.html +0 -239
- package/cli/templates/app/tester/views/default/partials/footer.html +0 -8
- package/cli/templates/app/tester/views/default/partials/header.html +0 -20
- package/cli/templates/app/tester/ws/ChatHandler.js +0 -98
- /package/cli/templates/{app/fuzionx/routes/api.js.tpl → make/app/routes/api.js} +0 -0
- /package/cli/templates/{app/fuzionx/routes/web.js.tpl → make/app/routes/web.js} +0 -0
- /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
|
|
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
|
-
//
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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 =
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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 %}
|
|
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';
|
package/lib/core/Application.js
CHANGED
|
@@ -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
|
-
//
|
|
431
|
-
|
|
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
|
|
623
|
-
|
|
624
|
-
|
|
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
|
|
package/lib/core/Context.js
CHANGED
|
@@ -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 {
|
|
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
|
|
75
|
+
const value = messages?.[key];
|
|
76
|
+
if (value != null) return this._substitute(value, vars);
|
|
79
77
|
|
|
80
|
-
|
|
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 };
|