@fuzionx/framework 0.1.45 → 0.1.46

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.
@@ -1,72 +1,29 @@
1
1
  /**
2
- * Context — 요청/응답 통합 API
2
+ * Context — 요청/응답 통합 API (고성능 버전)
3
3
  *
4
4
  * Bridge rawReq → Context 변환.
5
5
  * 미들웨어/핸들러에 전달되는 단일 객체.
6
6
  *
7
- * Session은 Bridge sessionSet/Destroy/Renew N-API에 위임.
8
- * Cookie는 rawReq.headers.cookie 파싱 + Set-Cookie 헤더 생성.
7
+ * 최적화:
8
+ * - 프로토타입 기반 메서드 공유 (프로토타입에 메서드 정의 → 함수 생성 0)
9
+ * - Lazy 프로퍼티: session, cookies, locale, t() 는 접근 시에만 생성
10
+ * - 공유 인스턴스(sync용) + Object.create(async용) 패턴
11
+ * - _toFusionResponse()에서 공유 응답 객체 재사용
9
12
  *
10
13
  * @see docs/framework/06-context.md
11
- * @see packages/fuzionx/lib/context.js (Core SessionProto 참조)
12
14
  */
13
15
  import AppError, { ValidationError } from '../core/AppError.js';
14
16
  import { readFileSync } from 'node:fs';
15
17
 
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
- }
18
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
19
+ // Context 프로토타입 — 모든 메서드를 여기에 정의
20
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
64
21
 
65
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
66
- // Request 유틸
67
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
22
+ const ContextProto = {
68
23
 
69
- /** lazy JSON 파싱 */
24
+ // ── Request 유틸 ──
25
+
26
+ /** lazy JSON 파싱 — body가 이미 object면 그대로 반환 */
70
27
  get getJson() {
71
28
  if (this._json !== undefined) return this._json;
72
29
  if (typeof this.body === 'object' && this.body !== null) {
@@ -78,14 +35,22 @@ export default class Context {
78
35
  this._json = null;
79
36
  }
80
37
  return this._json;
81
- }
38
+ },
82
39
 
83
- /** 헤더 값 가져오기 (case-insensitive) */
40
+ /**
41
+ * 헤더 값 가져오기 (case-insensitive)
42
+ * @param {string} name - 헤더 이름
43
+ * @returns {string|undefined}
44
+ */
84
45
  get(name) {
85
46
  return this.headers[name.toLowerCase()];
86
- }
47
+ },
87
48
 
88
- /** Content-Type 확인 */
49
+ /**
50
+ * Content-Type 확인
51
+ * @param {string} type - 'json', 'html', 'form', 'multipart'
52
+ * @returns {boolean}
53
+ */
89
54
  is(type) {
90
55
  const ct = this.get('content-type') || '';
91
56
  const map = {
@@ -95,7 +60,7 @@ export default class Context {
95
60
  multipart: 'multipart/form-data',
96
61
  };
97
62
  return ct.includes(map[type] || type);
98
- }
63
+ },
99
64
 
100
65
  /**
101
66
  * Accept 협상 (06-context.md)
@@ -115,16 +80,66 @@ export default class Context {
115
80
  if (accept.includes(mime) || accept.includes('*/*')) return type;
116
81
  }
117
82
  return false;
118
- }
83
+ },
84
+
85
+ // ── Lazy 프로퍼티: cookies ──
86
+
87
+ /** lazy 쿠키 파싱 — 최초 접근 시에만 파싱 */
88
+ get cookies() {
89
+ if (this._cookies !== undefined) return this._cookies;
90
+ this._cookies = _parseCookies(this.headers);
91
+ return this._cookies;
92
+ },
119
93
 
