@fuzionx/framework 0.1.2
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/bin/fx.js +12 -0
- package/index.js +64 -0
- package/lib/core/AppError.js +46 -0
- package/lib/core/Application.js +553 -0
- package/lib/core/AutoLoader.js +162 -0
- package/lib/core/Base.js +64 -0
- package/lib/core/Config.js +122 -0
- package/lib/core/Context.js +429 -0
- package/lib/database/ConnectionManager.js +192 -0
- package/lib/database/MariaModel.js +29 -0
- package/lib/database/Model.js +247 -0
- package/lib/database/ModelRegistry.js +72 -0
- package/lib/database/MongoModel.js +232 -0
- package/lib/database/Pagination.js +37 -0
- package/lib/database/PostgreModel.js +29 -0
- package/lib/database/QueryBuilder.js +172 -0
- package/lib/database/SQLiteModel.js +27 -0
- package/lib/database/SqlModel.js +252 -0
- package/lib/database/SqlQueryBuilder.js +309 -0
- package/lib/helpers/CryptoHelper.js +48 -0
- package/lib/helpers/FileHelper.js +61 -0
- package/lib/helpers/HashHelper.js +39 -0
- package/lib/helpers/I18nHelper.js +170 -0
- package/lib/helpers/Logger.js +105 -0
- package/lib/helpers/MediaHelper.js +38 -0
- package/lib/http/Controller.js +34 -0
- package/lib/http/ErrorHandler.js +135 -0
- package/lib/http/Middleware.js +43 -0
- package/lib/http/Router.js +109 -0
- package/lib/http/Validation.js +124 -0
- package/lib/middleware/index.js +286 -0
- package/lib/realtime/RoomManager.js +85 -0
- package/lib/realtime/WsHandler.js +107 -0
- package/lib/schedule/Job.js +34 -0
- package/lib/schedule/Queue.js +90 -0
- package/lib/schedule/Scheduler.js +161 -0
- package/lib/schedule/Task.js +39 -0
- package/lib/schedule/WorkerPool.js +225 -0
- package/lib/services/EventBus.js +94 -0
- package/lib/services/Service.js +261 -0
- package/lib/services/Storage.js +112 -0
- package/lib/view/OpenAPI.js +231 -0
- package/lib/view/View.js +72 -0
- package/package.json +52 -0
- package/testing/index.js +232 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context — 요청/응답 통합 API
|
|
3
|
+
*
|
|
4
|
+
* Bridge rawReq → Context 변환.
|
|
5
|
+
* 미들웨어/핸들러에 전달되는 단일 객체.
|
|
6
|
+
*
|
|
7
|
+
* Session은 Bridge sessionSet/Destroy/Renew N-API에 위임.
|
|
8
|
+
* Cookie는 rawReq.headers.cookie 파싱 + Set-Cookie 헤더 생성.
|
|
9
|
+
*
|
|
10
|
+
* @see docs/framework/06-context.md
|
|
11
|
+
* @see packages/fuzionx/lib/context.js (Core SessionProto 참조)
|
|
12
|
+
*/
|
|
13
|
+
import AppError, { ValidationError } from '../core/AppError.js';
|
|
14
|
+
|
|
15
|
+
export default class Context {
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} rawReq - Bridge에서 전달된 raw 요청 객체
|
|
18
|
+
* @param {import('./Application.js').default} app
|
|
19
|
+
*/
|
|
20
|
+
constructor(rawReq, app) {
|
|
21
|
+
// ── 요청 프로퍼티 ──
|
|
22
|
+
this.method = rawReq.method || 'GET';
|
|
23
|
+
this.url = rawReq.url || '/';
|
|
24
|
+
this.path = rawReq.path || this.url.split('?')[0];
|
|
25
|
+
this.query = rawReq.query || {};
|
|
26
|
+
this.params = rawReq.params || {};
|
|
27
|
+
this.headers = rawReq.headers || {};
|
|
28
|
+
this.body = rawReq.body || null;
|
|
29
|
+
this.ip = rawReq.remoteIp || '';
|
|
30
|
+
this.files = rawReq.files || null;
|
|
31
|
+
this.formFields = rawReq.formFields || null;
|
|
32
|
+
this.handlerId = rawReq.handlerId;
|
|
33
|
+
|
|
34
|
+
// 파생 속성 (06-context.md)
|
|
35
|
+
this.protocol = this.headers['x-forwarded-proto'] || 'http';
|
|
36
|
+
this.host = this.headers['host'] || '';
|
|
37
|
+
|
|
38
|
+
// ── 프레임워크 주입 ──
|
|
39
|
+
this.app = app;
|
|
40
|
+
this.user = null;
|
|
41
|
+
this._sessionId = rawReq.sessionId || null;
|
|
42
|
+
this._rawSession = rawReq.session || {};
|
|
43
|
+
// ── Cookie (parseCookies 먼저 — _detectLocale에서 참조) ──
|
|
44
|
+
this.cookies = this._parseCookies();
|
|
45
|
+
|
|
46
|
+
this.session = this._createSession(rawReq, app);
|
|
47
|
+
this.locale = this._detectLocale(rawReq);
|
|
48
|
+
|
|
49
|
+
// i18n — ctx.t() + ctx.t.all() (18-i18n.md)
|
|
50
|
+
this.t = this._createT();
|
|
51
|
+
|
|
52
|
+
// ── lazy 캐시 ──
|
|
53
|
+
this._json = undefined;
|
|
54
|
+
this._setCookies = []; // Set-Cookie 헤더 배열
|
|
55
|
+
|
|
56
|
+
// ── 응답 상태 ──
|
|
57
|
+
this._statusCode = 200;
|
|
58
|
+
this._headers = {};
|
|
59
|
+
this._body = '';
|
|
60
|
+
this._sent = false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
64
|
+
// Request 유틸
|
|
65
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
66
|
+
|
|
67
|
+
/** lazy JSON 파싱 */
|
|
68
|
+
get getJson() {
|
|
69
|
+
if (this._json !== undefined) return this._json;
|
|
70
|
+
if (typeof this.body === 'object' && this.body !== null) {
|
|
71
|
+
this._json = this.body;
|
|
72
|
+
} else if (typeof this.body === 'string' && this.body) {
|
|
73
|
+
try { this._json = JSON.parse(this.body); }
|
|
74
|
+
catch { this._json = null; }
|
|
75
|
+
} else {
|
|
76
|
+
this._json = null;
|
|
77
|
+
}
|
|
78
|
+
return this._json;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 헤더 값 가져오기 (case-insensitive) */
|
|
82
|
+
get(name) {
|
|
83
|
+
return this.headers[name.toLowerCase()];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Content-Type 확인 */
|
|
87
|
+
is(type) {
|
|
88
|
+
const ct = this.get('content-type') || '';
|
|
89
|
+
const map = {
|
|
90
|
+
json: 'application/json',
|
|
91
|
+
html: 'text/html',
|
|
92
|
+
form: 'application/x-www-form-urlencoded',
|
|
93
|
+
multipart: 'multipart/form-data',
|
|
94
|
+
};
|
|
95
|
+
return ct.includes(map[type] || type);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Accept 협상 (06-context.md)
|
|
100
|
+
* @param {...string} types - 'json', 'html', 'text'
|
|
101
|
+
* @returns {string|false} 매칭된 타입 또는 false
|
|
102
|
+
*/
|
|
103
|
+
accepts(...types) {
|
|
104
|
+
const accept = this.get('accept') || '*/*';
|
|
105
|
+
const mimeMap = {
|
|
106
|
+
json: 'application/json',
|
|
107
|
+
html: 'text/html',
|
|
108
|
+
text: 'text/plain',
|
|
109
|
+
xml: 'application/xml',
|
|
110
|
+
};
|
|
111
|
+
for (const type of types) {
|
|
112
|
+
const mime = mimeMap[type] || type;
|
|
113
|
+
if (accept.includes(mime) || accept.includes('*/*')) return type;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
119
|
+
// Cookie (06-context.md)
|
|
120
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 쿠키 읽기/쓰기
|
|
124
|
+
* @overload cookie(name) → 읽기
|
|
125
|
+
* @overload cookie(name, value, opts?) → 쓰기
|
|
126
|
+
*/
|
|
127
|
+
cookie(name, value, opts) {
|
|
128
|
+
if (arguments.length === 1) {
|
|
129
|
+
return this.cookies[name] ?? null;
|
|
130
|
+
}
|
|
131
|
+
// 쓰기 → Set-Cookie 헤더
|
|
132
|
+
const o = opts || {};
|
|
133
|
+
let str = `${name}=${encodeURIComponent(String(value))}`;
|
|
134
|
+
if (o.maxAge != null) str += `; Max-Age=${o.maxAge}`;
|
|
135
|
+
if (o.path) str += `; Path=${o.path}`;
|
|
136
|
+
else str += '; Path=/';
|
|
137
|
+
if (o.domain) str += `; Domain=${o.domain}`;
|
|
138
|
+
if (o.httpOnly !== false) str += '; HttpOnly';
|
|
139
|
+
if (o.secure) str += '; Secure';
|
|
140
|
+
if (o.sameSite) str += `; SameSite=${o.sameSite}`;
|
|
141
|
+
this._setCookies.push(str);
|
|
142
|
+
return this;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 쿠키 삭제 */
|
|
146
|
+
clearCookie(name, opts) {
|
|
147
|
+
return this.cookie(name, '', { ...opts, maxAge: 0 });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @private */
|
|
151
|
+
_parseCookies() {
|
|
152
|
+
const header = this.headers?.cookie || this.headers?.Cookie || '';
|
|
153
|
+
if (!header) return {};
|
|
154
|
+
const result = {};
|
|
155
|
+
for (const pair of header.split(';')) {
|
|
156
|
+
const [k, ...v] = pair.trim().split('=');
|
|
157
|
+
if (k) result[k.trim()] = decodeURIComponent(v.join('='));
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
163
|
+
// Response 메서드
|
|
164
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
165
|
+
|
|
166
|
+
status(code) {
|
|
167
|
+
this._statusCode = code;
|
|
168
|
+
return this;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
json(data) {
|
|
172
|
+
this._body = JSON.stringify(data);
|
|
173
|
+
this._headers['Content-Type'] = 'application/json';
|
|
174
|
+
this._sent = true;
|
|
175
|
+
return this;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
send(text) {
|
|
179
|
+
this._body = String(text);
|
|
180
|
+
this._headers['Content-Type'] = 'text/html; charset=utf-8';
|
|
181
|
+
this._sent = true;
|
|
182
|
+
return this;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
html(content) {
|
|
186
|
+
return this.send(content);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
text(content) {
|
|
190
|
+
this._body = String(content);
|
|
191
|
+
this._headers['Content-Type'] = 'text/plain; charset=utf-8';
|
|
192
|
+
this._sent = true;
|
|
193
|
+
return this;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
redirect(url, code = 302) {
|
|
197
|
+
this._statusCode = code;
|
|
198
|
+
this._headers['Location'] = url;
|
|
199
|
+
this._body = '';
|
|
200
|
+
this._sent = true;
|
|
201
|
+
return this;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
back() {
|
|
205
|
+
const referer = this.get('referer') || '/';
|
|
206
|
+
return this.redirect(referer);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
end() {
|
|
210
|
+
this._body = '';
|
|
211
|
+
this._sent = true;
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
header(key, value) {
|
|
216
|
+
this._headers[key] = value;
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** setHeader alias (06-context.md) */
|
|
221
|
+
setHeader(key, value) {
|
|
222
|
+
return this.header(key, value);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
error(statusCode, message) {
|
|
226
|
+
throw new AppError(message, statusCode);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 파일 다운로드 (06-context.md)
|
|
231
|
+
* @param {string} filePath
|
|
232
|
+
* @param {string} [filename]
|
|
233
|
+
*/
|
|
234
|
+
download(filePath, filename) {
|
|
235
|
+
const name = filename || filePath.split('/').pop();
|
|
236
|
+
this._headers['Content-Disposition'] = `attachment; filename="${name}"`;
|
|
237
|
+
this._body = filePath; // Application에서 파일 전송 처리
|
|
238
|
+
this._sent = true;
|
|
239
|
+
return this;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 스트리밍 응답 (06-context.md)
|
|
244
|
+
* 대용량 응답 시 ReadableStream/Buffer를 직접 전송.
|
|
245
|
+
* @param {ReadableStream|Buffer} readableStream
|
|
246
|
+
* @param {string} [contentType='application/octet-stream']
|
|
247
|
+
*/
|
|
248
|
+
stream(readableStream, contentType = 'application/octet-stream') {
|
|
249
|
+
this._headers['Content-Type'] = contentType;
|
|
250
|
+
this._streamBody = readableStream;
|
|
251
|
+
this._sent = true;
|
|
252
|
+
return this;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
256
|
+
// Validate
|
|
257
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
258
|
+
|
|
259
|
+
validate(schema) {
|
|
260
|
+
if (schema && typeof schema.validate === 'function') {
|
|
261
|
+
const { error, value } = schema.validate(this.body, { abortEarly: false, stripUnknown: true });
|
|
262
|
+
if (error) {
|
|
263
|
+
const fields = {};
|
|
264
|
+
for (const detail of error.details) {
|
|
265
|
+
fields[detail.path.join('.')] = detail.message;
|
|
266
|
+
}
|
|
267
|
+
throw new ValidationError('Validation failed', fields);
|
|
268
|
+
}
|
|
269
|
+
return value;
|
|
270
|
+
}
|
|
271
|
+
return this.body;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
275
|
+
// i18n (Bridge 연동)
|
|
276
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
277
|
+
|
|
278
|
+
/** @private Setup i18n t() function with .all() support */
|
|
279
|
+
_createT() {
|
|
280
|
+
const self = this;
|
|
281
|
+
const t = (key, vars) => {
|
|
282
|
+
const locale = vars?.locale || self.locale;
|
|
283
|
+
if (self.app?.i18n) {
|
|
284
|
+
return self.app.i18n.translate(locale, key, vars);
|
|
285
|
+
}
|
|
286
|
+
return vars?.default || key;
|
|
287
|
+
};
|
|
288
|
+
// ctx.t.all() — 현재 locale 전체 번역 데이터 (18-i18n.md)
|
|
289
|
+
t.all = () => {
|
|
290
|
+
if (self.app?.i18n?.getAll) {
|
|
291
|
+
return self.app.i18n.getAll(self.locale);
|
|
292
|
+
}
|
|
293
|
+
return {};
|
|
294
|
+
};
|
|
295
|
+
return t;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
299
|
+
// View 렌더링
|
|
300
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
301
|
+
|
|
302
|
+
render(view, data) {
|
|
303
|
+
// 글로벌 변수 주입 (03-views-templates.md)
|
|
304
|
+
const globals = {
|
|
305
|
+
session: this._rawSession,
|
|
306
|
+
auth: { user: this.user },
|
|
307
|
+
config: this.app?.config?._raw || {},
|
|
308
|
+
request: { url: this.url, method: this.method, path: this.path, ip: this.ip },
|
|
309
|
+
csrf_token: this._rawSession?._csrfToken || '',
|
|
310
|
+
flash: this.session?.getFlash() || {},
|
|
311
|
+
theme: this.theme || this.app?.config?.get('themes.default', 'default') || 'default',
|
|
312
|
+
locale: this.locale,
|
|
313
|
+
...data,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// Bridge SSR (i18n.render → bridge.ssrRenderString)
|
|
317
|
+
if (this.app?._bridge && typeof this.app._bridge.ssrRenderString === 'function') {
|
|
318
|
+
try {
|
|
319
|
+
const html = this.app._bridge.ssrRenderString(view, globals, this.locale);
|
|
320
|
+
return this.html(html);
|
|
321
|
+
} catch {} // Bridge SSR 실패 시 View 폴백
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (this.app?._view) {
|
|
325
|
+
const html = this.app._view.render(view, globals);
|
|
326
|
+
return this.html(html);
|
|
327
|
+
}
|
|
328
|
+
return this.send(`View '${view}' not found`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
332
|
+
// Session + Flash (Bridge sessionSet/Destroy/Renew)
|
|
333
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
334
|
+
|
|
335
|
+
/** @private */
|
|
336
|
+
_createSession(rawReq, app) {
|
|
337
|
+
const data = rawReq.session || {};
|
|
338
|
+
const flash = { ...(data._flash || {}) };
|
|
339
|
+
delete data._flash;
|
|
340
|
+
const sessionId = rawReq.sessionId || null;
|
|
341
|
+
const bridge = app?._bridge || null;
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
/** 세션 값 조회 */
|
|
345
|
+
get: (key) => key ? (data[key] ?? null) : { ...data },
|
|
346
|
+
|
|
347
|
+
/** 세션 값 설정 — Bridge sessionSet 연동 */
|
|
348
|
+
set: (key, value) => {
|
|
349
|
+
data[key] = value;
|
|
350
|
+
// Core SessionProto 참조: bridge.sessionSet(id, { ...data })
|
|
351
|
+
if (sessionId && bridge?.sessionSet) {
|
|
352
|
+
try { bridge.sessionSet(sessionId, { ...data }); } catch {}
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
/** 세션 삭제 — Bridge sessionDestroy 연동 */
|
|
357
|
+
destroy: () => {
|
|
358
|
+
Object.keys(data).forEach(k => delete data[k]);
|
|
359
|
+
if (sessionId && bridge?.sessionDestroy) {
|
|
360
|
+
try { bridge.sessionDestroy(sessionId); } catch {}
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
/** 세션 갱신 — Bridge sessionRenew 연동 */
|
|
365
|
+
renew: () => {
|
|
366
|
+
if (sessionId && bridge?.sessionRenew) {
|
|
367
|
+
try { return bridge.sessionRenew(sessionId); } catch {}
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
/** 플래시 메시지 설정 */
|
|
373
|
+
flash: (key, value) => {
|
|
374
|
+
if (!data._flash) data._flash = {};
|
|
375
|
+
data._flash[key] = value;
|
|
376
|
+
// 플래시도 세션에 영속화
|
|
377
|
+
if (sessionId && bridge?.sessionSet) {
|
|
378
|
+
try { bridge.sessionSet(sessionId, { ...data }); } catch {}
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
/** 플래시 메시지 조회 (1회 후 삭제) */
|
|
383
|
+
getFlash: (key) => {
|
|
384
|
+
if (key) {
|
|
385
|
+
const val = flash[key];
|
|
386
|
+
delete flash[key];
|
|
387
|
+
return val ?? null;
|
|
388
|
+
}
|
|
389
|
+
const all = { ...flash };
|
|
390
|
+
Object.keys(flash).forEach(k => delete flash[k]);
|
|
391
|
+
return all;
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** locale 감지 (5단계 우선순위) — 06-context.md */
|
|
397
|
+
_detectLocale(rawReq) {
|
|
398
|
+
if (rawReq.locale) return rawReq.locale;
|
|
399
|
+
if (this.query?.lang) return this.query.lang;
|
|
400
|
+
// 쿠키 fuzionx.lang
|
|
401
|
+
const cookieLang = this.cookies?.['fuzionx.lang'];
|
|
402
|
+
if (cookieLang) return cookieLang;
|
|
403
|
+
if (this._rawSession?.locale) return this._rawSession.locale;
|
|
404
|
+
const al = this.headers?.['accept-language'];
|
|
405
|
+
if (al) {
|
|
406
|
+
const first = al.split(',')[0]?.split('-')[0];
|
|
407
|
+
if (first) return first;
|
|
408
|
+
}
|
|
409
|
+
return this.app?.config?.get('app.i18n.default_locale', 'ko') || 'ko';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
413
|
+
// 응답 변환
|
|
414
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
415
|
+
|
|
416
|
+
/** Bridge 응답 포맷 { status, body, headers } */
|
|
417
|
+
toResponse() {
|
|
418
|
+
// Set-Cookie 헤더 병합
|
|
419
|
+
if (this._setCookies.length > 0) {
|
|
420
|
+
this._headers['Set-Cookie'] = this._setCookies; // 배열 → Bridge 다중 헤더
|
|
421
|
+
}
|
|
422
|
+
return {
|
|
423
|
+
status: this._statusCode,
|
|
424
|
+
body: this._streamBody || this._body,
|
|
425
|
+
headers: this._headers,
|
|
426
|
+
stream: !!this._streamBody,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConnectionManager — DB 연결 관리 싱글톤
|
|
3
|
+
*
|
|
4
|
+
* 드라이버별 연결 생성/캐싱/종료.
|
|
5
|
+
* SQLite (기본), MariaDB, PostgreSQL, MongoDB 지원.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/framework/02-database-orm.md
|
|
8
|
+
* @see docs/framework/17-config.md
|
|
9
|
+
*/
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
|
|
12
|
+
const _require = createRequire(import.meta.url);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* optional dependency 안전 로드
|
|
16
|
+
* @private
|
|
17
|
+
*/
|
|
18
|
+
function tryRequire(name) {
|
|
19
|
+
try { return _require(name); } catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default class ConnectionManager {
|
|
23
|
+
constructor() {
|
|
24
|
+
/** @type {Map<string, object>} name → connection */
|
|
25
|
+
this._connections = new Map();
|
|
26
|
+
/** @type {Map<string, object>} name → config */
|
|
27
|
+
this._configs = new Map();
|
|
28
|
+
/** @type {string} default connection name */
|
|
29
|
+
this._default = 'main';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 설정으로 초기화
|
|
34
|
+
* @param {object} dbConfig - fuzionx.yaml database 섹션
|
|
35
|
+
*/
|
|
36
|
+
configure(dbConfig) {
|
|
37
|
+
this._default = dbConfig.default || 'main';
|
|
38
|
+
const connections = dbConfig.connections || {};
|
|
39
|
+
for (const [name, cfg] of Object.entries(connections)) {
|
|
40
|
+
this._configs.set(name, cfg);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 연결 가져오기 (lazy 생성)
|
|
46
|
+
* @param {string} [name] - connection name (default: _default)
|
|
47
|
+
* @returns {object} driver connection instance
|
|
48
|
+
*/
|
|
49
|
+
get(name) {
|
|
50
|
+
const connName = name || this._default;
|
|
51
|
+
if (this._connections.has(connName)) {
|
|
52
|
+
return this._connections.get(connName);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const config = this._configs.get(connName);
|
|
56
|
+
if (!config) {
|
|
57
|
+
throw new Error(`DB connection '${connName}' not configured. Check fuzionx.yaml database.connections.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const conn = this._createConnection(connName, config);
|
|
61
|
+
this._connections.set(connName, conn);
|
|
62
|
+
return conn;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 드라이버 타입 조회
|
|
67
|
+
* @param {string} [name]
|
|
68
|
+
* @returns {string} 'sqlite' | 'mariadb' | 'postgres' | 'mongodb'
|
|
69
|
+
*/
|
|
70
|
+
getDriver(name) {
|
|
71
|
+
const connName = name || this._default;
|
|
72
|
+
const config = this._configs.get(connName);
|
|
73
|
+
return config?.driver || 'sqlite';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 드라이버별 연결 생성
|
|
78
|
+
* @private
|
|
79
|
+
*/
|
|
80
|
+
_createConnection(name, config) {
|
|
81
|
+
const driver = (config.driver || 'sqlite').toLowerCase();
|
|
82
|
+
|
|
83
|
+
switch (driver) {
|
|
84
|
+
case 'sqlite': {
|
|
85
|
+
const Database = tryRequire('better-sqlite3');
|
|
86
|
+
if (!Database) return this._createStub('sqlite', config);
|
|
87
|
+
|
|
88
|
+
const dbPath = config.database || config.path || ':memory:';
|
|
89
|
+
const db = new Database(dbPath, {
|
|
90
|
+
verbose: config.verbose ? console.log : undefined,
|
|
91
|
+
});
|
|
92
|
+
db.pragma('journal_mode = WAL');
|
|
93
|
+
db.pragma('foreign_keys = ON');
|
|
94
|
+
return { type: 'sqlite', db, config };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
case 'mariadb':
|
|
98
|
+
case 'mysql': {
|
|
99
|
+
const knex = tryRequire('knex');
|
|
100
|
+
if (!knex) return this._createStub('knex', config, 'mariadb');
|
|
101
|
+
|
|
102
|
+
const knexInit = typeof knex === 'function' ? knex : knex.default;
|
|
103
|
+
const db = knexInit({
|
|
104
|
+
client: 'mysql2',
|
|
105
|
+
connection: {
|
|
106
|
+
host: config.host || '127.0.0.1',
|
|
107
|
+
port: config.port || 3306,
|
|
108
|
+
user: config.user || 'root',
|
|
109
|
+
password: config.password || '',
|
|
110
|
+
database: config.database || '',
|
|
111
|
+
charset: config.charset || 'utf8mb4',
|
|
112
|
+
},
|
|
113
|
+
pool: config.pool || { min: 2, max: 10 },
|
|
114
|
+
});
|
|
115
|
+
return { type: 'knex', driver: 'mariadb', db, config };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case 'postgres':
|
|
119
|
+
case 'postgresql': {
|
|
120
|
+
const knex = tryRequire('knex');
|
|
121
|
+
if (!knex) return this._createStub('knex', config, 'postgres');
|
|
122
|
+
|
|
123
|
+
const knexInit = typeof knex === 'function' ? knex : knex.default;
|
|
124
|
+
const db = knexInit({
|
|
125
|
+
client: 'pg',
|
|
126
|
+
connection: {
|
|
127
|
+
host: config.host || '127.0.0.1',
|
|
128
|
+
port: config.port || 5432,
|
|
129
|
+
user: config.user || 'postgres',
|
|
130
|
+
password: config.password || '',
|
|
131
|
+
database: config.database || '',
|
|
132
|
+
},
|
|
133
|
+
pool: config.pool || { min: 2, max: 10 },
|
|
134
|
+
});
|
|
135
|
+
return { type: 'knex', driver: 'postgres', db, config };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'mongodb':
|
|
139
|
+
case 'mongo': {
|
|
140
|
+
const mongoose = tryRequire('mongoose');
|
|
141
|
+
if (!mongoose) return this._createStub('mongo', config);
|
|
142
|
+
return { type: 'mongo', mongoose, config };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
default:
|
|
146
|
+
throw new Error(`Unsupported DB driver: '${driver}'. Supported: sqlite, mariadb, postgres, mongodb.`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 드라이버 미설치 stub
|
|
152
|
+
* @private
|
|
153
|
+
*/
|
|
154
|
+
_createStub(type, config, driver) {
|
|
155
|
+
return { type, driver: driver || type, db: null, config, _stub: true };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 모든 연결 종료 (graceful shutdown)
|
|
160
|
+
*/
|
|
161
|
+
async closeAll() {
|
|
162
|
+
for (const [name, conn] of this._connections) {
|
|
163
|
+
try {
|
|
164
|
+
if (conn.type === 'sqlite' && conn.db) {
|
|
165
|
+
conn.db.close();
|
|
166
|
+
} else if (conn.type === 'knex' && conn.db) {
|
|
167
|
+
await conn.db.destroy();
|
|
168
|
+
} else if (conn.type === 'mongo' && conn.mongoose) {
|
|
169
|
+
await conn.mongoose.disconnect();
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error(`[ConnectionManager] Error closing '${name}':`, err.message);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
this._connections.clear();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 특정 연결 종료
|
|
180
|
+
* @param {string} name
|
|
181
|
+
*/
|
|
182
|
+
async close(name) {
|
|
183
|
+
const conn = this._connections.get(name);
|
|
184
|
+
if (!conn) return;
|
|
185
|
+
try {
|
|
186
|
+
if (conn.type === 'sqlite' && conn.db) conn.db.close();
|
|
187
|
+
else if (conn.type === 'knex' && conn.db) await conn.db.destroy();
|
|
188
|
+
else if (conn.type === 'mongo' && conn.mongoose) await conn.mongoose.disconnect();
|
|
189
|
+
} catch {}
|
|
190
|
+
this._connections.delete(name);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MariaModel — MariaDB/MySQL 모델 서브클래스
|
|
3
|
+
*
|
|
4
|
+
* Knex + mysql2 기반.
|
|
5
|
+
* SqlModel의 공통 SQL 로직을 상속하며,
|
|
6
|
+
* MariaDB 고유 기능(FULLTEXT, JSON 등)은 raw()로 접근.
|
|
7
|
+
*
|
|
8
|
+
* @see docs/framework/02-database-orm.md
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { MariaModel } from '@fuzionx/framework';
|
|
12
|
+
*
|
|
13
|
+
* export default class User extends MariaModel {
|
|
14
|
+
* static table = 'users';
|
|
15
|
+
* static connection = 'main';
|
|
16
|
+
* static timestamps = true;
|
|
17
|
+
* static columns = {
|
|
18
|
+
* id: { type: 'increments' },
|
|
19
|
+
* name: { type: 'string', length: 100 },
|
|
20
|
+
* email: { type: 'string', length: 150, unique: true },
|
|
21
|
+
* };
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
import SqlModel from './SqlModel.js';
|
|
25
|
+
|
|
26
|
+
export default class MariaModel extends SqlModel {
|
|
27
|
+
static driver = 'mariadb';
|
|
28
|
+
static connection = 'main';
|
|
29
|
+
}
|