@fuzionx/framework 0.1.38 → 0.1.41

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