120
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
121
- // Cookie (06-context.md)
122
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
94
+ // ── Lazy 프로퍼티: session ──
95
+
96
+ /** lazy 세션 객체 — 최초 접근 시에만 생성 */
97
+ get session() {
98
+ if (this._session !== undefined) return this._session;
99
+ this._session = _createSession(this._rawReq, this.app);
100
+ return this._session;
101
+ },
102
+
103
+ // ── Lazy 프로퍼티: locale ──
104
+
105
+ /** lazy locale 감지 — 최초 접근 시에만 계산 */
106
+ get locale() {
107
+ if (this._locale !== undefined) return this._locale;
108
+ this._locale = _detectLocale(this);
109
+ return this._locale;
110
+ },
111
+
112
+ // ── Lazy 프로퍼티: t (i18n) ──
113
+
114
+ /** lazy i18n 함수 — 최초 접근 시에만 생성 */
115
+ get t() {
116
+ if (this._t !== undefined) return this._t;
117
+ this._t = _createT(this);
118
+ return this._t;
119
+ },
120
+
121
+ // ── Lazy 프로퍼티: protocol, host ──
122
+
123
+ /** 프로토콜 (x-forwarded-proto 우선) */
124
+ get protocol() {
125
+ return this.headers['x-forwarded-proto'] || 'http';
126
+ },
127
+
128
+ /** 호스트 헤더 */
129
+ get host() {
130
+ return this.headers['host'] || '';
131
+ },
132
+
133
+ // ── Cookie 읽기/쓰기 ──
123
134
 
124
135
  /**
125
136
  * 쿠키 읽기/쓰기
126
- * @overload cookie(name) → 읽기
127
- * @overload cookie(name, value, opts?) → 쓰기
137
+ * @overload cookie(name) → 값 반환
138
+ * @overload cookie(name, value, opts?) → Set-Cookie 추가
139
+ * @param {string} name - 쿠키 이름
140
+ * @param {string} [value] - 쿠키 값
141
+ * @param {object} [opts] - 쿠키 옵션
142
+ * @returns {string|null|this}
128
143
  */
129
144
  cookie(name, value, opts) {
130
145
  if (arguments.length === 1) {
@@ -142,41 +157,46 @@ export default class Context {
142
157
  if (o.sameSite) str += `; SameSite=${o.sameSite}`;
143
158
  this._setCookies.push(str);
144
159
  return this;
145
- }
160
+ },
146
161
 
147
- /** 쿠키 삭제 */
162
+ /**
163
+ * 쿠키 삭제
164
+ * @param {string} name - 쿠키 이름
165
+ * @param {object} [opts] - 쿠키 옵션
166
+ */
148
167
  clearCookie(name, opts) {
149
168
  return this.cookie(name, '', { ...opts, maxAge: 0 });
150
- }
169
+ },
151
170
 
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
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
171
+ // ── Response 메서드 ──
167
172
 
173
+ /**
174
+ * 상태 코드 설정
175
+ * @param {number} code - HTTP 상태 코드
176
+ * @returns {this}
177
+ */
168
178
  status(code) {
169
179
  this._statusCode = code;
170
180
  return this;
171
- }
181
+ },
172
182
 
183
+ /**
184
+ * JSON 응답
185
+ * @param {object} data - 응답 데이터
186
+ * @returns {this}
187
+ */
173
188
  json(data) {
174
189
  this._body = JSON.stringify(data);
175
190
  this._headers['Content-Type'] = 'application/json';
176
191
  this._sent = true;
177
192
  return this;
178
- }
193
+ },
179
194
 
195
+ /**
196
+ * 텍스트/HTML 응답
197
+ * @param {string|object} data - 응답 데이터
198
+ * @returns {this}
199
+ */
180
200
  send(data) {
181
201
  if (typeof data === 'object' && data !== null) {
182
202
  return this.json(data);
@@ -187,57 +207,92 @@ export default class Context {
187
207
  }
188
208
  this._sent = true;
189
209
  return this;
190
- }
210
+ },
191
211
 
212
+ /**
213
+ * HTML 응답 (send 위임)
214
+ * @param {string} content - HTML 문자열
215
+ * @returns {this}
216
+ */
192
217
  html(content) {
218
+ this._headers['Content-Type'] = 'text/html; charset=utf-8';
193
219
  return this.send(content);
194
- }
220
+ },
195
221
 
