@fuzionx/framework 0.1.22 → 0.1.24
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 +104 -69
- package/cli/templates/app/app.js.tpl +1 -9
- package/cli/templates/app/controllers/HomeController.js +1 -1
- package/cli/templates/app/fuzionx.yaml.tpl +38 -4
- package/cli/templates/app/routes/web.js.tpl +3 -3
- package/cli/templates/make/middleware.js.tpl +1 -1
- package/lib/core/Application.js +306 -97
- package/lib/core/AutoLoader.js +53 -40
- package/lib/core/Config.js +4 -2
- package/lib/core/Context.js +6 -4
- package/lib/http/ErrorHandler.js +2 -1
- package/lib/middleware/index.js +3 -7
- package/lib/schedule/WorkerPool.js +3 -3
- package/package.json +2 -2
package/cli/index.js
CHANGED
|
@@ -51,16 +51,21 @@ async function loadTemplate(relativePath, vars = {}) {
|
|
|
51
51
|
// fx make:* — 코드 생성
|
|
52
52
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
/** 앱별 파일 (--app 필수) */
|
|
55
|
+
const APP_TYPE_DIRS = {
|
|
55
56
|
controller: 'controllers',
|
|
56
57
|
service: 'services',
|
|
57
|
-
model: 'models',
|
|
58
58
|
middleware: 'middleware',
|
|
59
|
-
job: 'jobs',
|
|
60
|
-
task: 'jobs',
|
|
61
59
|
ws: 'ws',
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** 공유 파일 (앱 무관) */
|
|
63
|
+
const SHARED_TYPE_DIRS = {
|
|
64
|
+
model: 'database/models',
|
|
65
|
+
job: 'shared/jobs',
|
|
66
|
+
task: 'shared/jobs',
|
|
67
|
+
event: 'shared/events',
|
|
68
|
+
worker: 'shared/workers',
|
|
64
69
|
test: 'tests',
|
|
65
70
|
};
|
|
66
71
|
|
|
@@ -78,15 +83,23 @@ const TYPE_SUFFIXES = {
|
|
|
78
83
|
};
|
|
79
84
|
|
|
80
85
|
/**
|
|
81
|
-
* fx make:<type> <Name> — 코드 생성
|
|
86
|
+
* fx make:<type> <Name> [--app=<appName>] — 코드 생성
|
|
82
87
|
* @param {string} type - 'controller', 'service', 'model', 등
|
|
83
88
|
* @param {string} name - PascalCase 이름 (e.g. 'User')
|
|
84
89
|
* @param {string} [baseDir='.'] - 프로젝트 루트
|
|
90
|
+
* @param {string} [appName] - 앱 이름 (controller/service/middleware/ws 시 필수)
|
|
85
91
|
* @returns {Promise<string>} - 생성된 파일 경로
|
|
86
92
|
*/
|
|
87
|
-
export async function makeFile(type, name, baseDir = '.') {
|
|
88
|
-
|
|
89
|
-
if (
|
|
93
|
+
export async function makeFile(type, name, baseDir = '.', appName) {
|
|
94
|
+
let dir;
|
|
95
|
+
if (APP_TYPE_DIRS[type]) {
|
|
96
|
+
if (!appName) throw new Error(`--app option required for make:${type} (e.g. fx make:${type} ${name} --app=backend)`);
|
|
97
|
+
dir = `app/${appName}/${APP_TYPE_DIRS[type]}`;
|
|
98
|
+
} else if (SHARED_TYPE_DIRS[type]) {
|
|
99
|
+
dir = SHARED_TYPE_DIRS[type];
|
|
100
|
+
} else {
|
|
101
|
+
throw new Error(`Unknown type: ${type}`);
|
|
102
|
+
}
|
|
90
103
|
|
|
91
104
|
const suffix = TYPE_SUFFIXES[type];
|
|
92
105
|
const fileName = `${name}${suffix}.js`;
|
|
@@ -114,25 +127,32 @@ const APP_FILES = [
|
|
|
114
127
|
{ tpl: 'app/package.json.tpl', dest: 'package.json' },
|
|
115
128
|
{ tpl: 'app/fuzionx.yaml.tpl', dest: 'fuzionx.yaml' },
|
|
116
129
|
{ tpl: 'app/app.js.tpl', dest: 'app.js' },
|
|
117
|
-
{ tpl: 'app/routes/web.js.tpl', dest: 'routes/web.js' },
|
|
118
|
-
{ tpl: 'app/routes/api.js.tpl', dest: 'routes/api.js' },
|
|
130
|
+
{ tpl: 'app/routes/web.js.tpl', dest: 'app/backend/routes/web.js' },
|
|
131
|
+
{ tpl: 'app/routes/api.js.tpl', dest: 'app/backend/routes/api.js' },
|
|
119
132
|
{ tpl: 'app/.env.example.tpl', dest: '.env.example' },
|
|
120
133
|
{ tpl: 'app/.env.example.tpl', dest: '.env' },
|
|
121
134
|
{ tpl: 'app/.gitignore.tpl', dest: '.gitignore' },
|
|
122
135
|
];
|
|
123
136
|
|
|
124
|
-
/** 스캐폴딩 디렉토리 (
|
|
137
|
+
/** 스캐폴딩 디렉토리 (multi-app 구조) */
|
|
125
138
|
const APP_DIRS = [
|
|
126
|
-
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
'
|
|
130
|
-
'
|
|
131
|
-
'
|
|
132
|
-
'
|
|
133
|
-
'
|
|
134
|
-
'
|
|
135
|
-
|
|
139
|
+
// 앱별
|
|
140
|
+
'app/backend/controllers',
|
|
141
|
+
'app/backend/routes',
|
|
142
|
+
'app/backend/services',
|
|
143
|
+
'app/backend/middleware',
|
|
144
|
+
'app/backend/views/default/pages',
|
|
145
|
+
'app/backend/views/default/layouts',
|
|
146
|
+
'app/backend/views/default/errors',
|
|
147
|
+
'app/backend/ws',
|
|
148
|
+
// 공유
|
|
149
|
+
'database/models',
|
|
150
|
+
'database/migrations',
|
|
151
|
+
'database/seeds',
|
|
152
|
+
'shared/events',
|
|
153
|
+
'shared/jobs',
|
|
154
|
+
'shared/workers',
|
|
155
|
+
// 인프라
|
|
136
156
|
'storage/logs',
|
|
137
157
|
'storage/uploads',
|
|
138
158
|
'public/css',
|
|
@@ -156,7 +176,11 @@ export async function createApp(name, targetDir) {
|
|
|
156
176
|
// 디렉토리 생성
|
|
157
177
|
for (const d of APP_DIRS) {
|
|
158
178
|
await fs.mkdir(path.join(dir, d), { recursive: true });
|
|
159
|
-
|
|
179
|
+
// .gitkeep 는 리프 원래 파일이 없는 디렉토리에만
|
|
180
|
+
const files = await fs.readdir(path.join(dir, d)).catch(() => []);
|
|
181
|
+
if (files.length === 0) {
|
|
182
|
+
await fs.writeFile(path.join(dir, d, '.gitkeep'), '');
|
|
183
|
+
}
|
|
160
184
|
}
|
|
161
185
|
|
|
162
186
|
// 템플릿 파일 생성
|
|
@@ -167,30 +191,36 @@ export async function createApp(name, targetDir) {
|
|
|
167
191
|
await fs.writeFile(fullPath, content);
|
|
168
192
|
}
|
|
169
193
|
|
|
170
|
-
// HomeController 복사
|
|
194
|
+
// HomeController 복사
|
|
171
195
|
const hcSrc = path.join(TPL_DIR, 'app/controllers/HomeController.js');
|
|
172
|
-
const hcDst = path.join(dir, 'controllers/HomeController.js');
|
|
196
|
+
const hcDst = path.join(dir, 'app/backend/controllers/HomeController.js');
|
|
173
197
|
await fs.copyFile(hcSrc, hcDst);
|
|
174
198
|
|
|
175
|
-
// Views — views/{theme}/ 구조
|
|
199
|
+
// Views — app/backend/views/{theme}/ 구조
|
|
176
200
|
const viewsSrc = path.join(TPL_DIR, 'app/views/default');
|
|
177
|
-
const viewsDst = path.join(dir, 'views/default');
|
|
201
|
+
const viewsDst = path.join(dir, 'app/backend/views/default');
|
|
178
202
|
|
|
179
203
|
// layouts/main.html
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
204
|
+
await fs.copyFile(
|
|
205
|
+
path.join(viewsSrc, 'layouts/main.html'),
|
|
206
|
+
path.join(viewsDst, 'layouts/main.html'),
|
|
207
|
+
);
|
|
183
208
|
|
|
184
209
|
// pages/home.html
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
210
|
+
await fs.copyFile(
|
|
211
|
+
path.join(viewsSrc, 'pages/home.html'),
|
|
212
|
+
path.join(viewsDst, 'pages/home.html'),
|
|
213
|
+
);
|
|
188
214
|
|
|
189
215
|
// errors/404.html, 500.html
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
await fs.copyFile(
|
|
217
|
+
path.join(viewsSrc, 'errors/404.html'),
|
|
218
|
+
path.join(viewsDst, 'errors/404.html'),
|
|
219
|
+
);
|
|
220
|
+
await fs.copyFile(
|
|
221
|
+
path.join(viewsSrc, 'errors/500.html'),
|
|
222
|
+
path.join(viewsDst, 'errors/500.html'),
|
|
223
|
+
);
|
|
194
224
|
|
|
195
225
|
return dir;
|
|
196
226
|
}
|
|
@@ -220,8 +250,10 @@ export async function run(args) {
|
|
|
220
250
|
if (command?.startsWith('make:')) {
|
|
221
251
|
const type = command.replace('make:', '');
|
|
222
252
|
const name = rest[0];
|
|
223
|
-
if (!name) { console.error(`Usage: fx ${command} <Name
|
|
224
|
-
const
|
|
253
|
+
if (!name) { console.error(`Usage: fx ${command} <Name> [--app=<appName>]`); process.exit(1); }
|
|
254
|
+
const appFlag = rest.find(a => a.startsWith('--app='));
|
|
255
|
+
const appName = appFlag?.split('=')[1] || undefined;
|
|
256
|
+
const file = await makeFile(type, name, '.', appName);
|
|
225
257
|
console.log(`✅ Created ${file}`);
|
|
226
258
|
return;
|
|
227
259
|
}
|
|
@@ -253,19 +285,22 @@ export async function run(args) {
|
|
|
253
285
|
try {
|
|
254
286
|
const appMod = await import(path.resolve('app.js'));
|
|
255
287
|
const app = appMod.default || appMod.app;
|
|
256
|
-
if (!app?.
|
|
257
|
-
const routes = app._router.getRoutes();
|
|
288
|
+
if (!app?._appRegistry) { console.error('Cannot load app routes'); return; }
|
|
258
289
|
console.log('');
|
|
259
|
-
console.log(' METHOD PATH HANDLER MIDDLEWARE');
|
|
260
|
-
console.log(' ────── ───── ──────── ──────────');
|
|
261
|
-
for (const
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
290
|
+
console.log(' APP METHOD PATH HANDLER MIDDLEWARE');
|
|
291
|
+
console.log(' ────── ────── ───── ──────── ──────────');
|
|
292
|
+
for (const [appName, appEntry] of app._appRegistry) {
|
|
293
|
+
const routes = appEntry.router.getRoutes();
|
|
294
|
+
for (const r of routes) {
|
|
295
|
+
const aName = appName.padEnd(10);
|
|
296
|
+
const method = r.method.padEnd(8);
|
|
297
|
+
const rPath = r.path.padEnd(22);
|
|
298
|
+
const handler = r.handler?.__handler__
|
|
299
|
+
? `${r.handler.controller?.name || ''}.${r.handler.method}`
|
|
300
|
+
: (typeof r.handler === 'function' ? r.handler.name || 'anonymous' : '');
|
|
301
|
+
const mw = (r.middleware || []).join(', ');
|
|
302
|
+
console.log(` ${aName} ${method} ${rPath} ${handler.padEnd(26)} ${mw}`);
|
|
303
|
+
}
|
|
269
304
|
}
|
|
270
305
|
console.log('');
|
|
271
306
|
} catch (err) { console.error('Failed to load routes:', err.message); }
|
|
@@ -286,7 +321,7 @@ export async function run(args) {
|
|
|
286
321
|
// ── fx db:sync — 모델 ↔ DB 스키마 diff ──
|
|
287
322
|
if (command === 'db:sync') {
|
|
288
323
|
try {
|
|
289
|
-
const modelsDir = path.resolve('models');
|
|
324
|
+
const modelsDir = path.resolve('database/models');
|
|
290
325
|
const files = await fs.readdir(modelsDir);
|
|
291
326
|
const jsFiles = files.filter(f => f.endsWith('.js'));
|
|
292
327
|
console.log('\n📊 Model Schema Status:\n');
|
|
@@ -309,21 +344,21 @@ export async function run(args) {
|
|
|
309
344
|
}
|
|
310
345
|
|
|
311
346
|
console.log(`
|
|
312
|
-
fx new <name>
|
|
313
|
-
fx make:controller <Name> Create controller
|
|
314
|
-
fx make:service <Name> Create service
|
|
315
|
-
fx make:model <Name>
|
|
316
|
-
fx make:middleware <Name>
|
|
317
|
-
fx make:job <Name>
|
|
318
|
-
fx make:task <Name>
|
|
319
|
-
fx make:ws <Name> Create WsHandler
|
|
320
|
-
fx make:event <Name>
|
|
321
|
-
fx make:worker <Name>
|
|
322
|
-
fx make:test <Name>
|
|
323
|
-
fx dev
|
|
324
|
-
fx test
|
|
325
|
-
fx routes
|
|
326
|
-
fx config
|
|
327
|
-
fx db:sync
|
|
347
|
+
fx new <name> Create new app (multi-app structure)
|
|
348
|
+
fx make:controller <Name> --app= Create controller (app-specific)
|
|
349
|
+
fx make:service <Name> --app= Create service (app-specific)
|
|
350
|
+
fx make:model <Name> Create model (database/models)
|
|
351
|
+
fx make:middleware <Name> --app= Create middleware (app-specific)
|
|
352
|
+
fx make:job <Name> Create job (shared/jobs)
|
|
353
|
+
fx make:task <Name> Create task (shared/jobs)
|
|
354
|
+
fx make:ws <Name> --app= Create WsHandler (app-specific)
|
|
355
|
+
fx make:event <Name> Create event handler (shared/events)
|
|
356
|
+
fx make:worker <Name> Create worker (shared/workers)
|
|
357
|
+
fx make:test <Name> Create test
|
|
358
|
+
fx dev Start dev server (--watch)
|
|
359
|
+
fx test Run tests
|
|
360
|
+
fx routes Print route table
|
|
361
|
+
fx config Print fuzionx.yaml
|
|
362
|
+
fx db:sync Sync models → DB (--apply)
|
|
328
363
|
`);
|
|
329
364
|
}
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import { Application } from '@fuzionx/framework';
|
|
2
|
-
import webRoutes from './routes/web.js';
|
|
3
|
-
import apiRoutes from './routes/api.js';
|
|
4
2
|
|
|
5
3
|
const app = new Application({ configPath: './fuzionx.yaml' });
|
|
6
4
|
|
|
7
|
-
app.routes(webRoutes);
|
|
8
|
-
app.routes(apiRoutes);
|
|
9
|
-
|
|
10
5
|
await app.boot();
|
|
11
|
-
|
|
12
|
-
app.listen(49080, () => {
|
|
13
|
-
console.log('🚀 FuzionX running on http://localhost:49080');
|
|
14
|
-
});
|
|
6
|
+
await app.listen();
|
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
# FuzionX Configuration
|
|
2
2
|
bridge:
|
|
3
3
|
port: 49080
|
|
4
|
-
workers:
|
|
4
|
+
workers: 0
|
|
5
5
|
worker_timeout: 30
|
|
6
6
|
|
|
7
|
+
cors:
|
|
8
|
+
enabled: false
|
|
9
|
+
origins:
|
|
10
|
+
- "*"
|
|
11
|
+
|
|
7
12
|
rate_limit:
|
|
8
13
|
enabled: true
|
|
9
14
|
per_ip: 1000
|
|
10
15
|
|
|
16
|
+
session:
|
|
17
|
+
enabled: false
|
|
18
|
+
store: memory
|
|
19
|
+
ttl: 3600
|
|
20
|
+
cookie_name: fuzionx.sid
|
|
21
|
+
|
|
22
|
+
websocket:
|
|
23
|
+
enabled: false
|
|
24
|
+
path: /ws
|
|
25
|
+
check_interval: 60
|
|
26
|
+
timeout: 60
|
|
27
|
+
|
|
28
|
+
static:
|
|
29
|
+
- url: /public
|
|
30
|
+
path: ./public
|
|
31
|
+
|
|
32
|
+
logging:
|
|
33
|
+
level: info
|
|
34
|
+
intercept_console: true
|
|
35
|
+
|
|
11
36
|
database:
|
|
12
37
|
default: main
|
|
13
38
|
connections:
|
|
@@ -18,15 +43,24 @@ database:
|
|
|
18
43
|
app:
|
|
19
44
|
name: '{{name}}'
|
|
20
45
|
environment: development
|
|
46
|
+
|
|
21
47
|
auth:
|
|
22
|
-
secret: 'change-me-in-production'
|
|
48
|
+
secret: '${JWT_SECRET:change-me-in-production}'
|
|
23
49
|
accessTtl: '15m'
|
|
50
|
+
|
|
24
51
|
i18n:
|
|
25
52
|
default_locale: 'ko'
|
|
26
53
|
fallback: 'en'
|
|
54
|
+
|
|
27
55
|
docs:
|
|
28
56
|
enabled: true
|
|
29
57
|
path: '/docs'
|
|
30
58
|
|
|
31
|
-
themes:
|
|
32
|
-
|
|
59
|
+
themes:
|
|
60
|
+
default: 'default'
|
|
61
|
+
|
|
62
|
+
# Multi-App (도메인 → 앱 라우팅)
|
|
63
|
+
apps:
|
|
64
|
+
"localhost": backend
|
|
65
|
+
# "admin.example.com": backend
|
|
66
|
+
# "example.com": frontend
|
package/lib/core/Application.js
CHANGED
|
@@ -77,12 +77,11 @@ export default class Application {
|
|
|
77
77
|
this._eventHandlers = new Map();
|
|
78
78
|
this._errorHandlers = [];
|
|
79
79
|
|
|
80
|
-
//
|
|
81
|
-
this.
|
|
80
|
+
// 멀티앱 레지스트리 (도메인→앱 라우팅)
|
|
81
|
+
this._appRegistry = new Map();
|
|
82
82
|
|
|
83
|
-
// 미들웨어
|
|
83
|
+
// 글로벌 미들웨어
|
|
84
84
|
this._globalMiddleware = [];
|
|
85
|
-
this._middlewareRegistry = new Map();
|
|
86
85
|
|
|
87
86
|
// 프레임워크 컴포넌트
|
|
88
87
|
this.db = new ModelRegistry();
|
|
@@ -105,14 +104,15 @@ export default class Application {
|
|
|
105
104
|
this.storage = null;
|
|
106
105
|
this._scheduler = null;
|
|
107
106
|
this._queue = null;
|
|
108
|
-
this._view = null;
|
|
109
107
|
this._wsHandlers = new Map();
|
|
110
108
|
|
|
111
109
|
// DB 연결 매니저
|
|
112
110
|
this._connectionManager = new ConnectionManager();
|
|
113
111
|
|
|
114
|
-
// Worker 매니저
|
|
115
|
-
this._workerPool = new WorkerPool(this
|
|
112
|
+
// Worker 매니저 — shared/workers 기준
|
|
113
|
+
this._workerPool = new WorkerPool(this, {
|
|
114
|
+
workersDir: path.resolve(this.baseDir, 'shared/workers'),
|
|
115
|
+
});
|
|
116
116
|
|
|
117
117
|
/** @type {WorkerPool} public worker 접근 */
|
|
118
118
|
this.worker = this._workerPool;
|
|
@@ -148,16 +148,24 @@ export default class Application {
|
|
|
148
148
|
// 라우터 접근
|
|
149
149
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
150
150
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
151
|
+
/**
|
|
152
|
+
* 앱별 라우터 인스턴스
|
|
153
|
+
* @param {string} [appName] - 앱 이름 (미지정 시 첫 번째 앱)
|
|
154
|
+
*/
|
|
155
|
+
getRouter(appName) {
|
|
156
|
+
const name = appName || this._getDefaultAppName();
|
|
157
|
+
return this._appRegistry.get(name)?.router || null;
|
|
158
|
+
}
|
|
153
159
|
|
|
154
160
|
/**
|
|
155
|
-
* 라우트 파일 로드
|
|
161
|
+
* 라우트 파일 로드 (특정 앱에)
|
|
156
162
|
* @param {Function} routeCallback - (r: RouteGroup) => void
|
|
157
|
-
* @
|
|
163
|
+
* @param {string} [appName]
|
|
158
164
|
*/
|
|
159
|
-
routes(routeCallback) {
|
|
160
|
-
this.
|
|
165
|
+
routes(routeCallback, appName) {
|
|
166
|
+
const name = appName || this._getDefaultAppName();
|
|
167
|
+
const entry = this._appRegistry.get(name);
|
|
168
|
+
if (entry?.router) entry.router.load(routeCallback);
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -178,13 +186,24 @@ export default class Application {
|
|
|
178
186
|
* @param {string} name - 'auth', 'csrf' 등
|
|
179
187
|
* @returns {Function} - (ctx, next) => void
|
|
180
188
|
*/
|
|
181
|
-
|
|
189
|
+
/**
|
|
190
|
+
* @param {string} name - 미들웨어 이름 (e.g. 'auth', 'role:admin')
|
|
191
|
+
* @param {string} [appName] - 앱 이름 (앱별 미들웨어 우선 탐색)
|
|
192
|
+
*/
|
|
193
|
+
resolveMiddleware(name, appName) {
|
|
182
194
|
// 캐시 히트 → zero-allocation (M-1)
|
|
183
|
-
|
|
195
|
+
const cacheKey = appName ? `${appName}:${name}` : name;
|
|
196
|
+
if (this._mwFnCache?.has(cacheKey)) return this._mwFnCache.get(cacheKey);
|
|
184
197
|
|
|
185
198
|
// 파라미터화: 'role:admin' → name='role', params=['admin']
|
|
186
199
|
const [mwName, ...params] = name.split(':');
|
|
187
|
-
|
|
200
|
+
|
|
201
|
+
// 앱별 미들웨어 우선 탐색 → 없으면 무시
|
|
202
|
+
let MwClass = null;
|
|
203
|
+
if (appName) {
|
|
204
|
+
const appEntry = this._appRegistry.get(appName);
|
|
205
|
+
MwClass = appEntry?.middlewareRegistry?.get(mwName) || null;
|
|
206
|
+
}
|
|
188
207
|
if (!MwClass) return null;
|
|
189
208
|
|
|
190
209
|
const instance = new MwClass(this);
|
|
@@ -192,7 +211,7 @@ export default class Application {
|
|
|
192
211
|
|
|
193
212
|
// 캐시 저장
|
|
194
213
|
if (!this._mwFnCache) this._mwFnCache = new Map();
|
|
195
|
-
this._mwFnCache.set(
|
|
214
|
+
this._mwFnCache.set(cacheKey, fn);
|
|
196
215
|
return fn;
|
|
197
216
|
}
|
|
198
217
|
|
|
@@ -264,17 +283,10 @@ export default class Application {
|
|
|
264
283
|
|
|
265
284
|
await this.emit('booting');
|
|
266
285
|
|
|
267
|
-
//
|
|
286
|
+
// 2. i18n 로드 (04-bootstrap-lifecycle.md)
|
|
268
287
|
await this.i18n.load();
|
|
269
288
|
|
|
270
|
-
//
|
|
271
|
-
this._view = new View({
|
|
272
|
-
viewsPath: path.resolve(this.baseDir, 'views'),
|
|
273
|
-
theme: this.config.get('themes.default', 'default'),
|
|
274
|
-
bridge: this._bridge,
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
// Scheduler / Queue / Storage 초기화 (04-bootstrap-lifecycle.md)
|
|
289
|
+
// 3. Scheduler / Queue / Storage 초기화
|
|
278
290
|
this._scheduler = new Scheduler(this);
|
|
279
291
|
this._queue = new Queue(this, { driver: this.config.get('queue.driver', 'memory') });
|
|
280
292
|
this.storage = new Storage({
|
|
@@ -283,16 +295,35 @@ export default class Application {
|
|
|
283
295
|
fileHelper: this.file,
|
|
284
296
|
});
|
|
285
297
|
|
|
286
|
-
//
|
|
287
|
-
const
|
|
288
|
-
await
|
|
298
|
+
// 4. Phase 1: 공유 리소스 로드 (database/models, shared/events,jobs,workers)
|
|
299
|
+
const sharedLoader = new AutoLoader(this, this.baseDir, { mode: 'shared' });
|
|
300
|
+
await sharedLoader.load();
|
|
301
|
+
|
|
302
|
+
// 5. Phase 2: 앱별 리소스 로드 (app/{name}/controllers,routes,services,middleware,ws,views)
|
|
303
|
+
const appNames = this._getAppNames();
|
|
304
|
+
for (const name of appNames) {
|
|
305
|
+
const appDir = path.resolve(this.baseDir, 'app', name);
|
|
306
|
+
const appEntry = {
|
|
307
|
+
name,
|
|
308
|
+
router: new Router(),
|
|
309
|
+
controllers: new Map(),
|
|
310
|
+
controllerCache: new Map(),
|
|
311
|
+
middlewareRegistry: new Map(),
|
|
312
|
+
wsHandlers: new Map(),
|
|
313
|
+
view: new View({
|
|
314
|
+
viewsPath: path.resolve(appDir, 'views'),
|
|
315
|
+
theme: this.config.get('app.themes.default', 'default'),
|
|
316
|
+
bridge: this._bridge,
|
|
317
|
+
}),
|
|
318
|
+
};
|
|
319
|
+
const appLoader = new AutoLoader(this, appDir, { mode: 'app', appContext: appEntry });
|
|
320
|
+
await appLoader.load();
|
|
321
|
+
this._appRegistry.set(name, appEntry);
|
|
322
|
+
}
|
|
289
323
|
|
|
290
|
-
// DB 연결 매니저 초기화 (database 섹션이 있을 때만)
|
|
324
|
+
// 6. DB 연결 매니저 초기화 (database 섹션이 있을 때만)
|
|
291
325
|
const dbConfig = this.config.get('database');
|
|
292
326
|
if (dbConfig) {
|
|
293
|
-
// connections 키가 없는 레거시 형식 호환:
|
|
294
|
-
// database.main: { driver: 'sqlite', ... }
|
|
295
|
-
// → database.connections.main: { driver: 'sqlite', ... }
|
|
296
327
|
if (!dbConfig.connections) {
|
|
297
328
|
const connections = {};
|
|
298
329
|
for (const [key, val] of Object.entries(dbConfig)) {
|
|
@@ -312,20 +343,66 @@ export default class Application {
|
|
|
312
343
|
}
|
|
313
344
|
}
|
|
314
345
|
|
|
315
|
-
// OpenAPI / Swagger UI 라우트 등록 (
|
|
346
|
+
// 7. OpenAPI / Swagger UI 라우트 등록 (모든 앱의 라우트 통합)
|
|
316
347
|
this._registerDocsRoutes();
|
|
317
348
|
|
|
318
349
|
this._booted = true;
|
|
319
350
|
await this.emit('booted');
|
|
320
351
|
}
|
|
321
352
|
|
|
353
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
354
|
+
// Multi-App 도메인 라우팅
|
|
355
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* config.apps에서 유니크 앱 이름 목록 추출
|
|
359
|
+
* @returns {string[]}
|
|
360
|
+
*/
|
|
361
|
+
_getAppNames() {
|
|
362
|
+
const appsConfig = this.config.get('apps') || {};
|
|
363
|
+
return [...new Set(Object.values(appsConfig))];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 기본 앱 이름 (config.apps의 첫 번째 항목 값)
|
|
368
|
+
* @returns {string}
|
|
369
|
+
*/
|
|
370
|
+
_getDefaultAppName() {
|
|
371
|
+
const appsConfig = this.config.get('apps') || {};
|
|
372
|
+
const values = Object.values(appsConfig);
|
|
373
|
+
return values[0] || 'backend';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 호스트 → 앱 이름 결정
|
|
378
|
+
* reverse proxy 경유 시 X-Forwarded-Host 우선 사용.
|
|
379
|
+
* @param {string} host - 요청 Host 헤더 (port 포함 가능)
|
|
380
|
+
* @param {object} [headers] - 전체 요청 헤더 (proxy 지원)
|
|
381
|
+
* @returns {string} 앱 이름
|
|
382
|
+
*/
|
|
383
|
+
_resolveApp(host, headers) {
|
|
384
|
+
// reverse proxy: X-Forwarded-Host > X-Original-Host > Host
|
|
385
|
+
const forwardedHost = headers?.['x-forwarded-host'] || headers?.['X-Forwarded-Host']
|
|
386
|
+
|| headers?.['x-original-host'] || headers?.['X-Original-Host'];
|
|
387
|
+
const rawHost = forwardedHost || host || '';
|
|
388
|
+
const hostname = rawHost.split(':')[0]; // 포트 제거
|
|
389
|
+
const appsConfig = this.config.get('apps') || {};
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
// 정확한 도메인 매칭
|
|
394
|
+
if (appsConfig[hostname]) return appsConfig[hostname];
|
|
395
|
+
// 매칭 없으면 첫 번째 앱 (기본)
|
|
396
|
+
return this._getDefaultAppName();
|
|
397
|
+
}
|
|
398
|
+
|
|
322
399
|
/**
|
|
323
400
|
* Bridge 참조를 모든 Helper 인스턴스에 전파
|
|
324
401
|
* @private
|
|
325
402
|
*/
|
|
326
403
|
_propagateBridge() {
|
|
327
404
|
if (!this._bridge) return;
|
|
328
|
-
|
|
405
|
+
// View는 앱별 → boot()에서 생성 시 bridge 주입됨
|
|
329
406
|
if (this.logger) this.logger._bridge = this._bridge;
|
|
330
407
|
if (this.crypto) this.crypto._bridge = this._bridge;
|
|
331
408
|
if (this.hash) this.hash._bridge = this._bridge;
|
|
@@ -426,7 +503,12 @@ export default class Application {
|
|
|
426
503
|
if (!docsConfig || docsConfig.enabled === false) return;
|
|
427
504
|
|
|
428
505
|
const docsPath = docsConfig.path || '/docs';
|
|
429
|
-
|
|
506
|
+
|
|
507
|
+
// 모든 앱의 라우트 통합
|
|
508
|
+
const allRoutes = [];
|
|
509
|
+
for (const [, appEntry] of this._appRegistry) {
|
|
510
|
+
allRoutes.push(...appEntry.router.getRoutes());
|
|
511
|
+
}
|
|
430
512
|
|
|
431
513
|
// OpenAPI spec 빌드 (1회, 캐싱)
|
|
432
514
|
this._openapi = new OpenAPI({
|
|
@@ -435,21 +517,26 @@ export default class Application {
|
|
|
435
517
|
description: docsConfig.description || '',
|
|
436
518
|
servers: docsConfig.servers || [],
|
|
437
519
|
});
|
|
438
|
-
this._openapi.build(
|
|
520
|
+
this._openapi.build(allRoutes);
|
|
521
|
+
|
|
522
|
+
// docs 라우트는 기본 앱의 라우터에 등록
|
|
523
|
+
const defaultAppName = this._getDefaultAppName();
|
|
524
|
+
const defaultApp = this._appRegistry.get(defaultAppName);
|
|
525
|
+
if (!defaultApp) return;
|
|
439
526
|
|
|
440
527
|
// JSON spec
|
|
441
|
-
|
|
528
|
+
defaultApp.router.get(`${docsPath}/openapi.json`, (ctx) => {
|
|
442
529
|
ctx.json(this._openapi.toJSON());
|
|
443
530
|
});
|
|
444
531
|
|
|
445
532
|
// YAML spec
|
|
446
|
-
|
|
533
|
+
defaultApp.router.get(`${docsPath}/openapi.yaml`, (ctx) => {
|
|
447
534
|
ctx.setHeader('Content-Type', 'text/yaml; charset=utf-8');
|
|
448
535
|
ctx.send(this._openapi.toYAML());
|
|
449
536
|
});
|
|
450
537
|
|
|
451
538
|
// Swagger UI HTML (CDN — 외부 의존 없음)
|
|
452
|
-
|
|
539
|
+
defaultApp.router.get(docsPath, (ctx) => {
|
|
453
540
|
const specUrl = `${docsPath}/openapi.json`;
|
|
454
541
|
const title = docsConfig.title || 'API Docs';
|
|
455
542
|
ctx.html(Application._swaggerHtml(title, specUrl));
|
|
@@ -492,107 +579,225 @@ export default class Application {
|
|
|
492
579
|
}
|
|
493
580
|
|
|
494
581
|
/**
|
|
495
|
-
* 싱글톤 컨트롤러 초기화
|
|
582
|
+
* 앱별 싱글톤 컨트롤러 초기화
|
|
496
583
|
* @private
|
|
497
584
|
*/
|
|
498
585
|
_initControllers() {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
586
|
+
for (const [, appEntry] of this._appRegistry) {
|
|
587
|
+
for (const route of appEntry.router.getRoutes()) {
|
|
588
|
+
const handler = route.handler;
|
|
589
|
+
if (handler?.__handler__ && handler.controller) {
|
|
590
|
+
const CtrlClass = handler.controller;
|
|
591
|
+
if (!appEntry.controllerCache.has(CtrlClass)) {
|
|
592
|
+
appEntry.controllerCache.set(CtrlClass, new CtrlClass(this));
|
|
593
|
+
}
|
|
507
594
|
}
|
|
508
595
|
}
|
|
509
596
|
}
|
|
510
597
|
}
|
|
511
598
|
|
|
512
599
|
/**
|
|
513
|
-
* 프레임워크 라우트 → Bridge
|
|
600
|
+
* 프레임워크 라우트 → Bridge 라우트 변환 (Host 기반 앱 디스패치)
|
|
601
|
+
*
|
|
602
|
+
* 동일 path에 여러 앱이 라우트를 가질 수 있으므로,
|
|
603
|
+
* 각 method+path 당 하나의 Bridge 핸들러를 등록하고
|
|
604
|
+
* 런타임에 Host 헤더로 앱을 결정한다.
|
|
605
|
+
*
|
|
514
606
|
* @private
|
|
515
607
|
*/
|
|
516
608
|
_registerBridgeRoutes(coreApp) {
|
|
517
609
|
this._initControllers();
|
|
518
|
-
const routes = this._router.getRoutes();
|
|
519
610
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
611
|
+
// 디스패치 테이블: "METHOD:path" → Map<appName, { route, middlewareFns }>
|
|
612
|
+
const dispatch = new Map();
|
|
613
|
+
|
|
614
|
+
for (const [appName, appEntry] of this._appRegistry) {
|
|
615
|
+
for (const route of appEntry.router.getRoutes()) {
|
|
616
|
+
const key = `${route.method}:${route.path}`;
|
|
617
|
+
if (!dispatch.has(key)) dispatch.set(key, new Map());
|
|
523
618
|
|
|
524
|
-
|
|
525
|
-
|
|
619
|
+
// 미들웨어 체인 사전 구성
|
|
620
|
+
const middlewareFns = [...this._globalMiddleware];
|
|
621
|
+
if (route.middleware?.length) {
|
|
622
|
+
for (const mwName of route.middleware) {
|
|
623
|
+
const fn = this.resolveMiddleware(mwName, appName);
|
|
624
|
+
if (fn) middlewareFns.push(fn);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
dispatch.get(key).set(appName, { route, middlewareFns });
|
|
526
629
|
}
|
|
527
630
|
}
|
|
631
|
+
|
|
632
|
+
// 유니크 path별 하나의 Bridge 핸들러 등록
|
|
633
|
+
for (const [key, appMap] of dispatch) {
|
|
634
|
+
const [method, routePath] = [key.split(':')[0].toLowerCase(), key.slice(key.indexOf(':') + 1)];
|
|
635
|
+
if (typeof coreApp[method] !== 'function') continue;
|
|
636
|
+
|
|
637
|
+
const bridgeHandler = this._createDispatchHandler(appMap);
|
|
638
|
+
coreApp[method](routePath, bridgeHandler);
|
|
639
|
+
}
|
|
528
640
|
}
|
|
529
641
|
|
|
530
642
|
/**
|
|
531
|
-
* WsHandler → Bridge WS 이벤트 연결
|
|
643
|
+
* WsHandler → Bridge WS 이벤트 연결 (모든 앱의 핸들러)
|
|
532
644
|
* @private
|
|
533
645
|
*/
|
|
534
646
|
_registerWsHandlers(coreApp) {
|
|
535
|
-
if (!this._wsHandlers || this._wsHandlers.size === 0) return;
|
|
536
647
|
if (!coreApp.ws) return;
|
|
537
648
|
|
|
538
|
-
for (const [
|
|
539
|
-
|
|
540
|
-
|
|
649
|
+
for (const [, appEntry] of this._appRegistry) {
|
|
650
|
+
if (!appEntry.wsHandlers || appEntry.wsHandlers.size === 0) continue;
|
|
651
|
+
|
|
652
|
+
for (const [namespace, HandlerClass] of appEntry.wsHandlers) {
|
|
653
|
+
const eventMap = HandlerClass.buildEventMap();
|
|
654
|
+
const wsNs = coreApp.ws(namespace);
|
|
655
|
+
|
|
656
|
+
// 싱글톤 인스턴스
|
|
657
|
+
const inst = new HandlerClass(this);
|
|
658
|
+
|
|
659
|
+
wsNs.on('connect', (socket) => {
|
|
660
|
+
console.log(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
|
|
661
|
+
inst.onConnect(socket);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
wsNs.on('message', (socket, rawMessage) => {
|
|
665
|
+
let parsed;
|
|
666
|
+
try { parsed = typeof rawMessage === 'string' ? JSON.parse(rawMessage) : rawMessage; }
|
|
667
|
+
catch { parsed = { type: 'message', data: rawMessage }; }
|
|
668
|
+
const eventType = parsed.type || 'message';
|
|
669
|
+
const eventData = parsed.data || parsed;
|
|
670
|
+
console.log(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
|
|
671
|
+
const entry = eventMap.get(eventType);
|
|
672
|
+
if (entry) {
|
|
673
|
+
const result = entry.handler.call(inst, socket, eventData);
|
|
674
|
+
if (result && typeof result.then === 'function') {
|
|
675
|
+
result.then(r => { if (r) socket.send(JSON.stringify(r)); })
|
|
676
|
+
.catch(e => console.error(`[WS] error: ${eventType}`, e));
|
|
677
|
+
} else if (result) {
|
|
678
|
+
socket.send(JSON.stringify(result));
|
|
679
|
+
}
|
|
680
|
+
} else {
|
|
681
|
+
inst.onEvent(socket, eventType, eventData);
|
|
682
|
+
}
|
|
683
|
+
});
|
|
541
684
|
|
|
542
|
-
|
|
543
|
-
|
|
685
|
+
wsNs.on('disconnect', (socket) => {
|
|
686
|
+
console.log(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
|
|
687
|
+
inst.onDisconnect(socket);
|
|
688
|
+
});
|
|
544
689
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
690
|
+
console.log(`[WS] 핸들러 등록: ${namespace} (events: ${[...eventMap.keys()].join(', ')})`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
549
694
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
695
|
+
/**
|
|
696
|
+
* Host 기반 디스패치 핸들러 생성
|
|
697
|
+
*
|
|
698
|
+
* 동일 path에 대해 Host 헤더로 앱을 결정하고
|
|
699
|
+
* 해당 앱의 route + middleware 체인을 실행.
|
|
700
|
+
*
|
|
701
|
+
* @param {Map<string, {route, middlewareFns}>} appMap - appName → {route, middlewareFns}
|
|
702
|
+
* @returns {Function} Bridge 핸들러
|
|
703
|
+
* @private
|
|
704
|
+
*/
|
|
705
|
+
_createDispatchHandler(appMap) {
|
|
706
|
+
return (req, res) => {
|
|
707
|
+
// Host 헤더에서 앱 결정 (reverse proxy: X-Forwarded-Host 우선)
|
|
708
|
+
const host = req.headers?.host || req.headers?.Host || '';
|
|
709
|
+
const appName = this._resolveApp(host, req.headers);
|
|
710
|
+
|
|
711
|
+
// 해당 앱의 route+middleware 찾기 (없으면 기본 앱)
|
|
712
|
+
let entry = appMap.get(appName);
|
|
713
|
+
if (!entry) {
|
|
714
|
+
// 매칭 앱에 이 경로가 없으면 기본 앱 시도
|
|
715
|
+
const defaultApp = this._getDefaultAppName();
|
|
716
|
+
entry = appMap.get(defaultApp);
|
|
717
|
+
}
|
|
718
|
+
if (!entry) {
|
|
719
|
+
// 어떤 앱에도 없으면 첫 번째 앱
|
|
720
|
+
entry = appMap.values().next().value;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const { route, middlewareFns } = entry;
|
|
724
|
+
|
|
725
|
+
// rawReq → Context
|
|
726
|
+
const ctx = new Context({
|
|
727
|
+
method: req.method,
|
|
728
|
+
url: req.url,
|
|
729
|
+
path: req.path,
|
|
730
|
+
query: req.query,
|
|
731
|
+
params: req.params,
|
|
732
|
+
headers: req.headers,
|
|
733
|
+
body: req.body || req.json,
|
|
734
|
+
remoteIp: req.ip,
|
|
735
|
+
handlerId: req.handlerId,
|
|
736
|
+
requestId: req.requestId,
|
|
737
|
+
sessionId: req.sessionId,
|
|
738
|
+
session: req.session?._data || {},
|
|
739
|
+
files: req.files || null,
|
|
740
|
+
formFields: req.formFields || null,
|
|
741
|
+
}, this);
|
|
742
|
+
|
|
743
|
+
ctx.appName = appName;
|
|
744
|
+
|
|
745
|
+
// async 체인 실행 → sendAsyncResponse
|
|
746
|
+
const promise = this._executeChain(middlewareFns, route, ctx);
|
|
747
|
+
|
|
748
|
+
promise.then(() => {
|
|
749
|
+
const response = ctx.toResponse();
|
|
750
|
+
const contentType = response.headers?.['Content-Type'] || 'application/json';
|
|
751
|
+
const headerParts = [];
|
|
752
|
+
if (response.headers) {
|
|
753
|
+
for (const [k, v] of Object.entries(response.headers)) {
|
|
754
|
+
if (k !== 'Content-Type') headerParts.push(`${k}: ${v}`);
|
|
565
755
|
}
|
|
566
|
-
}
|
|
567
|
-
|
|
756
|
+
}
|
|
757
|
+
const extraHeaders = headerParts.length > 0 ? headerParts.join('\r\n') + '\r\n' : '';
|
|
758
|
+
try {
|
|
759
|
+
this._coreApp._bridge.sendAsyncResponse(
|
|
760
|
+
req.requestId, response.status,
|
|
761
|
+
response.body || '', contentType, extraHeaders,
|
|
762
|
+
);
|
|
763
|
+
} catch (e) {
|
|
764
|
+
console.error('[fuzionx] sendAsyncResponse failed:', e.message);
|
|
765
|
+
}
|
|
766
|
+
}).catch((err) => {
|
|
767
|
+
console.error('[fuzionx] Handler error:', err.message || err);
|
|
768
|
+
try {
|
|
769
|
+
this._coreApp._bridge.sendAsyncResponse(
|
|
770
|
+
req.requestId, 500,
|
|
771
|
+
JSON.stringify({ error: err.message || 'Internal Server Error' }),
|
|
772
|
+
'application/json', '',
|
|
773
|
+
);
|
|
774
|
+
} catch (e) {
|
|
775
|
+
console.error('[fuzionx] sendAsyncResponse error failed:', e.message);
|
|
568
776
|
}
|
|
569
777
|
});
|
|
570
778
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
inst.onDisconnect(socket);
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
console.log(`[WS] 핸들러 등록: ${namespace} (events: ${[...eventMap.keys()].join(', ')})`);
|
|
577
|
-
}
|
|
779
|
+
return { async: true };
|
|
780
|
+
};
|
|
578
781
|
}
|
|
579
782
|
|
|
580
783
|
/**
|
|
581
|
-
* 단일 라우트의 Bridge 핸들러 생성
|
|
784
|
+
* 단일 라우트의 Bridge 핸들러 생성 (앱별)
|
|
582
785
|
* rawReq → Context → middleware → controller → toResponse
|
|
786
|
+
* @param {object} route
|
|
787
|
+
* @param {string} appName - 이 라우트가 속한 앱
|
|
583
788
|
* @private
|
|
584
789
|
*/
|
|
585
|
-
_createBridgeHandler(route) {
|
|
790
|
+
_createBridgeHandler(route, appName) {
|
|
586
791
|
// 미들웨어 체인 사전 구성
|
|
587
792
|
const middlewareFns = [];
|
|
588
793
|
|
|
589
794
|
// 글로벌 미들웨어
|
|
590
795
|
middlewareFns.push(...this._globalMiddleware);
|
|
591
796
|
|
|
592
|
-
// 라우트 미들웨어 (이름 → 인스턴스 변환)
|
|
797
|
+
// 앱별 라우트 미들웨어 (이름 → 인스턴스 변환)
|
|
593
798
|
if (route.middleware?.length) {
|
|
594
799
|
for (const mwName of route.middleware) {
|
|
595
|
-
const fn = this.resolveMiddleware(mwName);
|
|
800
|
+
const fn = this.resolveMiddleware(mwName, appName);
|
|
596
801
|
if (fn) middlewareFns.push(fn);
|
|
597
802
|
}
|
|
598
803
|
}
|
|
@@ -616,6 +821,9 @@ export default class Application {
|
|
|
616
821
|
formFields: req.formFields || null,
|
|
617
822
|
}, this);
|
|
618
823
|
|
|
824
|
+
// 앱 이름 주입
|
|
825
|
+
ctx.appName = appName;
|
|
826
|
+
|
|
619
827
|
// Framework → async 체인 실행 후 직접 sendAsyncResponse
|
|
620
828
|
const promise = this._executeChain(middlewareFns, route, ctx);
|
|
621
829
|
|
|
@@ -698,8 +906,9 @@ export default class Application {
|
|
|
698
906
|
*/
|
|
699
907
|
async _executeHandler(handler, ctx) {
|
|
700
908
|
if (handler?.__handler__) {
|
|
701
|
-
// 싱글톤 컨트롤러 메서드 참조
|
|
702
|
-
const
|
|
909
|
+
// 앱별 싱글톤 컨트롤러 메서드 참조
|
|
910
|
+
const appEntry = this._appRegistry.get(ctx.appName);
|
|
911
|
+
const instance = appEntry?.controllerCache?.get(handler.controller);
|
|
703
912
|
if (instance && typeof instance[handler.method] === 'function') {
|
|
704
913
|
await instance[handler.method](ctx);
|
|
705
914
|
return;
|
package/lib/core/AutoLoader.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* AutoLoader — 앱 디렉토리 자동 스캔 + 등록
|
|
3
3
|
*
|
|
4
4
|
* Application.boot() 시 호출.
|
|
5
|
-
*
|
|
5
|
+
* multi-app 구조:
|
|
6
|
+
* mode='shared' → database/models, shared/events, shared/jobs, shared/workers
|
|
7
|
+
* mode='app' → app/{name}/controllers, routes, services, middleware, ws
|
|
6
8
|
*
|
|
7
9
|
* @see docs/framework/04-bootstrap-lifecycle.md
|
|
8
10
|
*/
|
|
@@ -38,30 +40,39 @@ function extractName(filePath, suffix = '') {
|
|
|
38
40
|
export default class AutoLoader {
|
|
39
41
|
/**
|
|
40
42
|
* @param {import('./Application.js').default} app
|
|
41
|
-
* @param {string} baseDir - 프로젝트 루트
|
|
43
|
+
* @param {string} baseDir - 프로젝트 루트 (shared) 또는 앱 디렉토리 (app)
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @param {'shared'|'app'} [opts.mode='shared']
|
|
46
|
+
* @param {object} [opts.appContext] - 앱별 레지스트리 (mode='app' 시)
|
|
42
47
|
*/
|
|
43
|
-
constructor(app, baseDir) {
|
|
48
|
+
constructor(app, baseDir, opts = {}) {
|
|
44
49
|
this.app = app;
|
|
45
50
|
this.baseDir = baseDir;
|
|
51
|
+
this._mode = opts.mode || 'shared';
|
|
52
|
+
this._appContext = opts.appContext || null;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
/**
|
|
49
|
-
*
|
|
56
|
+
* 모드에 따른 스캔 + 등록 실행
|
|
50
57
|
*/
|
|
51
58
|
async load() {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
if (this._mode === 'shared') {
|
|
60
|
+
await this.loadModels('database/models');
|
|
61
|
+
await this.loadEvents('shared/events');
|
|
62
|
+
await this.loadJobs('shared/jobs');
|
|
63
|
+
// workers는 WorkerPool._resolve()에서 shared/workers/ 경로로 자동 탐색
|
|
64
|
+
} else if (this._mode === 'app') {
|
|
65
|
+
await this.loadControllers();
|
|
66
|
+
await this.loadRoutes();
|
|
67
|
+
await this.loadServices();
|
|
68
|
+
await this.loadMiddleware();
|
|
69
|
+
await this.loadWsHandlers();
|
|
70
|
+
}
|
|
60
71
|
}
|
|
61
72
|
|
|
62
|
-
/** models/*.js → ModelRegistry */
|
|
63
|
-
async loadModels() {
|
|
64
|
-
const files = await scanDir(path.join(this.baseDir,
|
|
73
|
+
/** database/models/*.js → ModelRegistry (공유) */
|
|
74
|
+
async loadModels(subDir = 'models') {
|
|
75
|
+
const files = await scanDir(path.join(this.baseDir, subDir));
|
|
65
76
|
for (const file of files) {
|
|
66
77
|
const mod = await import(file);
|
|
67
78
|
const ModelClass = mod.default;
|
|
@@ -74,7 +85,7 @@ export default class AutoLoader {
|
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
/**
|
|
77
|
-
* controllers/*.js →
|
|
88
|
+
* controllers/*.js → 앱별 컨트롤러 레지스트리 + __handler__ static 등록
|
|
78
89
|
*
|
|
79
90
|
* 문서 01-routing-controllers.md:
|
|
80
91
|
* 프로토타입 메서드를 static 레퍼런스로 자동 등록하여
|
|
@@ -83,9 +94,9 @@ export default class AutoLoader {
|
|
|
83
94
|
async loadControllers() {
|
|
84
95
|
const controllerDir = path.join(this.baseDir, 'controllers');
|
|
85
96
|
const files = await scanDir(controllerDir);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
const registry = this._appContext?.controllers || this.app._controllers;
|
|
98
|
+
if (!registry) return;
|
|
99
|
+
|
|
89
100
|
for (const file of files) {
|
|
90
101
|
const mod = await import(file);
|
|
91
102
|
const ControllerClass = mod.default;
|
|
@@ -96,7 +107,7 @@ export default class AutoLoader {
|
|
|
96
107
|
|
|
97
108
|
// 컨트롤러 이름 등록 (싱글톤 인스턴스는 boot 완료 후 생성)
|
|
98
109
|
const name = extractName(file, 'Controller');
|
|
99
|
-
|
|
110
|
+
registry.set(name, ControllerClass);
|
|
100
111
|
}
|
|
101
112
|
}
|
|
102
113
|
|
|
@@ -123,7 +134,7 @@ export default class AutoLoader {
|
|
|
123
134
|
}
|
|
124
135
|
}
|
|
125
136
|
|
|
126
|
-
/** services/*.js → DI register */
|
|
137
|
+
/** services/*.js → DI register (앱별 네임스페이스) */
|
|
127
138
|
async loadServices() {
|
|
128
139
|
const files = await scanDir(path.join(this.baseDir, 'services'));
|
|
129
140
|
for (const file of files) {
|
|
@@ -135,24 +146,24 @@ export default class AutoLoader {
|
|
|
135
146
|
}
|
|
136
147
|
}
|
|
137
148
|
|
|
138
|
-
/** middleware/*.js →
|
|
149
|
+
/** middleware/*.js → 앱별 미들웨어 맵 */
|
|
139
150
|
async loadMiddleware() {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
151
|
+
const registry = this._appContext?.middlewareRegistry || this.app._middlewareRegistry;
|
|
152
|
+
if (!registry) return;
|
|
153
|
+
|
|
143
154
|
const files = await scanDir(path.join(this.baseDir, 'middleware'));
|
|
144
155
|
for (const file of files) {
|
|
145
156
|
const mod = await import(file);
|
|
146
157
|
const MwClass = mod.default;
|
|
147
158
|
if (!MwClass) continue;
|
|
148
159
|
const mwName = MwClass.alias || extractName(file, 'Middleware').toLowerCase();
|
|
149
|
-
|
|
160
|
+
registry.set(mwName, MwClass);
|
|
150
161
|
}
|
|
151
162
|
}
|
|
152
163
|
|
|
153
|
-
/** events/*.js → EventBus.on() */
|
|
154
|
-
async loadEvents() {
|
|
155
|
-
const files = await scanDir(path.join(this.baseDir,
|
|
164
|
+
/** shared/events/*.js → EventBus.on() (공유) */
|
|
165
|
+
async loadEvents(subDir = 'events') {
|
|
166
|
+
const files = await scanDir(path.join(this.baseDir, subDir));
|
|
156
167
|
for (const file of files) {
|
|
157
168
|
const mod = await import(file);
|
|
158
169
|
// events/*.js 는 export default (app) => { app.on('...', handler) }
|
|
@@ -162,9 +173,9 @@ export default class AutoLoader {
|
|
|
162
173
|
}
|
|
163
174
|
}
|
|
164
175
|
|
|
165
|
-
/** jobs/*.js → Scheduler + Queue */
|
|
166
|
-
async loadJobs() {
|
|
167
|
-
const files = await scanDir(path.join(this.baseDir,
|
|
176
|
+
/** shared/jobs/*.js → Scheduler + Queue (공유) */
|
|
177
|
+
async loadJobs(subDir = 'jobs') {
|
|
178
|
+
const files = await scanDir(path.join(this.baseDir, subDir));
|
|
168
179
|
for (const file of files) {
|
|
169
180
|
const mod = await import(file);
|
|
170
181
|
const JobClass = mod.default;
|
|
@@ -182,29 +193,31 @@ export default class AutoLoader {
|
|
|
182
193
|
}
|
|
183
194
|
}
|
|
184
195
|
|
|
185
|
-
/** ws/*.js → WsHandler 등록 */
|
|
196
|
+
/** ws/*.js → 앱별 WsHandler 등록 */
|
|
186
197
|
async loadWsHandlers() {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
198
|
+
const registry = this._appContext?.wsHandlers || this.app._wsHandlers;
|
|
199
|
+
if (!registry) return;
|
|
200
|
+
|
|
190
201
|
const files = await scanDir(path.join(this.baseDir, 'ws'));
|
|
191
202
|
for (const file of files) {
|
|
192
203
|
const mod = await import(file);
|
|
193
204
|
const HandlerClass = mod.default;
|
|
194
205
|
if (!HandlerClass) continue;
|
|
195
206
|
const ns = HandlerClass.namespace || '/';
|
|
196
|
-
|
|
207
|
+
registry.set(ns, HandlerClass);
|
|
197
208
|
}
|
|
198
209
|
}
|
|
199
210
|
|
|
200
|
-
/** routes/*.js → Router.load() */
|
|
211
|
+
/** routes/*.js → 앱별 Router.load() */
|
|
201
212
|
async loadRoutes() {
|
|
213
|
+
const router = this._appContext?.router || this.app._router;
|
|
214
|
+
if (!router) return;
|
|
215
|
+
|
|
202
216
|
const files = await scanDir(path.join(this.baseDir, 'routes'));
|
|
203
|
-
if (!this.app._router) return;
|
|
204
217
|
for (const file of files) {
|
|
205
218
|
const mod = await import(file);
|
|
206
219
|
if (typeof mod.default === 'function') {
|
|
207
|
-
|
|
220
|
+
router.load(mod.default);
|
|
208
221
|
}
|
|
209
222
|
}
|
|
210
223
|
}
|
package/lib/core/Config.js
CHANGED
|
@@ -77,10 +77,12 @@ export default class Config {
|
|
|
77
77
|
if (!line.trim()) continue;
|
|
78
78
|
|
|
79
79
|
const indent = line.search(/\S/);
|
|
80
|
-
|
|
80
|
+
// 키: unquoted ([-\w.]+) 또는 quoted ("..." / '...')
|
|
81
|
+
const match = line.match(/^(\s*)(?:(["'])([^"']+)\2|([-\w.]+))\s*:\s*(.*)$/);
|
|
81
82
|
if (!match) continue;
|
|
82
83
|
|
|
83
|
-
const [
|
|
84
|
+
const key = match[3] || match[4]; // quoted key (그룹3) 또는 unquoted key (그룹4)
|
|
85
|
+
const rawValue = match[5];
|
|
84
86
|
|
|
85
87
|
// 스택에서 현재 indent보다 깊거나 같은 레벨 제거
|
|
86
88
|
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
package/lib/core/Context.js
CHANGED
|
@@ -318,12 +318,14 @@ export default class Context {
|
|
|
318
318
|
...data,
|
|
319
319
|
};
|
|
320
320
|
|
|
321
|
-
// Bridge Tera SSR —
|
|
322
|
-
|
|
323
|
-
|
|
321
|
+
// Bridge Tera SSR — 앱별 View 인스턴스 사용
|
|
322
|
+
const appEntry = this.app?._appRegistry?.get(this.appName);
|
|
323
|
+
const viewEngine = appEntry?.view;
|
|
324
|
+
if (!viewEngine) {
|
|
325
|
+
throw new Error(`View not initialized for app '${this.appName}' — Bridge not available`);
|
|
324
326
|
}
|
|
325
327
|
|
|
326
|
-
const html =
|
|
328
|
+
const html = viewEngine.render(view, globals);
|
|
327
329
|
return this.html(html);
|
|
328
330
|
}
|
|
329
331
|
|
package/lib/http/ErrorHandler.js
CHANGED
|
@@ -93,7 +93,8 @@ export default class ErrorHandler {
|
|
|
93
93
|
};
|
|
94
94
|
|
|
95
95
|
// 테마 에러 페이지 시도 (View 렌더러가 있을 때)
|
|
96
|
-
const
|
|
96
|
+
const appEntry = ctx.app?._appRegistry?.get(ctx.appName);
|
|
97
|
+
const view = this._view || appEntry?.view;
|
|
97
98
|
if (view) {
|
|
98
99
|
const theme = ctx.theme || ctx.app?.config?.get('themes.default', 'default') || 'default';
|
|
99
100
|
// 1. views/{theme}/errors/{code}.html
|
package/lib/middleware/index.js
CHANGED
|
@@ -270,15 +270,11 @@ export function session(opts = {}) {
|
|
|
270
270
|
*/
|
|
271
271
|
export function theme(opts = {}) {
|
|
272
272
|
const defaultTheme = opts.default || 'default';
|
|
273
|
-
const mapping = opts.mapping || {};
|
|
274
273
|
|
|
275
274
|
return async (ctx, next) => {
|
|
276
|
-
//
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const host = ctx.host?.split(':')[0] || ''; // 포트 제거
|
|
281
|
-
ctx.theme = configMapping[host] || configDefault;
|
|
275
|
+
// 앱별 테마 결정 — 도메인→앱 라우팅은 Application._resolveApp()에서 처리
|
|
276
|
+
const configDefault = ctx.app?.config?.get('app.themes.default') || defaultTheme;
|
|
277
|
+
ctx.theme = configDefault;
|
|
282
278
|
|
|
283
279
|
await next();
|
|
284
280
|
};
|
|
@@ -212,14 +212,14 @@ export default class WorkerPool {
|
|
|
212
212
|
return path.resolve(baseDir, name);
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
// 3) 단축 이름 → workers/ 폴더 탐색
|
|
216
|
-
const workersDir = path.resolve(baseDir, 'workers');
|
|
215
|
+
// 3) 단축 이름 → shared/workers/ 폴더 탐색
|
|
216
|
+
const workersDir = path.resolve(baseDir, 'shared/workers');
|
|
217
217
|
for (const ext of ['.js', '.mjs']) {
|
|
218
218
|
const candidate = path.join(workersDir, name + ext);
|
|
219
219
|
if (fs.existsSync(candidate)) return candidate;
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
// 기본값: workers/{name}.js (존재하지 않아도 Worker 생성 시 에러)
|
|
222
|
+
// 기본값: shared/workers/{name}.js (존재하지 않아도 Worker 생성 시 에러)
|
|
223
223
|
return path.join(workersDir, name + '.js');
|
|
224
224
|
}
|
|
225
225
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzionx/framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Full-stack MVC framework built on @fuzionx/core — Controller, Service, Model, Middleware, DI, EventBus",
|
|
6
6
|
"main": "index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"url": "https://github.com/saytohenry/fuzionx"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@fuzionx/core": "^0.1.
|
|
37
|
+
"@fuzionx/core": "^0.1.24",
|
|
38
38
|
"better-sqlite3": "^12.8.0",
|
|
39
39
|
"knex": "^3.2.5",
|
|
40
40
|
"mongoose": "^9.3.2",
|