@fuzionx/framework 0.1.39 → 0.1.42

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.
Files changed (80) hide show
  1. package/README.md +501 -501
  2. package/bin/fx.js +12 -12
  3. package/cli/db-sync.js +100 -99
  4. package/cli/index.js +494 -493
  5. package/cli/templates/make/app/controllers/HomeController.js +14 -14
  6. package/cli/templates/make/app/routes/api.js +7 -7
  7. package/cli/templates/make/app/routes/web.js +5 -5
  8. package/cli/templates/make/app/views/default/errors/404.html +11 -11
  9. package/cli/templates/make/app/views/default/errors/500.html +14 -14
  10. package/cli/templates/make/app/views/default/layouts/main.html +22 -22
  11. package/cli/templates/make/app/views/default/pages/home.html +11 -11
  12. package/cli/templates/make/controller.js.tpl +40 -40
  13. package/cli/templates/make/event.js.tpl +8 -8
  14. package/cli/templates/make/job.js.tpl +10 -10
  15. package/cli/templates/make/middleware.js.tpl +10 -10
  16. package/cli/templates/make/model.js.tpl +15 -15
  17. package/cli/templates/make/service.js.tpl +15 -15
  18. package/cli/templates/make/task.js.tpl +15 -15
  19. package/cli/templates/make/test.js.tpl +7 -7
  20. package/cli/templates/make/worker.js.tpl +14 -14
  21. package/cli/templates/make/ws.js.tpl +18 -18
  22. package/index.js +67 -67
  23. package/lib/core/AppError.js +46 -46
  24. package/lib/core/Application.js +1006 -1006
  25. package/lib/core/AutoLoader.js +227 -226
  26. package/lib/core/Base.js +64 -64
  27. package/lib/core/Config.js +228 -228
  28. package/lib/core/Context.js +484 -484
  29. package/lib/database/ConnectionManager.js +208 -208
  30. package/lib/database/MariaModel.js +29 -29
  31. package/lib/database/Model.js +247 -247
  32. package/lib/database/ModelRegistry.js +72 -72
  33. package/lib/database/MongoModel.js +232 -232
  34. package/lib/database/Pagination.js +37 -37
  35. package/lib/database/PostgreModel.js +29 -29
  36. package/lib/database/QueryBuilder.js +172 -172
  37. package/lib/database/SQLiteModel.js +27 -27
  38. package/lib/database/SqlModel.js +257 -257
  39. package/lib/database/SqlQueryBuilder.js +332 -332
  40. package/lib/helpers/CryptoHelper.js +48 -48
  41. package/lib/helpers/FileHelper.js +61 -61
  42. package/lib/helpers/HashHelper.js +39 -39
  43. package/lib/helpers/I18nHelper.js +174 -174
  44. package/lib/helpers/Logger.js +108 -108
  45. package/lib/helpers/MediaHelper.js +84 -84
  46. package/lib/http/Controller.js +34 -34
  47. package/lib/http/ErrorHandler.js +136 -136
  48. package/lib/http/Middleware.js +43 -43
  49. package/lib/http/Router.js +109 -109
  50. package/lib/http/Validation.js +125 -125
  51. package/lib/middleware/apiAuth.js +79 -79
  52. package/lib/middleware/auth.js +42 -42
  53. package/lib/middleware/bodyParser.js +19 -19
  54. package/lib/middleware/cors.js +47 -47
  55. package/lib/middleware/csrf.js +32 -32
  56. package/lib/middleware/index.js +13 -13
  57. package/lib/middleware/session.js +27 -27
  58. package/lib/middleware/theme.js +20 -20
  59. package/lib/realtime/RoomManager.js +85 -85
  60. package/lib/realtime/WsHandler.js +107 -107
  61. package/lib/schedule/Job.js +38 -38
  62. package/lib/schedule/Queue.js +103 -103
  63. package/lib/schedule/Scheduler.js +171 -171
  64. package/lib/schedule/Task.js +39 -39
  65. package/lib/schedule/WorkerPool.js +225 -225
  66. package/lib/services/EventBus.js +94 -94
  67. package/lib/services/Service.js +261 -261
  68. package/lib/services/Storage.js +112 -112
  69. package/lib/utilities/ArrUtil.js +112 -112
  70. package/lib/utilities/DateUtil.js +98 -98
  71. package/lib/utilities/FunctionUtil.js +119 -119
  72. package/lib/utilities/NumUtil.js +75 -75
  73. package/lib/utilities/ObjectUtil.js +170 -170
  74. package/lib/utilities/PaginationUtil.js +81 -81
  75. package/lib/utilities/StrUtil.js +105 -105
  76. package/lib/utilities/index.js +18 -18
  77. package/lib/view/OpenAPI.js +231 -231
  78. package/lib/view/View.js +83 -83
  79. package/package.json +2 -2
  80. package/testing/index.js +232 -232