222
+ /**
223
+ * 텍스트 응답
224
+ * @param {string} content - 텍스트 문자열
225
+ * @returns {this}
226
+ */
196
227
  text(content) {
197
228
  this._body = String(content);
198
229
  this._headers['Content-Type'] = 'text/plain; charset=utf-8';
199
230
  this._sent = true;
200
231
  return this;
201
- }
232
+ },
202
233
 
234
+ /**
235
+ * 리다이렉트 응답
236
+ * @param {string} url - 리다이렉트 URL
237
+ * @param {number} [code=302] - 상태 코드
238
+ * @returns {this}
239
+ */
203
240
  redirect(url, code = 302) {
204
241
  this._statusCode = code;
205
242
  this._headers['Location'] = url;
206
243
  this._body = '';
207
244
  this._sent = true;
208
245
  return this;
209
- }
246
+ },
210
247
 
248
+ /** Referer 기반 리다이렉트 */
211
249
  back() {
212
250
  const referer = this.get('referer') || '/';
213
251
  return this.redirect(referer);
214
- }
252
+ },
215
253
 
254
+ /** 빈 응답으로 종료 */
216
255
  end() {
217
256
  this._body = '';
218
257
  this._sent = true;
219
258
  return this;
220
- }
259
+ },
221
260
 
261
+ /**
262
+ * 응답 헤더 설정
263
+ * @param {string} key - 헤더 키
264
+ * @param {string} value - 헤더 값
265
+ * @returns {this}
266
+ */
222
267
  header(key, value) {
223
268
  this._headers[key] = value;
224
269
  return this;
225
- }
270
+ },
226
271
 
227
- /** setHeader alias (06-context.md) */
272
+ /**
273
+ * setHeader alias (06-context.md)
274
+ * @param {string} key - 헤더 키
275
+ * @param {string} value - 헤더 값
276
+ * @returns {this}
277
+ */
228
278
  setHeader(key, value) {
229
279
  return this.header(key, value);
230
- }
280
+ },
231
281
 
282
+ /**
283
+ * 에러 throw (AppError)
284
+ * @param {number} statusCode - HTTP 상태 코드
285
+ * @param {string} message - 에러 메시지
286
+ */
232
287
  error(statusCode, message) {
233
288
  throw new AppError(message, statusCode);
234
- }
289
+ },
235
290
 
236
291
  /**
237
292
  * 파일 다운로드 (06-context.md)
238
- * Bridge는 문자열 body만 지원하므로 파일 내용을 직접 읽어 base64 전송.
239
293
  * @param {string} filePath - 절대 경로
240
- * @param {string} [filename]
294
+ * @param {string} [filename] - 파일명
295
+ * @returns {this}
241
296
  */
242
297
  download(filePath, filename) {
243
298
  const name = filename || filePath.split('/').pop();
@@ -254,14 +309,13 @@ export default class Context {
254
309
  }
255
310
  this._sent = true;
256
311
  return this;
257
- }
312
+ },
258
313
 
259
314
  /**
260
315
  * 스트리밍 응답 (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']
316
+ * @param {ReadableStream|Buffer|string} readableStream - 스트림 데이터
317
+ * @param {string} [contentType='application/octet-stream'] - Content-Type
318
+ * @returns {Promise<this>}
265
319
  */
266
320
  async stream(readableStream, contentType = 'application/octet-stream') {
267
321
  this._headers['Content-Type'] = contentType;
@@ -281,12 +335,15 @@ export default class Context {
281
335
  }
282
336
  this._sent = true;
283
337
  return this;
284
- }
338
+ },
285
339
 
286
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
287
- // Validate
288
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
340
+ // ── Validate ──
289
341
 
