@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 CHANGED
@@ -51,16 +51,21 @@ async function loadTemplate(relativePath, vars = {}) {
51
51
  // fx make:* — 코드 생성
52
52
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
53
53
 
54
- const TYPE_DIRS = {
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
- event: 'events',
63
- worker: 'workers',
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
- const dir = TYPE_DIRS[type];
89
- if (!dir) throw new Error(`Unknown type: ${type}`);
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
- /** 스캐폴딩 디렉토리 (16-cli.md) */
137
+ /** 스캐폴딩 디렉토리 (multi-app 구조) */
125
138
  const APP_DIRS = [
126
- 'controllers',
127
- 'models',
128
- 'services',
129
- 'middleware',
130
- 'ws',
131
- 'jobs',
132
- 'events',
133
- 'workers',
134
- 'migrations',
135
- 'seeds',
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
- await fs.writeFile(path.join(dir, d, '.gitkeep'), '');
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 복사 (16-cli.md)
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}/ 구조 (03, 16-cli.md)
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
- const layoutDir = path.join(viewsDst, 'layouts');
181
- await fs.mkdir(layoutDir, { recursive: true });
182
- await fs.copyFile(path.join(viewsSrc, 'layouts/main.html'), path.join(layoutDir, 'main.html'));
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
- const pagesDir = path.join(viewsDst, 'pages');
186
- await fs.mkdir(pagesDir, { recursive: true });
187
- await fs.copyFile(path.join(viewsSrc, 'pages/home.html'), path.join(pagesDir, 'home.html'));
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
- const errDir = path.join(viewsDst, 'errors');
191
- await fs.mkdir(errDir, { recursive: true });
192
- await fs.copyFile(path.join(viewsSrc, 'errors/404.html'), path.join(errDir, '404.html'));
193
- await fs.copyFile(path.join(viewsSrc, 'errors/500.html'), path.join(errDir, '500.html'));
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>`); process.exit(1); }
224
- const file = await makeFile(type, name);
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?._router) { console.error('Cannot load app routes'); return; }
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 r of routes) {
262
- const method = r.method.padEnd(8);
263
- const rPath = r.path.padEnd(22);
264
- const handler = r.handler?.__handler__
265
- ? `${r.handler.controller?.name || ''}.${r.handler.method}`
266
- : (typeof r.handler === 'function' ? r.handler.name || 'anonymous' : '');
267
- const mw = (r.middleware || []).join(', ');
268
- console.log(` ${method} ${rPath} ${handler.padEnd(26)} ${mw}`);
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> Create new app
313
- fx make:controller <Name> Create controller
314
- fx make:service <Name> Create service
315
- fx make:model <Name> Create model
316
- fx make:middleware <Name> Create middleware
317
- fx make:job <Name> Create job
318
- fx make:task <Name> Create task
319
- fx make:ws <Name> Create WsHandler
320
- fx make:event <Name> Create event handler
321
- fx make:worker <Name> Create worker (worker_threads)
322
- fx make:test <Name> Create test
323
- fx dev Start dev server (--watch)
324
- fx test Run tests
325
- fx routes Print route table
326
- fx config Print fuzionx.yaml
327
- fx db:sync Sync models → DB (--apply)
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();
@@ -8,6 +8,6 @@ export default class HomeController extends Controller {
8
8
 
9
9
  /** 홈 페이지 */
10
10
  async index(ctx) {
11
- ctx.render('pages/home');
11
+ ctx.render('home');
12
12
  }
13
13
  }
@@ -1,13 +1,38 @@
1
1
  # FuzionX Configuration
2
2
  bridge:
3
3
  port: 49080
4
- workers: 4
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
- default: 'default'
59
+ themes:
60
+ default: 'default'
61
+
62
+ # Multi-App (도메인 → 앱 라우팅)
63
+ apps:
64
+ "localhost": backend
65
+ # "admin.example.com": backend
66
+ # "example.com": frontend
@@ -1,5 +1,5 @@
1
+ import HomeController from '../controllers/HomeController.js';
2
+
1
3
  export default (r) => {
2
- r.get('/', (ctx) => {
3
- ctx.render('home');
4
- });
4
+ r.get('/', HomeController.index);
5
5
  };
@@ -1,7 +1,7 @@
1
1
  import { Middleware } from '@fuzionx/framework';
2
2
 
3
3
  export default class {{Name}}Middleware extends Middleware {
4
- static name = '{{nameLower}}';
4
+ static alias = '{{nameLower}}';
5
5
 
6
6
  async handle(ctx, next) {
7
7
  // TODO: implement
@@ -77,12 +77,11 @@ export default class Application {
77
77
  this._eventHandlers = new Map();
78
78
  this._errorHandlers = [];
79
79
 
80
- // 라우터
81
- this._router = new Router();
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
- get router() { return this._router; }
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
- * @see docs/framework/01-routing-controllers.md
163
+ * @param {string} [appName]
158
164
  */
159
- routes(routeCallback) {
160
- this._router.load(routeCallback);
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
- resolveMiddleware(name) {
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
- if (this._mwFnCache?.has(name)) return this._mwFnCache.get(name);
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
- const MwClass = this._middlewareRegistry.get(mwName);
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(name, fn);
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
- // 6. i18n 로드 (04-bootstrap-lifecycle.md)
286
+ // 2. i18n 로드 (04-bootstrap-lifecycle.md)
268
287
  await this.i18n.load();
269
288
 
270
- // View 초기화
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
- // 자동 스캔 (models, services, middleware, events, jobs, ws, routes)
287
- const loader = new AutoLoader(this, this.baseDir);
288
- await loader.load();
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 라우트 등록 (21-openapi.md)
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
- if (this._view) this._view._bridge = this._bridge;
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
- const routes = this._router.getRoutes();
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(routes);
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
- this._router.get(`${docsPath}/openapi.json`, (ctx) => {
528
+ defaultApp.router.get(`${docsPath}/openapi.json`, (ctx) => {
442
529
  ctx.json(this._openapi.toJSON());
443
530
  });
444
531
 
445
532
  // YAML spec
446
- this._router.get(`${docsPath}/openapi.yaml`, (ctx) => {
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
- this._router.get(docsPath, (ctx) => {
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
- this._controllerCache = new Map();
500
-
501
- for (const route of this._router.getRoutes()) {
502
- const handler = route.handler;
503
- if (handler?.__handler__ && handler.controller) {
504
- const CtrlClass = handler.controller;
505
- if (!this._controllerCache.has(CtrlClass)) {
506
- this._controllerCache.set(CtrlClass, new CtrlClass(this));
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 FuzionXApp 라우트 변환
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
- for (const route of routes) {
521
- const bridgeHandler = this._createBridgeHandler(route);
522
- const method = route.method.toLowerCase();
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
- if (typeof coreApp[method] === 'function') {
525
- coreApp[method](route.path, bridgeHandler);
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 [namespace, HandlerClass] of this._wsHandlers) {
539
- const eventMap = HandlerClass.buildEventMap();
540
- const wsNs = coreApp.ws(namespace);
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
- const inst = new HandlerClass(this);
685
+ wsNs.on('disconnect', (socket) => {
686
+ console.log(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
687
+ inst.onDisconnect(socket);
688
+ });
544
689
 
545
- wsNs.on('connect', (socket) => {
546
- console.log(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
547
- inst.onConnect(socket);
548
- });
690
+ console.log(`[WS] 핸들러 등록: ${namespace} (events: ${[...eventMap.keys()].join(', ')})`);
691
+ }
692
+ }
693
+ }
549
694
 
550
- wsNs.on('message', (socket, rawMessage) => {
551
- let parsed;
552
- try { parsed = typeof rawMessage === 'string' ? JSON.parse(rawMessage) : rawMessage; }
553
- catch { parsed = { type: 'message', data: rawMessage }; }
554
- const eventType = parsed.type || 'message';
555
- const eventData = parsed.data || parsed;
556
- console.log(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
557
- const entry = eventMap.get(eventType);
558
- if (entry) {
559
- const result = entry.handler.call(inst, socket, eventData);
560
- if (result && typeof result.then === 'function') {
561
- result.then(r => { if (r) socket.send(JSON.stringify(r)); })
562
- .catch(e => console.error(`[WS] error: ${eventType}`, e));
563
- } else if (result) {
564
- socket.send(JSON.stringify(result));
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
- } else {
567
- inst.onEvent(socket, eventType, eventData);
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
- wsNs.on('disconnect', (socket) => {
572
- console.log(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
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 instance = this._controllerCache.get(handler.controller);
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;
@@ -2,7 +2,9 @@
2
2
  * AutoLoader — 앱 디렉토리 자동 스캔 + 등록
3
3
  *
4
4
  * Application.boot() 시 호출.
5
- * models/, services/, middleware/ 등 자동 로드.
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
- await this.loadModels();
53
- await this.loadControllers();
54
- await this.loadServices();
55
- await this.loadMiddleware();
56
- await this.loadEvents();
57
- await this.loadJobs();
58
- await this.loadWsHandlers();
59
- await this.loadRoutes();
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, 'models'));
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 → 싱글톤 인스턴스 + __handler__ static 등록
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
- if (!this.app._controllers) {
87
- this.app._controllers = new Map();
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
- this.app._controllers.set(name, ControllerClass);
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
- if (!this.app._middlewareRegistry) {
141
- this.app._middlewareRegistry = new Map();
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
- this.app._middlewareRegistry.set(mwName, MwClass);
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, 'events'));
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, 'jobs'));
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
- if (!this.app._wsHandlers) {
188
- this.app._wsHandlers = new Map();
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
- this.app._wsHandlers.set(ns, HandlerClass);
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
- this.app._router.load(mod.default);
220
+ router.load(mod.default);
208
221
  }
209
222
  }
210
223
  }
@@ -77,10 +77,12 @@ export default class Config {
77
77
  if (!line.trim()) continue;
78
78
 
79
79
  const indent = line.search(/\S/);
80
- const match = line.match(/^(\s*)([-\w.]+)\s*:\s*(.*)$/);
80
+ // 키: unquoted ([-\w.]+) 또는 quoted ("..." / '...')
81
+ const match = line.match(/^(\s*)(?:(["'])([^"']+)\2|([-\w.]+))\s*:\s*(.*)$/);
81
82
  if (!match) continue;
82
83
 
83
- const [, , key, rawValue] = match;
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) {
@@ -318,12 +318,14 @@ export default class Context {
318
318
  ...data,
319
319
  };
320
320
 
321
- // Bridge Tera SSR — 폴백 없음
322
- if (!this.app?._view) {
323
- throw new Error('View not initialized — Bridge not available');
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 = this.app._view.render(view, globals);
328
+ const html = viewEngine.render(view, globals);
327
329
  return this.html(html);
328
330
  }
329
331
 
@@ -93,7 +93,8 @@ export default class ErrorHandler {
93
93
  };
94
94
 
95
95
  // 테마 에러 페이지 시도 (View 렌더러가 있을 때)
96
- const view = this._view || ctx.app?._view;
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
@@ -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
- // YAML 설정에서 매핑 읽기
277
- const configMapping = ctx.app?.config?.get('themes.mapping') || mapping;
278
- const configDefault = ctx.app?.config?.get('themes.default') || defaultTheme;
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.22",
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.22",
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",