@@ -1,484 +1,484 @@
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
- import { readFileSync } from 'node:fs';
15
-
16
- export default class Context {
17
- /**
18
- * @param {object} rawReq - Bridge에서 전달된 raw 요청 객체
19
- * @param {import('./Application.js').default} app
20
- */
21
- constructor(rawReq, app) {
22
- // ── 요청 프로퍼티 ──
23
- this.method = rawReq.method || 'GET';
24
- this.url = rawReq.url || '/';
25
- this.path = rawReq.path || this.url.split('?')[0];
26
- this.query = rawReq.query || {};
27
- this.params = rawReq.params || {};
28
- this.headers = rawReq.headers || {};
29
- this.body = rawReq.body || null;
30
- this.ip = rawReq.remoteIp || '';
31
- this.files = rawReq.files || null;
32
- this.uploadError = rawReq.uploadError || null;
33
- this.formFields = rawReq.formFields || null;
34
- this.handlerId = rawReq.handlerId;
35
-
36
- // 파생 속성 (06-context.md)
37
- this.protocol = this.headers['x-forwarded-proto'] || 'http';
38
- this.host = this.headers['host'] || '';
39
-
40
- // ── 프레임워크 주입 ──
41
- this.app = app;
42
- this.user = null;
43
- this._sessionId = rawReq.sessionId || null;
44
- this._rawSession = rawReq.session || {};
45
- // ── Cookie (parseCookies 먼저 — _detectLocale에서 참조) ──
46
- this.cookies = this._parseCookies();
47
-
48
- this.session = this._createSession(rawReq, app);
49
- this.locale = this._detectLocale(rawReq);
50
-
51
- // i18n — ctx.t() + ctx.t.all() (18-i18n.md)
52
- this.t = this._createT();
53
-
54
- // ── lazy 캐시 ──
55
- this._json = undefined;
56
- this._setCookies = []; // Set-Cookie 헤더 배열
57
-
58
- // ── 응답 상태 ──
59
- this._statusCode = 200;
60
- this._headers = {};
61
- this._body = '';
62
- this._sent = false;
63
- }
64
-
65
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
66
- // Request 유틸
67
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
68
-
69
- /** lazy JSON 파싱 */
70
- get getJson() {
71
- if (this._json !== undefined) return this._json;
72
- if (typeof this.body === 'object' && this.body !== null) {
73
- this._json = this.body;
74
- } else if (typeof this.body === 'string' && this.body) {
75
- try { this._json = JSON.parse(this.body); }
76
- catch { this._json = null; }
77
- } else {
78
- this._json = null;
79
- }
80
- return this._json;
81
- }
82
-
83
- /** 헤더 값 가져오기 (case-insensitive) */
84
- get(name) {
85
- return this.headers[name.toLowerCase()];
86
- }
87
-
88
- /** Content-Type 확인 */
89
- is(type) {
90
- const ct = this.get('content-type') || '';
91
- const map = {
92
- json: 'application/json',
93
- html: 'text/html',
94
- form: 'application/x-www-form-urlencoded',
95
- multipart: 'multipart/form-data',
96
- };
97
- return ct.includes(map[type] || type);
98
- }
99
-
100
- /**
101
- * Accept 협상 (06-context.md)
102
- * @param {...string} types - 'json', 'html', 'text'
103
- * @returns {string|false} 매칭된 타입 또는 false
104
- */
105
- accepts(...types) {
106
- const accept = this.get('accept') || '*/*';
107
- const mimeMap = {
108
- json: 'application/json',
109
- html: 'text/html',
110
- text: 'text/plain',
111
- xml: 'application/xml',
112
- };
113
- for (const type of types) {
114
- const mime = mimeMap[type] || type;
115
- if (accept.includes(mime) || accept.includes('*/*')) return type;
116
- }
117
- return false;
118
- }
119
-
120
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
121
- // Cookie (06-context.md)
122
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
123
-
124
- /**
125
- * 쿠키 읽기/쓰기
126
- * @overload cookie(name) → 읽기
127
- * @overload cookie(name, value, opts?) → 쓰기
128
- */
129
- cookie(name, value, opts) {
130
- if (arguments.length === 1) {
131
- return this.cookies[name] ?? null;
132
- }
133
- // 쓰기 → Set-Cookie 헤더
134
- const o = opts || {};
135
- let str = `${name}=${encodeURIComponent(String(value))}`;
136
- if (o.maxAge != null) str += `; Max-Age=${o.maxAge}`;
137
- if (o.path) str += `; Path=${o.path}`;
138
- else str += '; Path=/';
139
- if (o.domain) str += `; Domain=${o.domain}`;
140
- if (o.httpOnly !== false) str += '; HttpOnly';
141
- if (o.secure) str += '; Secure';
142
- if (o.sameSite) str += `; SameSite=${o.sameSite}`;
143
- this._setCookies.push(str);
144
- return this;
145
- }
146
-
147
- /** 쿠키 삭제 */
148
- clearCookie(name, opts) {
149
- return this.cookie(name, '', { ...opts, maxAge: 0 });
150
- }
151
-
152
- /** @private */
153
- _parseCookies() {
154
- const header = this.headers?.cookie || this.headers?.Cookie || '';
155
- if (!header) return {};
156
- const result = {};
157
- for (const pair of header.split(';')) {
158
- const [k, ...v] = pair.trim().split('=');
159
- if (k) result[k.trim()] = decodeURIComponent(v.join('='));
160
- }
161
- return result;
162
- }
163
-
164
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
165
- // Response 메서드
166
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
167
-
168
- status(code) {
169
- this._statusCode = code;
170
- return this;
171
- }
172
-
173
- json(data) {
174
- this._body = JSON.stringify(data);
175
- this._headers['Content-Type'] = 'application/json';
176
- this._sent = true;
177
- return this;
178
- }
179
-
180
- send(data) {
181
- if (typeof data === 'object' && data !== null) {
182
- return this.json(data);
183
- }
184
- this._body = String(data);
185
- if (!this._headers['Content-Type']) {
186
- this._headers['Content-Type'] = 'text/html; charset=utf-8';
187
- }
188
- this._sent = true;
189
- return this;
190
- }
191
-
192
- html(content) {
193
- return this.send(content);
194
- }
195
-
196
- text(content) {
197
- this._body = String(content);
198
- this._headers['Content-Type'] = 'text/plain; charset=utf-8';
199
- this._sent = true;
200
- return this;
201
- }
202
-
203
- redirect(url, code = 302) {
204
- this._statusCode = code;
205
- this._headers['Location'] = url;
206
- this._body = '';
207
- this._sent = true;
208
- return this;
209
- }
210
-
211
- back() {
212
- const referer = this.get('referer') || '/';
213
- return this.redirect(referer);
214
- }
215
-
216
- end() {
217
- this._body = '';
218
- this._sent = true;
219
- return this;
220
- }
221
-
222
- header(key, value) {
223
- this._headers[key] = value;
224
- return this;
225
- }
226
-
227
- /** setHeader alias (06-context.md) */
228
- setHeader(key, value) {
229
- return this.header(key, value);
230
- }
231
-
232
- error(statusCode, message) {
233
- throw new AppError(message, statusCode);
234
- }
235
-
236
- /**
237
- * 파일 다운로드 (06-context.md)
238
- * Bridge는 문자열 body만 지원하므로 파일 내용을 직접 읽어 base64 전송.
239
- * @param {string} filePath - 절대 경로
240
- * @param {string} [filename]
241
- */
242
- download(filePath, filename) {
243
- const name = filename || filePath.split('/').pop();
244
- this._headers['Content-Disposition'] = `attachment; filename="${name}"`;
245
- try {
246
- const buf = readFileSync(filePath);
247
- this._body = buf.toString('base64');
248
- this._headers['Content-Transfer-Encoding'] = 'base64';
249
- this._headers['Content-Type'] = this._headers['Content-Type'] || 'application/octet-stream';
250
- } catch (err) {
251
- this._statusCode = 404;
252
- this._body = JSON.stringify({ error: `File not found: ${name}` });
253
- this._headers['Content-Type'] = 'application/json';
254
- }
255
- this._sent = true;
256
- return this;
257
- }
258
-
259
- /**
260
- * 스트리밍 응답 (06-context.md)
261
- * Bridge는 문자열 body만 지원하므로 Buffer/Stream을 문자열로 변환.
262
- * Buffer → toString(), ReadableStream → 청크 수집.
263
- * @param {ReadableStream|Buffer|string} readableStream
264
- * @param {string} [contentType='application/octet-stream']
265
- */
266
- async stream(readableStream, contentType = 'application/octet-stream') {
267
- this._headers['Content-Type'] = contentType;
268
- if (Buffer.isBuffer(readableStream)) {
269
- this._body = readableStream.toString();
270
- } else if (typeof readableStream === 'string') {
271
- this._body = readableStream;
272
- } else if (readableStream && typeof readableStream[Symbol.asyncIterator] === 'function') {
273
- // ReadableStream/AsyncIterable → 청크 수집
274
- const chunks = [];
275
- for await (const chunk of readableStream) {
276
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
277
- }
278
- this._body = Buffer.concat(chunks).toString();
279
- } else {
280
- this._body = String(readableStream || '');
281
- }
282
- this._sent = true;
283
- return this;
284
- }
285
-
286
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
287
- // Validate
288
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
289
-
290
- validate(schema) {
291
- if (schema && typeof schema.validate === 'function') {
292
- const { error, value } = schema.validate(this.body, { abortEarly: false, stripUnknown: true });
293
- if (error) {
294
- const fields = {};
295
- for (const detail of error.details) {
296
- fields[detail.path.join('.')] = detail.message;
297
- }
298
- throw new ValidationError('Validation failed', fields);
299
- }
300
- return value;
301
- }
302
- return this.body;
303
- }
304
-
305
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
306
- // i18n (Bridge 연동)
307
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
308
-
309
- /** @private Setup i18n t() function with .all() support */
310
- _createT() {
311
- const self = this;
312
- const t = (key, vars) => {
313
- const locale = vars?.locale || self.locale;
314
- if (self.app?.i18n) {
315
- return self.app.i18n.translate(locale, key, vars);
316
- }
317
- return vars?.default || key;
318
- };
319
- // ctx.t.all() — 현재 locale 전체 번역 데이터 (18-i18n.md)
320
- t.all = () => {
321
- if (self.app?.i18n?.getAll) {
322
- return self.app.i18n.getAll(self.locale);
323
- }
324
- return {};
325
- };
326
- return t;
327
- }
328
-
329
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
330
- // View 렌더링
331
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
332
-
333
- render(view, data) {
334
- // 글로벌 변수 주입 (03-views-templates.md)
335
- const bridge = this.app?._bridge;
336
- let aspVars = {};
337
- if (bridge?.getAspConfig && bridge?.cryptoEncryptCustom) {
338
- try {
339
- const aspCfg = JSON.parse(bridge.getAspConfig());
340
- const clientSecret = this.app?.config?.get('app.client_secret') || '';
341
- if (clientSecret && aspCfg.masterSecret) {
342
- aspVars = {
343
- _fx_client_secret: clientSecret,
344
- _fx_asp_secret: bridge.cryptoEncryptCustom(clientSecret, aspCfg.masterSecret),
345
- _fx_asp_header: aspCfg.headerSignal || 'Ruxy-Enc-Mode',
346
- };
347
- }
348
- } catch {}
349
- }
350
-
351
- const globals = {
352
- session: this._rawSession,
353
- auth: { user: this.user },
354
- config: this.app?.config?._raw || {},
355
- request: { url: this.url, method: this.method, path: this.path, ip: this.ip },
356
- csrf_token: this._rawSession?._csrfToken || '',
357
- flash: this.session?.getFlash() || {},
358
- theme: this.theme || this.app?.config?.get('themes.default', 'default') || 'default',
359
- locale: this.locale,
360
- locales: this.app?.i18n?.locales?.() || [],
361
- ...aspVars,
362
- ...data,
363
- };
364
-
365
- // Bridge Tera SSR — 앱별 View 인스턴스 사용
366
- const appEntry = this.app?._appRegistry?.get(this.appName);
367
- const viewEngine = appEntry?.view;
368
- if (!viewEngine) {
369
- throw new Error(`View not initialized for app '${this.appName}' — Bridge not available`);
370
- }
371
-
372
- const html = viewEngine.render(view, globals);
373
- return this.html(html);
374
- }
375
-
376
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
377
- // Session + Flash (Bridge sessionSet/Destroy/Renew)
378
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
379
-
380
- /** @private */
381
- _createSession(rawReq, app) {
382
- const data = rawReq.session || {};
383
- // Flash 추출 + 세션에서 삭제 (one-time read)
384
- const flash = { ...(data._flash || {}) };
385
- const hadFlash = !!data._flash;
386
- delete data._flash;
387
- const sessionId = rawReq.sessionId || null;
388
- const bridge = app?._bridge || null;
389
-
390
- // Flash 소비 후 세션 저장소에 즉시 반영
391
- if (hadFlash && sessionId && bridge?.sessionSet) {
392
- try { bridge.sessionSet(sessionId, { ...data }); } catch {}
393
- }
394
-
395
- return {
396
- /** 세션 값 조회 */
397
- get: (key) => key ? (data[key] ?? null) : { ...data },
398
-
399
- /** 세션 값 설정 — Bridge sessionSet 연동 */
400
- set: (key, value) => {
401
- data[key] = value;
402
- // Core SessionProto 참조: bridge.sessionSet(id, { ...data })
403
- if (sessionId && bridge?.sessionSet) {
404
- try {
405
- bridge.sessionSet(sessionId, { ...data });
406
- } catch (e) {
407
- console.error('[Session] sessionSet failed:', e?.message || e);
408
- }
409
- }
410
- },
411
-
412
- /** 세션 삭제 — Bridge sessionDestroy 연동 */
413
- destroy: () => {
414
- Object.keys(data).forEach(k => delete data[k]);
415
- if (sessionId && bridge?.sessionDestroy) {
416
- try { bridge.sessionDestroy(sessionId); } catch {}
417
- }
418
- },
419
-
420
- /** 세션 갱신 — Bridge sessionRenew 연동 */
421
- renew: () => {
422
- if (sessionId && bridge?.sessionRenew) {
423
- try { return bridge.sessionRenew(sessionId); } catch {}
424
- }
425
- return null;
426
- },
427
-
428
- /** 플래시 메시지 설정 */
429
- flash: (key, value) => {
430
- if (!data._flash) data._flash = {};
431
- data._flash[key] = value;
432
- // 플래시도 세션에 영속화
433
- if (sessionId && bridge?.sessionSet) {
434
- try { bridge.sessionSet(sessionId, { ...data }); } catch {}
435
- }
436
- },
437
-
438
- /** 플래시 메시지 조회 (1회 후 삭제) */
439
- getFlash: (key) => {
440
- if (key) {
441
- const val = flash[key];
442
- delete flash[key];
443
- return val ?? null;
444
- }
445
- const all = { ...flash };
446
- Object.keys(flash).forEach(k => delete flash[k]);
447
- return all;
448
- },
449
- };
450
- }
451
-
452
- /** locale 감지 (5단계 우선순위) — 06-context.md */
453
- _detectLocale(rawReq) {
454
- if (rawReq.locale) return rawReq.locale;
455
- if (this.query?.lang) return this.query.lang;
456
- // 쿠키 fuzionx.lang
457
- const cookieLang = this.cookies?.['fuzionx.lang'];
458
- if (cookieLang) return cookieLang;
459
- if (this._rawSession?.locale) return this._rawSession.locale;
460
- const al = this.headers?.['accept-language'];
461
- if (al) {
462
- const first = al.split(',')[0]?.split('-')[0];
463
- if (first) return first;
464
- }
465
- return this.app?.config?.get('app.i18n.default_locale', 'ko') || 'ko';
466
- }
467
-
468
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
469
- // 응답 변환
470
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
471
-
472
- /** Bridge 응답 포맷 { status, body, headers } */
473
- toResponse() {
474
- // Set-Cookie 헤더 병합
475
- if (this._setCookies.length > 0) {
476
- this._headers['Set-Cookie'] = this._setCookies; // 배열 → Bridge 다중 헤더
477
- }
478
- return {
479
- status: this._statusCode,
480
- body: this._body,
481
- headers: this._headers,
482
- };
483
- }
484
- }
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
+ import { readFileSync } from 'node:fs';
15
+
16
+ export default class Context {
17
+ /**
18
+ * @param {object} rawReq - Bridge에서 전달된 raw 요청 객체
19
+ * @param {import('./Application.js').default} app
20
+ */
21
+ constructor(rawReq, app) {
22
+ // ── 요청 프로퍼티 ──
23
+ this.method = rawReq.method || 'GET';
24
+ this.url = rawReq.url || '/';
25
+ this.path = rawReq.path || this.url.split('?')[0];
26
+ this.query = rawReq.query || {};
27
+ this.params = rawReq.params || {};
28
+ this.headers = rawReq.headers || {};
29
+ this.body = rawReq.body || null;
30
+ this.ip = rawReq.remoteIp || '';
31
+ this.files = rawReq.files || null;
32
+ this.uploadError = rawReq.uploadError || null;
33
+ this.formFields = rawReq.formFields || null;
34
+ this.handlerId = rawReq.handlerId;
35
+
36
+ // 파생 속성 (06-context.md)
37
+ this.protocol = this.headers['x-forwarded-proto'] || 'http';
38
+ this.host = this.headers['host'] || '';
39
+
40
+ // ── 프레임워크 주입 ──
41
+ this.app = app;
42
+ this.user = null;
43
+ this._sessionId = rawReq.sessionId || null;
44
+ this._rawSession = rawReq.session || {};
45
+ // ── Cookie (parseCookies 먼저 — _detectLocale에서 참조) ──
46
+ this.cookies = this._parseCookies();
47
+
48
+ this.session = this._createSession(rawReq, app);
49
+ this.locale = this._detectLocale(rawReq);
50
+
51
+ // i18n — ctx.t() + ctx.t.all() (18-i18n.md)
52
+ this.t = this._createT();
53
+
54
+ // ── lazy 캐시 ──
55
+ this._json = undefined;
56
+ this._setCookies = []; // Set-Cookie 헤더 배열
57
+
58
+ // ── 응답 상태 ──
59
+ this._statusCode = 200;
60
+ this._headers = {};
61
+ this._body = '';
62
+ this._sent = false;
63
+ }
64
+
65
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
66
+ // Request 유틸
67
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
68
+
69
+ /** lazy JSON 파싱 */
70
+ get getJson() {
71
+ if (this._json !== undefined) return this._json;
72
+ if (typeof this.body === 'object' && this.body !== null) {
73
+ this._json = this.body;
74
+ } else if (typeof this.body === 'string' && this.body) {
75
+ try { this._json = JSON.parse(this.body); }
76
+ catch { this._json = null; }
77
+ } else {
78
+ this._json = null;
79
+ }
80
+ return this._json;
81
+ }
82
+
83
+ /** 헤더 값 가져오기 (case-insensitive) */
84
+ get(name) {
85
+ return this.headers[name.toLowerCase()];
86
+ }
87
+
88
+ /** Content-Type 확인 */
89
+ is(type) {
90
+ const ct = this.get('content-type') || '';
91
+ const map = {
92
+ json: 'application/json',
93
+ html: 'text/html',
94
+ form: 'application/x-www-form-urlencoded',
95
+ multipart: 'multipart/form-data',
96
+ };
97
+ return ct.includes(map[type] || type);
98
+ }
99
+
100
+ /**
101
+ * Accept 협상 (06-context.md)
102
+ * @param {...string} types - 'json', 'html', 'text'
103
+ * @returns {string|false} 매칭된 타입 또는 false
104
+ */
105
+ accepts(...types) {
106
+ const accept = this.get('accept') || '*/*';
107
+ const mimeMap = {
108
+ json: 'application/json',
109
+ html: 'text/html',
110
+ text: 'text/plain',
111
+ xml: 'application/xml',
112
+ };
113
+ for (const type of types) {
114
+ const mime = mimeMap[type] || type;
115
+ if (accept.includes(mime) || accept.includes('*/*')) return type;
116
+ }
117
+ return false;
118
+ }
119
+
120
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
121
+ // Cookie (06-context.md)
122
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
123
+
124
+ /**
125
+ * 쿠키 읽기/쓰기
126
+ * @overload cookie(name) → 읽기
127
+ * @overload cookie(name, value, opts?) → 쓰기
128
+ */
129
+ cookie(name, value, opts) {
130
+ if (arguments.length === 1) {
131
+ return this.cookies[name] ?? null;
132
+ }
133
+ // 쓰기 → Set-Cookie 헤더
134
+ const o = opts || {};
135
+ let str = `${name}=${encodeURIComponent(String(value))}`;
136
+ if (o.maxAge != null) str += `; Max-Age=${o.maxAge}`;
137
+ if (o.path) str += `; Path=${o.path}`;
138
+ else str += '; Path=/';
139
+ if (o.domain) str += `; Domain=${o.domain}`;
140
+ if (o.httpOnly !== false) str += '; HttpOnly';
141
+ if (o.secure) str += '; Secure';
142
+ if (o.sameSite) str += `; SameSite=${o.sameSite}`;
143
+ this._setCookies.push(str);
144
+ return this;
145
+ }
146
+
147
+ /** 쿠키 삭제 */
148
+ clearCookie(name, opts) {
149
+ return this.cookie(name, '', { ...opts, maxAge: 0 });
150
+ }
151
+
152
+ /** @private */
153
+ _parseCookies() {
154
+ const header = this.headers?.cookie || this.headers?.Cookie || '';
155
+ if (!header) return {};
156
+ const result = {};
157
+ for (const pair of header.split(';')) {
158
+ const [k, ...v] = pair.trim().split('=');
159
+ if (k) result[k.trim()] = decodeURIComponent(v.join('='));
160
+ }
161
+ return result;
162
+ }
163
+
164
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
165
+ // Response 메서드
166
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
167
+
168
+ status(code) {
169
+ this._statusCode = code;
170
+ return this;
171
+ }
172
+
173
+ json(data) {
174
+ this._body = JSON.stringify(data);
175
+ this._headers['Content-Type'] = 'application/json';
176
+ this._sent = true;
177
+ return this;
178
+ }
179
+
180
+ send(data) {
181
+ if (typeof data === 'object' && data !== null) {
182
+ return this.json(data);
183
+ }
184
+ this._body = String(data);
185
+ if (!this._headers['Content-Type']) {
186
+ this._headers['Content-Type'] = 'text/html; charset=utf-8';
187
+ }
188
+ this._sent = true;
189
+ return this;
190
+ }
191
+
192
+ html(content) {
193
+ return this.send(content);
194
+ }
195
+
196
+ text(content) {
197
+ this._body = String(content);
198
+ this._headers['Content-Type'] = 'text/plain; charset=utf-8';
199
+ this._sent = true;
200
+ return this;
201
+ }
202
+
203
+ redirect(url, code = 302) {
204
+ this._statusCode = code;
205
+ this._headers['Location'] = url;
206
+ this._body = '';
207
+ this._sent = true;
208
+ return this;
209
+ }
210
+
211
+ back() {
212
+ const referer = this.get('referer') || '/';
213
+ return this.redirect(referer);
214
+ }
215
+
216
+ end() {
217
+ this._body = '';
218
+ this._sent = true;
219
+ return this;
220
+ }
221
+
222
+ header(key, value) {
223
+ this._headers[key] = value;
224
+ return this;
225
+ }
226
+
227
+ /** setHeader alias (06-context.md) */
228
+ setHeader(key, value) {
229
+ return this.header(key, value);
230
+ }
231
+
232
+ error(statusCode, message) {
233
+ throw new AppError(message, statusCode);
234
+ }
235
+
236
+ /**
237
+ * 파일 다운로드 (06-context.md)
238
+ * Bridge는 문자열 body만 지원하므로 파일 내용을 직접 읽어 base64 전송.
239
+ * @param {string} filePath - 절대 경로
240
+ * @param {string} [filename]
241
+ */
242
+ download(filePath, filename) {
243
+ const name = filename || filePath.split('/').pop();
244
+ this._headers['Content-Disposition'] = `attachment; filename="${name}"`;
245
+ try {
246
+ const buf = readFileSync(filePath);
247
+ this._body = buf.toString('base64');
248
+ this._headers['Content-Transfer-Encoding'] = 'base64';
249
+ this._headers['Content-Type'] = this._headers['Content-Type'] || 'application/octet-stream';
250
+ } catch (err) {
251
+ this._statusCode = 404;
252
+ this._body = JSON.stringify({ error: `File not found: ${name}` });
253
+ this._headers['Content-Type'] = 'application/json';
254
+ }
255
+ this._sent = true;
256
+ return this;
257
+ }
258
+
259
+ /**
260
+ * 스트리밍 응답 (06-context.md)
261
+ * Bridge는 문자열 body만 지원하므로 Buffer/Stream을 문자열로 변환.
262
+ * Buffer → toString(), ReadableStream → 청크 수집.
263
+ * @param {ReadableStream|Buffer|string} readableStream
264
+ * @param {string} [contentType='application/octet-stream']
265
+ */
266
+ async stream(readableStream, contentType = 'application/octet-stream') {
267
+ this._headers['Content-Type'] = contentType;
268
+ if (Buffer.isBuffer(readableStream)) {
269
+ this._body = readableStream.toString();
270
+ } else if (typeof readableStream === 'string') {
271
+ this._body = readableStream;
272
+ } else if (readableStream && typeof readableStream[Symbol.asyncIterator] === 'function') {
273
+ // ReadableStream/AsyncIterable → 청크 수집
274
+ const chunks = [];
275
+ for await (const chunk of readableStream) {
276
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
277
+ }
278
+ this._body = Buffer.concat(chunks).toString();
279
+ } else {
280
+ this._body = String(readableStream || '');
281
+ }
282
+ this._sent = true;
283
+ return this;
284
+ }
285
+
286
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
287
+ // Validate
288
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
289
+
290
+ validate(schema) {
291
+ if (schema && typeof schema.validate === 'function') {
292
+ const { error, value } = schema.validate(this.body, { abortEarly: false, stripUnknown: true });
293
+ if (error) {
294
+ const fields = {};
295
+ for (const detail of error.details) {
296
+ fields[detail.path.join('.')] = detail.message;
297
+ }
298
+ throw new ValidationError('Validation failed', fields);
299
+ }
300
+ return value;
301
+ }
302
+ return this.body;
303
+ }
304
+
305
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
306
+ // i18n (Bridge 연동)
307
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
308
+
309
+ /** @private Setup i18n t() function with .all() support */
310
+ _createT() {
311
+ const self = this;
312
+ const t = (key, vars) => {
313
+ const locale = vars?.locale || self.locale;
314
+ if (self.app?.i18n) {
315
+ return self.app.i18n.translate(locale, key, vars);
316
+ }
317
+ return vars?.default || key;
318
+ };
319
+ // ctx.t.all() — 현재 locale 전체 번역 데이터 (18-i18n.md)
320
+ t.all = () => {
321
+ if (self.app?.i18n?.getAll) {
322
+ return self.app.i18n.getAll(self.locale);
323
+ }
324
+ return {};
325
+ };
326
+ return t;
327
+ }
328
+
329
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
330
+ // View 렌더링
331
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
332
+
333
+ render(view, data) {
334
+ // 글로벌 변수 주입 (03-views-templates.md)
335
+ const bridge = this.app?._bridge;
336
+ let aspVars = {};
337
+ if (bridge?.getAspConfig && bridge?.cryptoEncryptCustom) {
338
+ try {
339
+ const aspCfg = JSON.parse(bridge.getAspConfig());
340
+ const clientSecret = this.app?.config?.get('app.client_secret') || '';
341
+ if (clientSecret && aspCfg.masterSecret) {
342
+ aspVars = {
343
+ _fx_client_secret: clientSecret,
344
+ _fx_asp_secret: bridge.cryptoEncryptCustom(clientSecret, aspCfg.masterSecret),
345
+ _fx_asp_header: aspCfg.headerSignal || 'Ruxy-Enc-Mode',
346
+ };
347
+ }
348
+ } catch {}
349
+ }
350
+
351
+ const globals = {
352
+ session: this._rawSession,
353
+ auth: { user: this.user },
354
+ config: this.app?.config?._raw || {},
355
+ request: { url: this.url, method: this.method, path: this.path, ip: this.ip },
356
+ csrf_token: this._rawSession?._csrfToken || '',
357
+ flash: this.session?.getFlash() || {},
358
+ theme: this.theme || this.app?.config?.get('themes.default', 'default') || 'default',
359
+ locale: this.locale,
360
+ locales: this.app?.i18n?.locales?.() || [],
361
+ ...aspVars,
362
+ ...data,
363
+ };
364
+
365
+ // Bridge Tera SSR — 앱별 View 인스턴스 사용
366
+ const appEntry = this.app?._appRegistry?.get(this.appName);
367
+ const viewEngine = appEntry?.view;
368
+ if (!viewEngine) {
369
+ throw new Error(`View not initialized for app '${this.appName}' — Bridge not available`);
370
+ }
371
+
372
+ const html = viewEngine.render(view, globals);
373
+ return this.html(html);
374
+ }
375
+
376
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
377
+ // Session + Flash (Bridge sessionSet/Destroy/Renew)
378
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
379
+
380
+ /** @private */
381
+ _createSession(rawReq, app) {
382
+ const data = rawReq.session || {};
383
+ // Flash 추출 + 세션에서 삭제 (one-time read)
384
+ const flash = { ...(data._flash || {}) };
385
+ const hadFlash = !!data._flash;
386
+ delete data._flash;
387
+ const sessionId = rawReq.sessionId || null;
388
+ const bridge = app?._bridge || null;
389
+
390
+ // Flash 소비 후 세션 저장소에 즉시 반영
391
+ if (hadFlash && sessionId && bridge?.sessionSet) {
392
+ try { bridge.sessionSet(sessionId, { ...data }); } catch {}
393
+ }
394
+
395
+ return {
396
+ /** 세션 값 조회 */
397
+ get: (key) => key ? (data[key] ?? null) : { ...data },
398
+
399
+ /** 세션 값 설정 — Bridge sessionSet 연동 */
400
+ set: (key, value) => {
401
+ data[key] = value;
402
+ // Core SessionProto 참조: bridge.sessionSet(id, { ...data })
403
+ if (sessionId && bridge?.sessionSet) {
404
+ try {
405
+ bridge.sessionSet(sessionId, { ...data });
406
+ } catch (e) {
407
+ console.error('[Session] sessionSet failed:', e?.message || e);
408
+ }
409
+ }
410
+ },
411
+
412
+ /** 세션 삭제 — Bridge sessionDestroy 연동 */
413
+ destroy: () => {
414
+ Object.keys(data).forEach(k => delete data[k]);
415
+ if (sessionId && bridge?.sessionDestroy) {
416
+ try { bridge.sessionDestroy(sessionId); } catch {}
417
+ }
418
+ },
419
+
420
+ /** 세션 갱신 — Bridge sessionRenew 연동 */
421
+ renew: () => {
422
+ if (sessionId && bridge?.sessionRenew) {
423
+ try { return bridge.sessionRenew(sessionId); } catch {}
424
+ }
425
+ return null;
426
+ },
427
+
428
+ /** 플래시 메시지 설정 */
429
+ flash: (key, value) => {
430
+ if (!data._flash) data._flash = {};
431
+ data._flash[key] = value;
432
+ // 플래시도 세션에 영속화
433
+ if (sessionId && bridge?.sessionSet) {
434
+ try { bridge.sessionSet(sessionId, { ...data }); } catch {}
435
+ }
436
+ },
437
+
438
+ /** 플래시 메시지 조회 (1회 후 삭제) */
439
+ getFlash: (key) => {
440
+ if (key) {
441
+ const val = flash[key];
442
+ delete flash[key];
443
+ return val ?? null;
444
+ }
445
+ const all = { ...flash };
446
+ Object.keys(flash).forEach(k => delete flash[k]);
447
+ return all;
448
+ },
449
+ };
450
+ }
451
+
452
+ /** locale 감지 (5단계 우선순위) — 06-context.md */
453
+ _detectLocale(rawReq) {
454
+ if (rawReq.locale) return rawReq.locale;
455
+ if (this.query?.lang) return this.query.lang;
456
+ // 쿠키 fuzionx.lang
457
+ const cookieLang = this.cookies?.['fuzionx.lang'];
458
+ if (cookieLang) return cookieLang;
459
+ if (this._rawSession?.locale) return this._rawSession.locale;
460
+ const al = this.headers?.['accept-language'];
461
+ if (al) {
462
+ const first = al.split(',')[0]?.split('-')[0];
463
+ if (first) return first;
464
+ }
465
+ return this.app?.config?.get('app.i18n.default_locale', 'ko') || 'ko';
466
+ }
467
+
468
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
469
+ // 응답 변환
470
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
471
+
472
+ /** Bridge 응답 포맷 { status, body, headers } */
473
+ toResponse() {
474
+ // Set-Cookie 헤더 병합
475
+ if (this._setCookies.length > 0) {
476
+ this._headers['Set-Cookie'] = this._setCookies; // 배열 → Bridge 다중 헤더
477
+ }
478
+ return {
479
+ status: this._statusCode,
480
+ body: this._body,
481
+ headers: this._headers,
482
+ };
483
+ }
484
+ }