342
+ /**
343
+ * Joi 스키마 검증
344
+ * @param {object} schema - Joi 스키마 인스턴스
345
+ * @returns {object} 검증된 데이터
346
+ */
290
347
  validate(schema) {
291
348
  if (schema && typeof schema.validate === 'function') {
292
349
  const { error, value } = schema.validate(this.body, { abortEarly: false, stripUnknown: true });
@@ -300,36 +357,16 @@ export default class Context {
300
357
  return value;
301
358
  }
302
359
  return this.body;
303
- }
360
+ },
304
361
 
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
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
362
+ // ── View 렌더링 ──
332
363
 
364
+ /**
365
+ * 템플릿 렌더링 (Bridge Tera SSR)
366
+ * @param {string} view - 뷰 이름
367
+ * @param {object} [data] - 템플릿 변수
368
+ * @returns {this}
369
+ */
333
370
  render(view, data) {
334
371
  // 글로벌 변수 주입 (03-views-templates.md)
335
372
  const bridge = this.app?._bridge;
@@ -349,11 +386,11 @@ export default class Context {
349
386
  }
350
387
 
351
388
  const globals = {
352
- session: this._rawSession,
389
+ session: this._rawReq?.session || {},
353
390
  auth: { user: this.user },
354
391
  config: this.app?.config?._raw || {},
355
392
  request: { url: this.url, method: this.method, path: this.path, ip: this.ip },
356
- csrf_token: this._rawSession?._csrfToken || '',
393
+ csrf_token: (this._rawReq?.session)?._csrfToken || '',
357
394
  flash: this.session?.getFlash() || {},
358
395
  theme: this.theme || this.app?.config?.get('themes.default', 'default') || 'default',
359
396
  locale: this.locale,
@@ -371,114 +408,381 @@ export default class Context {
371
408
 
372
409
  const html = viewEngine.render(view, globals);
373
410
  return this.html(html);
374
- }
411
+ },
412
+
413
+ // ── 응답 변환 ──
375
414
 
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 {}
415
+ /**
416
+ * Bridge 응답 포맷 { status, body, headers }
417
+ * sync 경로: 공유 응답 객체(_sharedFusionResponse)를 재사용하여 할당 0
418
+ * async 경로: 새 객체 생성
419
+ * @returns {{ status: number, body: string, headers: object }}
420
+ */
421
+ _toFusionResponse() {
422
+ // Set-Cookie 헤더 병합
423
+ if (this._setCookies.length > 0) {
424
+ this._headers['Set-Cookie'] = this._setCookies;
425
+ }
426
+ // async(비공유) 인스턴스면 객체 반환 → race condition 방지
427
+ if (this !== _sharedCtx) {
428
+ return { status: this._statusCode, body: this._body, headers: this._headers };
393
429
  }
430
+ // sync(공유) 인스턴스 → 사전 할당된 응답 객체 재사용
431
+ _sharedFusionResponse.status = this._statusCode;
432
+ _sharedFusionResponse.body = this._body;
433
+ _sharedFusionResponse.headers = this._headers;
434
+ return _sharedFusionResponse;
435
+ },
436
+ };
437
+
438
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
439
+ // Private 헬퍼 함수 (프로토타입 외부)
440
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
394
441
 
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
- },
442
+ /**
443
+ * 쿠키 헤더 파싱 — 공통 함수
444
+ * @param {object} headers - 요청 헤더
445
+ * @returns {object} 파싱된 쿠키 맵
446
+ */
447
+ function _parseCookies(headers) {
448
+ const header = headers?.cookie || headers?.Cookie || '';
449
+ if (!header) return {};
450
+ const result = {};
451
+ for (const pair of header.split(';')) {
452
+ const [k, ...v] = pair.trim().split('=');
453
+ if (k) result[k.trim()] = decodeURIComponent(v.join('='));
454
+ }
455
+ return result;
456
+ }
411
457
 
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
- },
458
+ /**
459
+ * 세션 프록시 객체 생성 — lazy 시점에만 호출
460
+ * @param {object} rawReq - 원시 요청 객체
461
+ * @param {import('./Application.js').default} app - Application 인스턴스
462
+ * @returns {object} 세션 프록시 (get/set/destroy/renew/flash/getFlash)
463
+ */
464
+ function _createSession(rawReq, app) {
465
+ // Core req의 session은 SessionProto 인스턴스 → _data 프로퍼티 추출
466
+ const rawSession = rawReq?.session;
467
+ const data = (rawSession?._data !== undefined) ? rawSession._data : (rawSession || {});
468
+ // Flash 추출 + 세션에서 삭제 (one-time read)
469
+ const flash = { ...(data._flash || {}) };
470
+ const hadFlash = !!data._flash;
471
+ delete data._flash;
472
+ const sessionId = rawReq?.sessionId || null;
473
+ const bridge = app?._bridge || null;
474
+
475
+ // Flash 소비 후 세션 저장소에 즉시 반영
476
+ if (hadFlash && sessionId && bridge?.sessionSet) {
477
+ try { bridge.sessionSet(sessionId, { ...data }); } catch {}
478
+ }
419
479
 
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
- };
480
+ return {
481
+ /** 세션 조회 */
482
+ get: (key) => key ? (data[key] ?? null) : { ...data },
483
+
484
+ /** 세션 값 설정 — Bridge sessionSet 연동 */
485
+ set: (key, value) => {
486
+ data[key] = value;
487
+ if (sessionId && bridge?.sessionSet) {
488
+ try { bridge.sessionSet(sessionId, { ...data }); }
489
+ catch (e) { console.error('[Session] sessionSet failed:', e?.message || e); }
490
+ }
491
+ },
492
+
493
+ /** 세션 삭제 Bridge sessionDestroy 연동 */
494
+ destroy: () => {
495
+ Object.keys(data).forEach(k => delete data[k]);
496
+ if (sessionId && bridge?.sessionDestroy) {
497
+ try { bridge.sessionDestroy(sessionId); } catch {}
498
+ }
499
+ },
500
+
501
+ /** 세션 갱신 — Bridge sessionRenew 연동 */
502
+ renew: () => {
503
+ if (sessionId && bridge?.sessionRenew) {
504
+ try { return bridge.sessionRenew(sessionId); } catch {}
505
+ }
506
+ return null;
507
+ },
508
+
509
+ /** 플래시 메시지 설정 */
510
+ flash: (key, value) => {
511
+ if (!data._flash) data._flash = {};
512
+ data._flash[key] = value;
513
+ if (sessionId && bridge?.sessionSet) {
514
+ try { bridge.sessionSet(sessionId, { ...data }); } catch {}
515
+ }
516
+ },
517
+
518
+ /** 플래시 메시지 조회 (1회 후 삭제) */
519
+ getFlash: (key) => {
520
+ if (key) {
521
+ const val = flash[key];
522
+ delete flash[key];
523
+ return val ?? null;
524
+ }
525
+ const all = { ...flash };
526
+ Object.keys(flash).forEach(k => delete flash[k]);
527
+ return all;
528
+ },
529
+ };
530
+ }
531
+
532
+ /**
533
+ * locale 감지 (5단계 우선순위) — 06-context.md
534
+ * @param {object} ctx - Context 인스턴스
535
+ * @returns {string} 감지된 locale
536
+ */
537
+ function _detectLocale(ctx) {
538
+ if (ctx._rawReq?.locale) return ctx._rawReq.locale;
539
+ if (ctx.query?.lang) return ctx.query.lang;
540
+ // 쿠키 fuzionx.lang (lazy cookie 접근)
541
+ const cookieLang = ctx.cookies?.['fuzionx.lang'];
542
+ if (cookieLang) return cookieLang;
543
+ if (ctx._rawReq?.session?.locale) return ctx._rawReq.session.locale;
544
+ const al = ctx.headers?.['accept-language'];
545
+ if (al) {
546
+ const first = al.split(',')[0]?.split('-')[0];
547
+ if (first) return first;
450
548
  }
549
+ return ctx.app?.config?.get('app.i18n.default_locale', 'ko') || 'ko';
550
+ }
451
551
 
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;
552
+ /**
553
+ * i18n 번역 함수 생성 — lazy 시점에만 호출
554
+ * @param {object} ctx - Context 인스턴스
555
+ * @returns {Function} t(key, vars) + t.all()
556
+ */
557
+ function _createT(ctx) {
558
+ const t = (key, vars) => {
559
+ const locale = vars?.locale || ctx.locale;
560
+ if (ctx.app?.i18n) {
561
+ return ctx.app.i18n.translate(locale, key, vars);
464
562
  }
465
- return this.app?.config?.get('app.i18n.default_locale', 'ko') || 'ko';
466
- }
563
+ return vars?.default || key;
564
+ };
565
+ // ctx.t.all() — 현재 locale 전체 번역 데이터 (18-i18n.md)
566
+ t.all = () => {
567
+ if (ctx.app?.i18n?.getAll) {
568
+ return ctx.app.i18n.getAll(ctx.locale);
569
+ }
570
+ return {};
571
+ };
572
+ return t;
573
+ }
467
574
 
