@fuzionx/framework 0.1.8 → 0.1.20
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/db-sync.js +99 -0
- package/cli/fx.js +3 -0
- package/cli/index.js +329 -0
- package/cli/templates/app/.env.example.tpl +14 -0
- package/cli/templates/app/.gitignore.tpl +4 -0
- package/cli/templates/app/app.js.tpl +14 -0
- package/cli/templates/app/controllers/HomeController.js +13 -0
- package/cli/templates/app/fuzionx.yaml.tpl +32 -0
- package/cli/templates/app/package.json.tpl +15 -0
- package/cli/templates/app/routes/api.js.tpl +7 -0
- package/cli/templates/app/routes/web.js.tpl +5 -0
- package/cli/templates/app/views/default/errors/404.html +15 -0
- package/cli/templates/app/views/default/errors/500.html +14 -0
- package/cli/templates/app/views/default/layouts/main.html +22 -0
- package/cli/templates/app/views/default/pages/home.html +188 -0
- package/cli/templates/make/controller.js.tpl +40 -0
- package/cli/templates/make/event.js.tpl +8 -0
- package/cli/templates/make/job.js.tpl +10 -0
- package/cli/templates/make/middleware.js.tpl +10 -0
- package/cli/templates/make/model.js.tpl +15 -0
- package/cli/templates/make/service.js.tpl +15 -0
- package/cli/templates/make/task.js.tpl +15 -0
- package/cli/templates/make/test.js.tpl +7 -0
- package/cli/templates/make/worker.js.tpl +14 -0
- package/cli/templates/make/ws.js.tpl +18 -0
- package/lib/core/Application.js +164 -5
- package/lib/core/AutoLoader.js +46 -0
- package/lib/core/Config.js +103 -0
- package/lib/core/Context.js +5 -11
- package/lib/view/View.js +31 -20
- package/package.json +4 -2
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="ko">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>FuzionX</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap');
|
|
10
|
+
|
|
11
|
+
body {
|
|
12
|
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
background: linear-gradient(135deg, #0f0c29 0%, #1a1a3e 40%, #24243e 100%);
|
|
18
|
+
color: #e0e0e0;
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.container {
|
|
23
|
+
text-align: center;
|
|
24
|
+
z-index: 1;
|
|
25
|
+
animation: fadeInUp 0.8s ease-out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.logo {
|
|
29
|
+
font-size: 4rem;
|
|
30
|
+
font-weight: 800;
|
|
31
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
|
32
|
+
-webkit-background-clip: text;
|
|
33
|
+
background-clip: text;
|
|
34
|
+
-webkit-text-fill-color: transparent;
|
|
35
|
+
letter-spacing: -2px;
|
|
36
|
+
margin-bottom: 0.5rem;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.subtitle {
|
|
40
|
+
font-size: 1.1rem;
|
|
41
|
+
font-weight: 300;
|
|
42
|
+
color: rgba(255, 255, 255, 0.5);
|
|
43
|
+
margin-bottom: 2.5rem;
|
|
44
|
+
letter-spacing: 2px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.card {
|
|
48
|
+
background: rgba(255, 255, 255, 0.05);
|
|
49
|
+
backdrop-filter: blur(20px);
|
|
50
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
51
|
+
border-radius: 16px;
|
|
52
|
+
padding: 2rem 3rem;
|
|
53
|
+
max-width: 480px;
|
|
54
|
+
margin: 0 auto 2rem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.version {
|
|
58
|
+
display: inline-block;
|
|
59
|
+
background: linear-gradient(135deg, #667eea, #764ba2);
|
|
60
|
+
color: white;
|
|
61
|
+
padding: 4px 14px;
|
|
62
|
+
border-radius: 20px;
|
|
63
|
+
font-size: 0.75rem;
|
|
64
|
+
font-weight: 600;
|
|
65
|
+
letter-spacing: 1px;
|
|
66
|
+
margin-bottom: 1.5rem;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.features {
|
|
70
|
+
display: grid;
|
|
71
|
+
grid-template-columns: 1fr 1fr;
|
|
72
|
+
gap: 1rem;
|
|
73
|
+
text-align: left;
|
|
74
|
+
margin-top: 1.5rem;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.feature {
|
|
78
|
+
display: flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
gap: 8px;
|
|
81
|
+
font-size: 0.85rem;
|
|
82
|
+
color: rgba(255, 255, 255, 0.7);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.feature span {
|
|
86
|
+
font-size: 1.1rem;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.links {
|
|
90
|
+
display: flex;
|
|
91
|
+
gap: 1rem;
|
|
92
|
+
justify-content: center;
|
|
93
|
+
margin-top: 1rem;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.links a {
|
|
97
|
+
color: rgba(255, 255, 255, 0.6);
|
|
98
|
+
text-decoration: none;
|
|
99
|
+
font-size: 0.85rem;
|
|
100
|
+
padding: 8px 20px;
|
|
101
|
+
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
102
|
+
border-radius: 8px;
|
|
103
|
+
transition: all 0.3s ease;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.links a:hover {
|
|
107
|
+
color: #fff;
|
|
108
|
+
border-color: #667eea;
|
|
109
|
+
background: rgba(102, 126, 234, 0.1);
|
|
110
|
+
transform: translateY(-2px);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.hint {
|
|
114
|
+
margin-top: 2rem;
|
|
115
|
+
font-size: 0.75rem;
|
|
116
|
+
color: rgba(255, 255, 255, 0.3);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.hint code {
|
|
120
|
+
background: rgba(255, 255, 255, 0.08);
|
|
121
|
+
padding: 2px 8px;
|
|
122
|
+
border-radius: 4px;
|
|
123
|
+
font-family: 'JetBrains Mono', monospace;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* Background orbs */
|
|
127
|
+
.orb {
|
|
128
|
+
position: fixed;
|
|
129
|
+
border-radius: 50%;
|
|
130
|
+
filter: blur(80px);
|
|
131
|
+
opacity: 0.3;
|
|
132
|
+
animation: float 8s ease-in-out infinite;
|
|
133
|
+
}
|
|
134
|
+
.orb-1 { width: 400px; height: 400px; background: #667eea; top: -100px; right: -100px; }
|
|
135
|
+
.orb-2 { width: 300px; height: 300px; background: #764ba2; bottom: -80px; left: -80px; animation-delay: -4s; }
|
|
136
|
+
.orb-3 { width: 200px; height: 200px; background: #f093fb; top: 50%; left: 60%; animation-delay: -2s; }
|
|
137
|
+
|
|
138
|
+
@keyframes fadeInUp {
|
|
139
|
+
from { opacity: 0; transform: translateY(30px); }
|
|
140
|
+
to { opacity: 1; transform: translateY(0); }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@keyframes float {
|
|
144
|
+
0%, 100% { transform: translate(0, 0); }
|
|
145
|
+
50% { transform: translate(30px, -30px); }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@media (max-width: 600px) {
|
|
149
|
+
.logo { font-size: 2.5rem; }
|
|
150
|
+
.card { padding: 1.5rem; margin: 0 1rem; }
|
|
151
|
+
.features { grid-template-columns: 1fr; }
|
|
152
|
+
}
|
|
153
|
+
</style>
|
|
154
|
+
</head>
|
|
155
|
+
<body>
|
|
156
|
+
<div class="orb orb-1"></div>
|
|
157
|
+
<div class="orb orb-2"></div>
|
|
158
|
+
<div class="orb orb-3"></div>
|
|
159
|
+
|
|
160
|
+
<div class="container">
|
|
161
|
+
<h1 class="logo">FuzionX</h1>
|
|
162
|
+
<p class="subtitle">HIGH-PERFORMANCE NODE.JS FRAMEWORK</p>
|
|
163
|
+
|
|
164
|
+
<div class="card">
|
|
165
|
+
<div class="version">v0.1.0 · POWERED BY RUST</div>
|
|
166
|
+
|
|
167
|
+
<div class="features">
|
|
168
|
+
<div class="feature"><span>⚡</span> 500K+ RPS</div>
|
|
169
|
+
<div class="feature"><span>🦀</span> Rust N-API Bridge</div>
|
|
170
|
+
<div class="feature"><span>🎯</span> MVC Architecture</div>
|
|
171
|
+
<div class="feature"><span>🔌</span> WebSocket</div>
|
|
172
|
+
<div class="feature"><span>🗄️</span> Multi-DB ORM</div>
|
|
173
|
+
<div class="feature"><span>🔐</span> Auth & Session</div>
|
|
174
|
+
<div class="feature"><span>📡</span> Event System</div>
|
|
175
|
+
<div class="feature"><span>⏰</span> Job Scheduler</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div class="links">
|
|
180
|
+
<a href="https://github.com/saytohenry/fuzionx">GitHub</a>
|
|
181
|
+
<a href="/docs">API Docs</a>
|
|
182
|
+
<a href="/api/health">Health Check</a>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<p class="hint">Edit <code>routes/web.js</code> to get started</p>
|
|
186
|
+
</div>
|
|
187
|
+
</body>
|
|
188
|
+
</html>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Controller } from '@fuzionx/framework';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* {{Name}} 컨트롤러.
|
|
5
|
+
* routes/ 파일에서 라우트를 등록하세요.
|
|
6
|
+
*/
|
|
7
|
+
export default class {{Name}}Controller extends Controller {
|
|
8
|
+
|
|
9
|
+
/** 목록 조회 */
|
|
10
|
+
async index(ctx) {
|
|
11
|
+
const items = await this.db.{{Name}}.paginate(ctx.query.page || 1, 20);
|
|
12
|
+
ctx.json(items);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 상세 조회 */
|
|
16
|
+
async show(ctx) {
|
|
17
|
+
const item = await this.db.{{Name}}.findOrFail(ctx.params.id);
|
|
18
|
+
ctx.json(item);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 생성 */
|
|
22
|
+
async store(ctx) {
|
|
23
|
+
const item = await this.db.{{Name}}.create(ctx.body);
|
|
24
|
+
ctx.status(201).json(item);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 수정 */
|
|
28
|
+
async update(ctx) {
|
|
29
|
+
const item = await this.db.{{Name}}.findOrFail(ctx.params.id);
|
|
30
|
+
await item.update(ctx.body);
|
|
31
|
+
ctx.json(item);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 삭제 */
|
|
35
|
+
async destroy(ctx) {
|
|
36
|
+
const item = await this.db.{{Name}}.findOrFail(ctx.params.id);
|
|
37
|
+
await item.delete();
|
|
38
|
+
ctx.status(204).end();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { SQLiteModel } from '@fuzionx/framework';
|
|
2
|
+
|
|
3
|
+
export default class {{Name}} extends SQLiteModel {
|
|
4
|
+
static table = '{{tableName}}';
|
|
5
|
+
static timestamps = true;
|
|
6
|
+
static hidden = [];
|
|
7
|
+
|
|
8
|
+
static columns = {
|
|
9
|
+
id: { type: 'increments' },
|
|
10
|
+
name: { type: 'string', length: 100 },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// 관계 정의
|
|
14
|
+
// posts() { return this.hasMany('Post'); }
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Service } from '@fuzionx/framework';
|
|
2
|
+
|
|
3
|
+
export default class {{Name}}Service extends Service {
|
|
4
|
+
async findAll() {
|
|
5
|
+
return this.db.{{Name}}.query().paginate();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async findById(id) {
|
|
9
|
+
return this.db.{{Name}}.findOrFail(id);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async create(data) {
|
|
13
|
+
return this.db.{{Name}}.create(data);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Task } from '@fuzionx/framework';
|
|
2
|
+
|
|
3
|
+
export default class {{Name}} extends Task {
|
|
4
|
+
static queue = 'default';
|
|
5
|
+
static retries = 3;
|
|
6
|
+
static retryDelay = 5000;
|
|
7
|
+
|
|
8
|
+
async handle(data) {
|
|
9
|
+
// 비동기 작업 로직
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async failed(data, error) {
|
|
13
|
+
this.logger.error('작업 최종 실패', { error: error.message });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{Name}} Worker — CPU-heavy 작업 처리
|
|
3
|
+
*
|
|
4
|
+
* 사용: this.worker.run('{{nameLower}}', data)
|
|
5
|
+
*/
|
|
6
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
7
|
+
|
|
8
|
+
async function handle(data) {
|
|
9
|
+
// TODO: CPU-heavy 로직 구현
|
|
10
|
+
return data;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const result = await handle(workerData);
|
|
14
|
+
parentPort.postMessage(result);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { WsHandler } from '@fuzionx/framework';
|
|
2
|
+
|
|
3
|
+
export default class {{Name}}Handler extends WsHandler {
|
|
4
|
+
static namespace = '/{{nameLower}}';
|
|
5
|
+
static middleware = [];
|
|
6
|
+
|
|
7
|
+
static events(e) {
|
|
8
|
+
// e.on('message', this.handleMessage);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async onConnect(socket) {
|
|
12
|
+
this.logger.info('{{Name}} connected:', socket.sessionId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async onDisconnect(socket, code, reason) {
|
|
16
|
+
this.logger.info('{{Name}} disconnected:', socket.sessionId);
|
|
17
|
+
}
|
|
18
|
+
}
|
package/lib/core/Application.js
CHANGED
|
@@ -21,6 +21,7 @@ import HashHelper from '../helpers/HashHelper.js';
|
|
|
21
21
|
import MediaHelper from '../helpers/MediaHelper.js';
|
|
22
22
|
import FileHelper from '../helpers/FileHelper.js';
|
|
23
23
|
import View from '../view/View.js';
|
|
24
|
+
import OpenAPI from '../view/OpenAPI.js';
|
|
24
25
|
import Scheduler from '../schedule/Scheduler.js';
|
|
25
26
|
import Queue from '../schedule/Queue.js';
|
|
26
27
|
import Storage from '../services/Storage.js';
|
|
@@ -59,6 +60,11 @@ export default class Application {
|
|
|
59
60
|
this.baseDir = path.resolve(opts.baseDir || process.cwd());
|
|
60
61
|
this.configPath = opts.configPath || null;
|
|
61
62
|
|
|
63
|
+
// configPath 지정 시 YAML 파일에서 database/app/themes 등 로드
|
|
64
|
+
if (this.configPath) {
|
|
65
|
+
this.config.loadYaml(path.resolve(this.baseDir, this.configPath));
|
|
66
|
+
}
|
|
67
|
+
|
|
62
68
|
// Bridge 참조 (listen() 시 FuzionXApp 생성)
|
|
63
69
|
this._bridge = null;
|
|
64
70
|
this._coreApp = null;
|
|
@@ -271,12 +277,32 @@ export default class Application {
|
|
|
271
277
|
|
|
272
278
|
// DB 연결 매니저 초기화 (database 섹션이 있을 때만)
|
|
273
279
|
const dbConfig = this.config.get('database');
|
|
274
|
-
if (dbConfig
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
280
|
+
if (dbConfig) {
|
|
281
|
+
// connections 키가 없는 레거시 형식 호환:
|
|
282
|
+
// database.main: { driver: 'sqlite', ... }
|
|
283
|
+
// → database.connections.main: { driver: 'sqlite', ... }
|
|
284
|
+
if (!dbConfig.connections) {
|
|
285
|
+
const connections = {};
|
|
286
|
+
for (const [key, val] of Object.entries(dbConfig)) {
|
|
287
|
+
if (key !== 'default' && typeof val === 'object' && val !== null) {
|
|
288
|
+
connections[key] = val;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (Object.keys(connections).length > 0) {
|
|
292
|
+
dbConfig.connections = connections;
|
|
293
|
+
if (!dbConfig.default) dbConfig.default = Object.keys(connections)[0];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (dbConfig.connections) {
|
|
297
|
+
this._connectionManager.configure(dbConfig);
|
|
298
|
+
SqlModel.setConnectionManager(this._connectionManager);
|
|
299
|
+
MongoModel.setConnectionManager(this._connectionManager);
|
|
300
|
+
}
|
|
278
301
|
}
|
|
279
302
|
|
|
303
|
+
// OpenAPI / Swagger UI 라우트 등록 (21-openapi.md)
|
|
304
|
+
this._registerDocsRoutes();
|
|
305
|
+
|
|
280
306
|
this._booted = true;
|
|
281
307
|
await this.emit('booted');
|
|
282
308
|
}
|
|
@@ -293,7 +319,7 @@ export default class Application {
|
|
|
293
319
|
*/
|
|
294
320
|
async listen(port, callback) {
|
|
295
321
|
if (typeof port === 'function') { callback = port; port = undefined; }
|
|
296
|
-
port = port
|
|
322
|
+
port = port ?? this.config.get('bridge.server.port') ?? this.config.get('bridge.port', 3000);
|
|
297
323
|
|
|
298
324
|
if (!this._booted) await this.boot();
|
|
299
325
|
|
|
@@ -322,8 +348,14 @@ export default class Application {
|
|
|
322
348
|
if (this.file) this.file._bridge = this._bridge;
|
|
323
349
|
if (this.i18n) this.i18n._bridge = this._bridge;
|
|
324
350
|
}
|
|
351
|
+
// WS proxy 연결 (WsHandler에서 this.app.ws 사용)
|
|
352
|
+
this.ws = this._coreApp.ws;
|
|
325
353
|
}
|
|
326
354
|
this._registerBridgeRoutes(this._coreApp);
|
|
355
|
+
|
|
356
|
+
// WsHandler → Bridge WS 이벤트 연결
|
|
357
|
+
this._registerWsHandlers(this._coreApp);
|
|
358
|
+
|
|
327
359
|
this._coreApp.listen(port, callback);
|
|
328
360
|
// Bridge가 자체 graceful shutdown 처리 → Framework 중복 등록 불필요
|
|
329
361
|
} else {
|
|
@@ -367,6 +399,83 @@ export default class Application {
|
|
|
367
399
|
});
|
|
368
400
|
}
|
|
369
401
|
|
|
402
|
+
/**
|
|
403
|
+
* OpenAPI / Swagger UI 라우트 등록 (21-openapi.md)
|
|
404
|
+
*
|
|
405
|
+
* app.docs.enabled = true 시 활성화.
|
|
406
|
+
* 라우트: GET /docs, GET /docs/openapi.json, GET /docs/openapi.yaml
|
|
407
|
+
* @private
|
|
408
|
+
*/
|
|
409
|
+
_registerDocsRoutes() {
|
|
410
|
+
const docsConfig = this.config.get('app.docs');
|
|
411
|
+
if (!docsConfig || docsConfig.enabled === false) return;
|
|
412
|
+
|
|
413
|
+
const docsPath = docsConfig.path || '/docs';
|
|
414
|
+
const routes = this._router.getRoutes();
|
|
415
|
+
|
|
416
|
+
// OpenAPI spec 빌드 (1회, 캐싱)
|
|
417
|
+
this._openapi = new OpenAPI({
|
|
418
|
+
title: docsConfig.title || this.config.get('app.name', 'FuzionX') + ' API',
|
|
419
|
+
version: docsConfig.version || '1.0.0',
|
|
420
|
+
description: docsConfig.description || '',
|
|
421
|
+
servers: docsConfig.servers || [],
|
|
422
|
+
});
|
|
423
|
+
this._openapi.build(routes);
|
|
424
|
+
|
|
425
|
+
// JSON spec
|
|
426
|
+
this._router.get(`${docsPath}/openapi.json`, (ctx) => {
|
|
427
|
+
ctx.json(this._openapi.toJSON());
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// YAML spec
|
|
431
|
+
this._router.get(`${docsPath}/openapi.yaml`, (ctx) => {
|
|
432
|
+
ctx.setHeader('Content-Type', 'text/yaml; charset=utf-8');
|
|
433
|
+
ctx.send(this._openapi.toYAML());
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Swagger UI HTML (CDN — 외부 의존 없음)
|
|
437
|
+
this._router.get(docsPath, (ctx) => {
|
|
438
|
+
const specUrl = `${docsPath}/openapi.json`;
|
|
439
|
+
const title = docsConfig.title || 'API Docs';
|
|
440
|
+
ctx.html(Application._swaggerHtml(title, specUrl));
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
this.logger.info(`[docs] Swagger UI → ${docsPath}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Swagger UI HTML 생성 (CDN 기반)
|
|
448
|
+
* @private
|
|
449
|
+
*/
|
|
450
|
+
static _swaggerHtml(title, specUrl) {
|
|
451
|
+
return `<!DOCTYPE html>
|
|
452
|
+
<html lang="ko">
|
|
453
|
+
<head>
|
|
454
|
+
<meta charset="UTF-8">
|
|
455
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
456
|
+
<title>${title}</title>
|
|
457
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
|
458
|
+
<style>
|
|
459
|
+
body { margin: 0; background: #fafafa; }
|
|
460
|
+
.topbar { display: none; }
|
|
461
|
+
</style>
|
|
462
|
+
</head>
|
|
463
|
+
<body>
|
|
464
|
+
<div id="swagger-ui"></div>
|
|
465
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
466
|
+
<script>
|
|
467
|
+
SwaggerUIBundle({
|
|
468
|
+
url: '${specUrl}',
|
|
469
|
+
dom_id: '#swagger-ui',
|
|
470
|
+
deepLinking: true,
|
|
471
|
+
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
|
472
|
+
layout: 'BaseLayout',
|
|
473
|
+
});
|
|
474
|
+
</script>
|
|
475
|
+
</body>
|
|
476
|
+
</html>`;
|
|
477
|
+
}
|
|
478
|
+
|
|
370
479
|
/**
|
|
371
480
|
* 싱글톤 컨트롤러 초기화
|
|
372
481
|
* @private
|
|
@@ -403,6 +512,56 @@ export default class Application {
|
|
|
403
512
|
}
|
|
404
513
|
}
|
|
405
514
|
|
|
515
|
+
/**
|
|
516
|
+
* WsHandler → Bridge WS 이벤트 연결
|
|
517
|
+
* @private
|
|
518
|
+
*/
|
|
519
|
+
_registerWsHandlers(coreApp) {
|
|
520
|
+
if (!this._wsHandlers || this._wsHandlers.size === 0) return;
|
|
521
|
+
if (!coreApp.ws) return;
|
|
522
|
+
|
|
523
|
+
for (const [namespace, HandlerClass] of this._wsHandlers) {
|
|
524
|
+
const eventMap = HandlerClass.buildEventMap();
|
|
525
|
+
const wsNs = coreApp.ws(namespace);
|
|
526
|
+
|
|
527
|
+
wsNs.on('connect', (socket) => {
|
|
528
|
+
const inst = new HandlerClass(this);
|
|
529
|
+
console.log(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
|
|
530
|
+
inst.onConnect(socket);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
wsNs.on('message', (socket, rawMessage) => {
|
|
534
|
+
const inst = new HandlerClass(this);
|
|
535
|
+
let parsed;
|
|
536
|
+
try { parsed = typeof rawMessage === 'string' ? JSON.parse(rawMessage) : rawMessage; }
|
|
537
|
+
catch { parsed = { type: 'message', data: rawMessage }; }
|
|
538
|
+
const eventType = parsed.type || 'message';
|
|
539
|
+
const eventData = parsed.data || parsed;
|
|
540
|
+
console.log(`[WS] msg: ${namespace} type=${eventType} sid=${socket.sessionId}`);
|
|
541
|
+
const entry = eventMap.get(eventType);
|
|
542
|
+
if (entry) {
|
|
543
|
+
const result = entry.handler.call(inst, socket, eventData);
|
|
544
|
+
if (result && typeof result.then === 'function') {
|
|
545
|
+
result.then(r => { if (r) socket.send(JSON.stringify(r)); })
|
|
546
|
+
.catch(e => console.error(`[WS] error: ${eventType}`, e));
|
|
547
|
+
} else if (result) {
|
|
548
|
+
socket.send(JSON.stringify(result));
|
|
549
|
+
}
|
|
550
|
+
} else {
|
|
551
|
+
inst.onEvent(socket, eventType, eventData);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
wsNs.on('disconnect', (socket) => {
|
|
556
|
+
const inst = new HandlerClass(this);
|
|
557
|
+
console.log(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
|
|
558
|
+
inst.onDisconnect(socket);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
console.log(`[WS] 핸들러 등록: ${namespace} (events: ${[...eventMap.keys()].join(', ')})`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
406
565
|
/**
|
|
407
566
|
* 단일 라우트의 Bridge 핸들러 생성
|
|
408
567
|
* rawReq → Context → middleware → controller → toResponse
|
package/lib/core/AutoLoader.js
CHANGED
|
@@ -50,6 +50,7 @@ export default class AutoLoader {
|
|
|
50
50
|
*/
|
|
51
51
|
async load() {
|
|
52
52
|
await this.loadModels();
|
|
53
|
+
await this.loadControllers();
|
|
53
54
|
await this.loadServices();
|
|
54
55
|
await this.loadMiddleware();
|
|
55
56
|
await this.loadEvents();
|
|
@@ -72,6 +73,51 @@ export default class AutoLoader {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/**
|
|
77
|
+
* controllers/*.js → 싱글톤 인스턴스 + __handler__ static 등록
|
|
78
|
+
*
|
|
79
|
+
* 문서 01-routing-controllers.md:
|
|
80
|
+
* 프로토타입 메서드를 static 레퍼런스로 자동 등록하여
|
|
81
|
+
* 라우트에서 UserController.index 형태로 사용 가능.
|
|
82
|
+
*/
|
|
83
|
+
async loadControllers() {
|
|
84
|
+
const controllerDir = path.join(this.baseDir, 'controllers');
|
|
85
|
+
const files = await scanDir(controllerDir);
|
|
86
|
+
if (!this.app._controllers) {
|
|
87
|
+
this.app._controllers = new Map();
|
|
88
|
+
}
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
const mod = await import(file);
|
|
91
|
+
const ControllerClass = mod.default;
|
|
92
|
+
if (!ControllerClass) continue;
|
|
93
|
+
|
|
94
|
+
// __handler__ static 레퍼런스 자동 등록
|
|
95
|
+
AutoLoader.registerController(ControllerClass);
|
|
96
|
+
|
|
97
|
+
// 컨트롤러 이름 등록 (싱글톤 인스턴스는 boot 완료 후 생성)
|
|
98
|
+
const name = extractName(file, 'Controller');
|
|
99
|
+
this.app._controllers.set(name, ControllerClass);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 컨트롤러 프로토타입 메서드를 static __handler__ 레퍼런스로 등록
|
|
105
|
+
* @param {Function} ControllerClass
|
|
106
|
+
*/
|
|
107
|
+
static registerController(ControllerClass) {
|
|
108
|
+
const proto = ControllerClass.prototype;
|
|
109
|
+
for (const method of Object.getOwnPropertyNames(proto)) {
|
|
110
|
+
if (method === 'constructor') continue;
|
|
111
|
+
if (typeof proto[method] !== 'function') continue;
|
|
112
|
+
// static property로 __handler__ 디스크립터 등록
|
|
113
|
+
ControllerClass[method] = {
|
|
114
|
+
__handler__: true,
|
|
115
|
+
controller: ControllerClass,
|
|
116
|
+
method,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
75
121
|
/** services/*.js → DI register */
|
|
76
122
|
async loadServices() {
|
|
77
123
|
const files = await scanDir(path.join(this.baseDir, 'services'));
|