468
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
469
- // 응답 변환
470
- // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
575
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
576
+ // 공유 인스턴스 (sync 경로용 — 할당 0)
577
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
578
+
579
+ /** 사전 할당된 응답 객체 (sync 경로에서 재사용) */
580
+ const _sharedFusionResponse = { status: 200, body: '', headers: null };
581
+
582
+ /** 사전 할당된 응답 헤더 객체 (sync 경로에서 재사용) */
583
+ const _sharedRespHeaders = {};
584
+
585
+ /** 사전 할당된 Set-Cookie 배열 (sync 경로에서 재사용) */
586
+ const _sharedSetCookies = [];
587
+
588
+ /** 사전 할당된 Context 싱글톤 (sync 경로 전용 재사용) */
589
+ const _sharedCtx = Object.create(ContextProto);
590
+ _sharedCtx.method = '';
591
+ _sharedCtx.url = '';
592
+ _sharedCtx.path = '';
593
+ _sharedCtx.query = null;
594
+ _sharedCtx.params = null;
595
+ _sharedCtx.headers = null;
596
+ _sharedCtx.body = null;
597
+ _sharedCtx.ip = '';
598
+ _sharedCtx.files = null;
599
+ _sharedCtx.uploadError = null;
600
+ _sharedCtx.formFields = null;
601
+ _sharedCtx.handlerId = 0;
602
+ _sharedCtx.user = null;
603
+ _sharedCtx.app = null;
604
+ _sharedCtx.appName = null;
605
+ _sharedCtx.theme = null;
606
+ _sharedCtx._sessionId = null;
607
+ _sharedCtx._rawReq = null;
608
+ _sharedCtx._json = undefined;
609
+ _sharedCtx._cookies = undefined;
610
+ _sharedCtx._session = undefined;
611
+ _sharedCtx._locale = undefined;
612
+ _sharedCtx._t = undefined;
613
+ _sharedCtx._setCookies = _sharedSetCookies;
614
+ _sharedCtx._statusCode = 200;
615
+ _sharedCtx._headers = _sharedRespHeaders;
616
+ _sharedCtx._body = '';
617
+ _sharedCtx._sent = false;
618
+ _sharedCtx._res = null; // Core res 직접 참조용 (createContextFromReq에서 설정)
619
+
620
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
621
+ // Context 팩토리 함수
622
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
471
623
 
472
- /** Bridge 응답 포맷 { status, body, headers } */
473
- toResponse() {
474
- // Set-Cookie 헤더 병합
475
- if (this._setCookies.length > 0) {
476
- this._headers['Set-Cookie'] = this._setCookies; // 배열 Bridge 다중 헤더
624
+ /**
625
+ * Context 인스턴스 생성 — 팩토리 패턴
626
+ *
627
+ * @param {object} rawReq - Bridge에서 전달된 raw 요청 객체
628
+ * @param {import('./Application.js').default} app - Application 인스턴스
629
+ * @param {boolean} [reusable=true] - true면 공유 인스턴스 재사용 (sync용), false면 새로 생성 (async용)
630
+ * @returns {object} Context 인스턴스
631
+ */
632
+ export function createContext(rawReq, app, reusable = true) {
633
+ let ctx;
634
+ if (reusable) {
635
+ // sync 핸들러: 공유 인스턴스 리셋 후 재사용 (할당 0)
636
+ ctx = _sharedCtx;
637
+ // 이전 헤더 키 제거 (무조건 리셋)
638
+ const keys = Object.keys(_sharedRespHeaders);
639
+ for (let i = 0; i < keys.length; i++) {
640
+ delete _sharedRespHeaders[keys[i]];
477
641
  }
478
- return {
479
- status: this._statusCode,
480
- body: this._body,
481
- headers: this._headers,
482
- };
642
+ ctx._headers = _sharedRespHeaders;
643
+ // Set-Cookie 배열 리셋
644
+ _sharedSetCookies.length = 0;
645
+ ctx._setCookies = _sharedSetCookies;
646
+ } else {
647
+ // async 핸들러: 새 인스턴스 (응답 전까지 유지 필요)
648
+ ctx = Object.create(ContextProto);
649
+ ctx._setCookies = [];
650
+ ctx._headers = {};
651
+ }
652
+
653
+ // ── 요청 프로퍼티 (핫 경로 — 즉시 할당) ──
654
+ ctx.method = rawReq.method || 'GET';
655
+ ctx.url = rawReq.url || '/';
656
+ ctx.path = rawReq.path || ctx.url.split('?')[0];
657
+ ctx.query = rawReq.query || {};
658
+ ctx.params = rawReq.params || {};
659
+ ctx.headers = rawReq.headers || {};
660
+ ctx.body = rawReq.body || null;
661
+ ctx.ip = rawReq.remoteIp || '';
662
+ ctx.files = rawReq.files || null;
663
+ ctx.uploadError = rawReq.uploadError || null;
664
+ ctx.formFields = rawReq.formFields || null;
665
+ ctx.handlerId = rawReq.handlerId;
666
+
667
+ // ── 프레임워크 주입 ──
668
+ ctx.app = app;
669
+ ctx.user = null;
670
+ ctx._sessionId = rawReq.sessionId || null;
671
+ ctx._rawReq = rawReq;
672
+
673
+ // ── lazy 캐시 초기화 (접근 시에만 생성) ──
674
+ ctx._json = undefined;
675
+ ctx._cookies = undefined; // get cookies() 에서 lazy 파싱
676
+ ctx._session = undefined; // get session() 에서 lazy 생성
677
+ ctx._locale = undefined; // get locale() 에서 lazy 감지
678
+ ctx._t = undefined; // get t() 에서 lazy 생성
679
+
680
+ // ── 응답 상태 리셋 ──
681
+ ctx._statusCode = 200;
682
+ ctx._body = '';
683
+ ctx._sent = false;
684
+
685
+ // ── 앱/테마 (디스패칭 후 설정) ──
686
+ ctx.appName = null;
687
+ ctx.theme = null;
688
+
689
+ return ctx;
690
+ }
691
+
692
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
693
+ // Core req/res 직접 참조 팩토리 (최적화: 중간 객체 제거)
694
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
695
+
696
+ /**
697
+ * Core req/res에서 직접 Context 생성 — 중간 객체 리터럴 할당 제거
698
+ *
699
+ * ⚡ 최적화 핵심:
700
+ * - Core req 프로퍼티를 직접 참조 (14-프로퍼티 중간 객체 힙 할당 제거)
701
+ * - sync 경로: ctx._headers = res._headers (헤더 객체 공유 → _copyToRes 불필요)
702
+ * - async 경로: 독립 인스턴스 (기존 createContext와 동일)
703
+ *
704
+ * @param {object} req - Core의 req 객체 (createReq로 생성된)
705
+ * @param {object|null} res - Core의 res 객체 (sync: 직접 참조, async: null)
706
+ * @param {import('./Application.js').default} app - Application 인스턴스
707
+ * @param {boolean} [reusable=true] - true면 공유 인스턴스 (sync용)
708
+ * @returns {object} Context 인스턴스
709
+ */
710
+ export function createContextFromReq(req, res, app, reusable = true) {
711
+ let ctx;
712
+ if (reusable && res) {
713
+ // sync 핸들러: 공유 인스턴스 + Core res 헤더 공유 (할당 0)
714
+ ctx = _sharedCtx;
715
+ // Core res의 헤더 객체를 직접 공유 → 핸들러가 ctx._headers에 쓰면 res._headers에 반영
716
+ ctx._headers = res._headers;
717
+ // Set-Cookie 배열 리셋
718
+ _sharedSetCookies.length = 0;
719
+ ctx._setCookies = _sharedSetCookies;
720
+ ctx._res = res; // Core res 참조 저장
721
+ } else {
722
+ // async 핸들러: 새 인스턴스 (응답 전까지 유지 필요)
723
+ ctx = Object.create(ContextProto);
724
+ ctx._setCookies = [];
725
+ ctx._headers = {};
726
+ ctx._res = null;
727
+ }
728
+
729
+ // ── Core req 프로퍼티 직접 참조 (중간 객체 없이) ──
730
+ ctx.method = req.method || 'GET';
731
+ ctx.url = req.url || '/';
732
+ ctx.path = req.path || ctx.url.split('?')[0];
733
+ ctx.query = req.query || {};
734
+ ctx.params = req.params || {};
735
+ ctx.headers = req.headers || {};
736
+ ctx.body = req.body || req.json || null; // Core req는 body 또는 lazy json getter
737
+ ctx.ip = req.ip || ''; // Core req는 ip (remoteIp 아님)
738
+ ctx.files = req.files || null;
739
+ ctx.uploadError = null;
740
+ ctx.formFields = req.formFields || null;
741
+ ctx.handlerId = req.handlerId;
742
+
743
+ // ── 프레임워크 주입 ──
744
+ ctx.app = app;
745
+ ctx.user = null;
746
+ ctx._sessionId = req.sessionId || null;
747
+ ctx._rawReq = req; // Core의 req 직접 참조
748
+
749
+ // ── lazy 캐시 초기화 (접근 시에만 생성) ──
750
+ ctx._json = undefined;
751
+ ctx._cookies = undefined;
752
+ ctx._session = undefined;
753
+ ctx._locale = undefined;
754
+ ctx._t = undefined;
755
+
756
+ // ── 응답 상태 리셋 ──
757
+ ctx._statusCode = 200;
758
+ ctx._body = '';
759
+ ctx._sent = false;
760
+
761
+ // ── 앱/테마 (디스패칭 후 설정) ──
762
+ ctx.appName = null;
763
+ ctx.theme = null;
764
+
765
+ return ctx;
766
+ }
767
+
768
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
769
+ // 하위 호환: default export = Context 클래스
770
+ // 기존 코드에서 `new Context(rawReq, app)` 사용 시 호환
771
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
772
+
773
+ /**
774
+ * Context 래거시 호환 클래스
775
+ * 기존 코드에서 `new Context(rawReq, app)` 형태로 사용 시 호환성 유지를 위해 제공.
776
+ * 내부에서 createContext(rawReq, app, false)를 호출하여 async-safe 인스턴스를 반환.
777
+ */
778
+ export default class Context {
779
+ /**
780
+ * @param {object} rawReq - Bridge에서 전달된 raw 요청 객체
781
+ * @param {import('./Application.js').default} app - Application 인스턴스
782
+ */
783
+ constructor(rawReq, app) {
784
+ // 래거시 호환: new Context() → createContext(false) 위임
785
+ // 새 인스턴스(async-safe)를 생성하여 프록시 반환
786
+ return createContext(rawReq, app, false);
483
787
  }
484
788
  }