mega-framework 0.1.7 → 0.1.8

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 (76) hide show
  1. package/README.md +9 -0
  2. package/package.json +3 -3
  3. package/sample/crud/.env +9 -0
  4. package/sample/crud/.env.example +9 -0
  5. package/sample/crud/apps/main/locales/server/en.json +12 -1
  6. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  7. package/sample/crud/mega.config.js +7 -0
  8. package/sample/crud/package.json +2 -2
  9. package/sample/crud/scripts/start-ws-hub.sh +18 -4
  10. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/src/adapters/adapter-options.js +14 -3
  12. package/src/adapters/file-adapter.js +9 -5
  13. package/src/adapters/file-session-adapter.js +4 -3
  14. package/src/adapters/maria-adapter.js +7 -4
  15. package/src/adapters/mega-cache-adapter.js +83 -6
  16. package/src/adapters/mega-db-adapter.js +4 -1
  17. package/src/adapters/mongo-adapter.js +21 -7
  18. package/src/adapters/postgres-adapter.js +8 -4
  19. package/src/adapters/redis-adapter.js +7 -3
  20. package/src/adapters/sqlite-adapter.js +6 -2
  21. package/src/cli/commands/console-cmd.js +3 -1
  22. package/src/cli/commands/scaffold.js +38 -2
  23. package/src/cli/generators/index.js +58 -1
  24. package/src/cli/index.js +88 -59
  25. package/src/cli/watch.js +188 -0
  26. package/src/core/ajv-mapper.js +3 -1
  27. package/src/core/ctx-builder.js +59 -1
  28. package/src/core/envelope.js +9 -2
  29. package/src/core/hub-link.js +24 -14
  30. package/src/core/index.js +1 -1
  31. package/src/core/mega-app.js +55 -45
  32. package/src/core/pipeline.js +8 -6
  33. package/src/core/scope-registry.js +1 -0
  34. package/src/core/security.js +3 -3
  35. package/src/core/session-store.js +14 -1
  36. package/src/core/ws-presence.js +17 -5
  37. package/src/core/ws-roster.js +49 -10
  38. package/src/core/ws-upgrade.js +105 -0
  39. package/src/lib/mega-circuit-breaker.js +5 -3
  40. package/src/lib/mega-health.js +10 -0
  41. package/src/lib/mega-job-queue.js +53 -13
  42. package/src/lib/mega-job.js +8 -1
  43. package/src/lib/mega-metrics.js +28 -1
  44. package/src/lib/mega-plugin.js +2 -2
  45. package/src/lib/mega-worker.js +28 -5
  46. package/src/lib/ws-hub.js +90 -9
  47. package/templates/adr/code.tpl +23 -0
  48. package/types/adapters/adapter-options.d.ts +2 -0
  49. package/types/adapters/file-adapter.d.ts +12 -1
  50. package/types/adapters/file-session-adapter.d.ts +4 -2
  51. package/types/adapters/maria-adapter.d.ts +5 -3
  52. package/types/adapters/mega-cache-adapter.d.ts +27 -1
  53. package/types/adapters/mega-db-adapter.d.ts +4 -1
  54. package/types/adapters/mongo-adapter.d.ts +13 -2
  55. package/types/adapters/postgres-adapter.d.ts +4 -2
  56. package/types/adapters/redis-adapter.d.ts +8 -0
  57. package/types/adapters/sqlite-adapter.d.ts +8 -2
  58. package/types/cli/generators/index.d.ts +11 -1
  59. package/types/cli/index.d.ts +12 -27
  60. package/types/cli/watch.d.ts +59 -0
  61. package/types/core/ctx-builder.d.ts +23 -0
  62. package/types/core/hub-link.d.ts +3 -1
  63. package/types/core/index.d.ts +1 -1
  64. package/types/core/mega-app.d.ts +1 -1
  65. package/types/core/pipeline.d.ts +2 -1
  66. package/types/core/security.d.ts +3 -3
  67. package/types/core/session-store.d.ts +7 -0
  68. package/types/core/ws-roster.d.ts +13 -1
  69. package/types/core/ws-upgrade.d.ts +29 -0
  70. package/types/lib/mega-circuit-breaker.d.ts +4 -2
  71. package/types/lib/mega-health.d.ts +7 -0
  72. package/types/lib/mega-job-queue.d.ts +16 -4
  73. package/types/lib/mega-job.d.ts +8 -1
  74. package/types/lib/mega-plugin.d.ts +1 -1
  75. package/types/lib/mega-worker.d.ts +3 -1
  76. package/types/lib/ws-hub.d.ts +27 -2
@@ -37,6 +37,9 @@ function passthroughT(key, defaultValue) {
37
37
  /** 요청당 ctx 를 캐싱하는 비열거 키. 글로벌 미들웨어와 라우트 핸들러가 같은 ctx 를 공유하도록 한다. */
38
38
  const HTTP_CTX_KEY = Symbol('mega.httpCtx')
39
39
 
40
+ /** 요청당 lazy ctx 프록시 캐싱 키 — 미들웨어·핸들러가 같은 프록시 객체를 받도록 한다 (ADR-214). */
41
+ const LAZY_CTX_KEY = Symbol('mega.lazyHttpCtx')
42
+
40
43
  /**
41
44
  * `ctx.services.<name>` lazy DI proxy (ADR-148) — 요청별 서비스 인스턴스화·캐시.
42
45
  *
@@ -252,6 +255,61 @@ export function getHttpCtx({ app, req, reply }) {
252
255
  const cached = /** @type {any} */ (req)[HTTP_CTX_KEY]
253
256
  if (cached) return cached
254
257
  const ctx = buildHttpCtx({ app, req, reply })
255
- Object.defineProperty(req, HTTP_CTX_KEY, { value: ctx, enumerable: false, configurable: true })
258
+ // 심볼 직접 대입 (G1 H-2, ADR-214) 심볼 키는 어차피 Object.keys/spread/JSON 안 잡히므로
259
+ // 비열거 defineProperty 의 실익이 없고, defineProperty 는 hidden class 전이를 유발해 17배 비싸다(실측).
260
+ ;/** @type {any} */ (req)[HTTP_CTX_KEY] = ctx
256
261
  return ctx
257
262
  }
263
+
264
+ /**
265
+ * **lazy** HTTP ctx — 실제 ctx 빌드를 첫 속성 접근 시점까지 미루는 요청당 프록시 (G1 H-1, ADR-214).
266
+ *
267
+ * hot path 단일 최대 프레임워크 비용이 "핸들러가 ctx 를 안 써도 매 요청 무조건 buildHttpCtx"
268
+ * (1,615 B + ≈0.7 µs/req, CPU 2.3% — audit-G1 실측)였다. 이 프록시는 생성 비용이 객체 1개 수준이고,
269
+ * 핸들러·미들웨어가 ctx 의 속성을 **처음 만질 때만** {@link getHttpCtx} 로 실 ctx 를 만든다(이후 캐시).
270
+ *
271
+ * 호환성: canonical 시그니처 `(req, reply, ctx)` 는 그대로 — 모든 핸들러가 여전히 3번째 인자를 받고,
272
+ * 참조하는 순간부터는 기존과 동일한 실 ctx 표면(ADR-134 요청당 공유 포함)이다. 프록시 자체도 요청당
273
+ * 1개로 캐시되어 미들웨어와 핸들러가 **동일 객체**(`===`)를 받는다. 모든 트랩이 실 ctx 로 위임되므로
274
+ * `in`/spread/`Object.keys`/getter·setter(`ctx.user=`) 동작이 보존된다.
275
+ *
276
+ * @param {object} args
277
+ * @param {import('./mega-app.js').MegaApp | null} args.app
278
+ * @param {import('fastify').FastifyRequest} args.req
279
+ * @param {import('fastify').FastifyReply} args.reply
280
+ * @returns {Record<string, any>}
281
+ */
282
+ export function getLazyHttpCtx({ app, req, reply }) {
283
+ const cached = /** @type {any} */ (req)[LAZY_CTX_KEY]
284
+ if (cached) return cached
285
+ /** 첫 트랩에서 실 ctx 를 만들고 이후 재사용 (getHttpCtx 가 req 단위 캐시를 겸한다). */
286
+ const real = () => getHttpCtx({ app, req, reply })
287
+ const proxy = new Proxy(
288
+ {},
289
+ {
290
+ get(_t, prop) {
291
+ return Reflect.get(real(), prop)
292
+ },
293
+ set(_t, prop, value) {
294
+ return Reflect.set(real(), prop, value)
295
+ },
296
+ has(_t, prop) {
297
+ return Reflect.has(real(), prop)
298
+ },
299
+ ownKeys() {
300
+ return Reflect.ownKeys(real())
301
+ },
302
+ getOwnPropertyDescriptor(_t, prop) {
303
+ return Object.getOwnPropertyDescriptor(real(), prop)
304
+ },
305
+ defineProperty(_t, prop, desc) {
306
+ return Reflect.defineProperty(real(), prop, desc)
307
+ },
308
+ deleteProperty(_t, prop) {
309
+ return Reflect.deleteProperty(real(), prop)
310
+ },
311
+ },
312
+ )
313
+ ;/** @type {any} */ (req)[LAZY_CTX_KEY] = proxy
314
+ return proxy
315
+ }
@@ -22,13 +22,20 @@ export const REPLY_START_SYMBOL = Symbol('mega.reply.start')
22
22
  export const ENVELOPE_MARK = Symbol('mega.envelope')
23
23
 
24
24
  /**
25
- * envelope 객체에 비열거 마킹을 부착한다.
25
+ * envelope 객체에 마킹을 부착한다.
26
+ *
27
+ * 심볼 **직접 대입** (G1 M-1, ADR-214) — `Object.defineProperty` 대비 17배 빠르고(136→8 ns/op 실측)
28
+ * allocation 도 작다. 심볼 키는 어차피 `JSON.stringify`/`Object.keys` 에 안 나오므로 와이어·열거
29
+ * 동작은 동일하다. 의미 차이는 하나 — spread 복사(`{...env}`)가 마크를 함께 복사한다. 복사본도
30
+ * "이미 envelope" 로 취급되는데, 이는 이중 wrap 방지(ADR-184)에 오히려 부합한다(복사 후 필드를
31
+ * 고쳐 재전송하는 패턴도 envelope 그대로 나간다 — 의도된 동작으로 확정, ADR-214).
32
+ *
26
33
  * @template {Record<string, any>} T
27
34
  * @param {T} env
28
35
  * @returns {T} 같은 객체 (마킹됨).
29
36
  */
30
37
  function markEnvelope(env) {
31
- Object.defineProperty(env, ENVELOPE_MARK, { value: true, enumerable: false })
38
+ ;/** @type {any} */ (env)[ENVELOPE_MARK] = true
32
39
  return env
33
40
  }
34
41
 
@@ -189,7 +189,9 @@ export class MegaHubLink {
189
189
  /**
190
190
  * hub 연결 + REGISTER 핸드셰이크. register_ok 수신 시 resolve.
191
191
  *
192
- * `retry` 옵션이 있으면 최초 연결도 지수 백오프로 재시도한다(ADR-098). 없으면 단발 시도.
192
+ * 초기 연결은 retry 유무와 무관하게 **단 1회** 시도한다. `retry` 옵션이 있으면 실패 시 백그라운드
193
+ * 재연결(지수 백오프)로 전환하고 reject 한다 — 성공 시 RECONNECTED, 소진 시 RECONNECT_FAILED emit.
194
+ * 호출부(boot)는 reject 를 warn 으로 받고 부팅을 계속한다(허브 다운이 부팅을 막지 않는 계약).
193
195
  *
194
196
  * @param {Object} [opts]
195
197
  * @param {number} [opts.connectTimeoutMs] - register_ok 대기 한도 override(M4). **이 값은 인스턴스에
@@ -214,19 +216,27 @@ export class MegaHubLink {
214
216
  throw err
215
217
  }
216
218
  }
217
- // retry 활성 — 최초 연결도 백오프 재시도. AbortError(인증 실패 등) 재시도하지 않는다.
218
- this._abort = new AbortController()
219
- return withRetry(() => this._connectOnce({ connectTimeoutMs: timeout }), {
220
- ...this._retry,
221
- signal: this._abort.signal,
222
- shouldRetry: retryUnlessFatal,
223
- onFailedAttempt: (ctx) => {
224
- this._log?.warn?.(
225
- { bridgeId: this._bridgeId, attempt: ctx.attemptNumber, retriesLeft: ctx.retriesLeft },
226
- 'hub-link connect attempt failedbacking off',
227
- )
228
- },
229
- })
219
+ // retry 활성 — 초기 연결은 **단 1회**만 시도하고, 실패하면 백그라운드 재연결로 전환한 뒤 throw 한다.
220
+ // 예전엔 withRetry 가 최초 연결까지 백오프 전체를 await 해 호출부(boot 의 cluster-transport step)
221
+ // 단위로 블로킹됐다(crud retry 기준 약 4.7분) — "허브 다운이어도 boot 를 막지 않는다" 는 호출부
222
+ // 계약과 정반대 동작. 이제 호출부는 즉시 실패를 보고(warn) 부팅을 계속하고, 백그라운드 _reconnect 가
223
+ // 같은 retry 백오프로 재시도한다 — 성공 시 RECONNECTED 이벤트가 presence 재동기화를 트리거하고,
224
+ // 소진 시 RECONNECT_FAILED 를 emit 한다. 치명 에러(인증 거부 등 fatal)는 재시도 무의미라 그대로 전파.
225
+ try {
226
+ return await this._connectOnce({ connectTimeoutMs: timeout })
227
+ } catch (err) {
228
+ // 레거시 hub 폴백(1회)protocolVersion 필드 거부 판정 후 v1 register 로 즉시 재시도.
229
+ if (err instanceof Error && err.message === 'hub.legacy_register_fallback') {
230
+ try {
231
+ return await this._connectOnce({ connectTimeoutMs: timeout })
232
+ } catch (err2) {
233
+ if (retryUnlessFatal({ error: err2 })) void this._reconnect()
234
+ throw err2
235
+ }
236
+ }
237
+ if (retryUnlessFatal({ error: err })) void this._reconnect()
238
+ throw err
239
+ }
230
240
  }
231
241
 
232
242
  /**
package/src/core/index.js CHANGED
@@ -16,7 +16,7 @@ export { buildErrorHandler } from './error-mapper.js'
16
16
  export { ajvErrorToValidationError, MAX_VALIDATION_DETAILS } from './ajv-mapper.js'
17
17
  export { loadAndValidateConfig } from './config-loader.js'
18
18
  // 요청 ctx 빌더 — db/cache/bus 접근자 배선 (ADR-102)
19
- export { buildHttpCtx, getHttpCtx, buildAdapterAccessors } from './ctx-builder.js'
19
+ export { buildHttpCtx, getHttpCtx, getLazyHttpCtx, buildAdapterAccessors } from './ctx-builder.js'
20
20
  // WS envelope + 컨트롤러 베이스 (ADR-015 / ADR-074)
21
21
  export {
22
22
  createWsMessage,
@@ -63,7 +63,7 @@ export class MegaApp {
63
63
  * @param {Object|false} [opts.helmet] - fastify-helmet 옵션 (보안 헤더). false=미등록, undefined=디폴트 ON
64
64
  * (ADR-047/127). 라우트 옵션 오버라이드는 완전 교체(ADR-073).
65
65
  * @param {Object|false} [opts.cors] - fastify-cors 옵션. false=미등록, undefined=origin:false(교차출처 거부, 안전 디폴트).
66
- * @param {Object|false} [opts.rateLimit] - fastify-rate-limit 옵션. false=미등록, undefined=디폴트(IP당 100/min, ADR-048).
66
+ * @param {Object|false} [opts.rateLimit] - fastify-rate-limit 옵션. false=미등록, undefined=디폴트(IP당 1000/min, ADR-048/197).
67
67
  * 멀티 인스턴스 분산 카운팅은 `caches.rate` 별명(Redis), 미선언 시 in-memory 폴백.
68
68
  * @param {Object|false} [opts.csrf] - fastify-csrf-protection 옵션. false=미등록, undefined=디폴트 ON.
69
69
  * ADR-051: JSON 요청은 토큰 면제+Origin 검증, 폼 요청만 토큰 검증.
@@ -233,53 +233,63 @@ export class MegaApp {
233
233
  Date.now()
234
234
  })
235
235
 
236
- // 1b) HTTP 루트 span (ADR-126) 옵트인 OFF 면 isEnabled() false 즉시 return(0 비용).
237
- // onRequest 에서 span 시작 + 활성 컨텍스트 진입(enterWith) 핸들러의 ctx.tracer.span·어댑터 호출이
238
- // span 자식으로 중첩됨. onResponse 에서 status_code 기록 종료. HTTP 요청은 독립 async
239
- // context enterWith 요청 누수 없이 격리된다(실측 확인 ADR-126).
240
- this.fastify.addHook('onRequest', async (req, reply) => {
241
- if (!MegaTracing.isEnabled()) return
242
- const route = /** @type {any} */ (req).routeOptions?.url ?? req.url
243
- const host = String(req.headers.host ?? '').split(':')[0]
244
- const handle = MegaTracing.enterHttpSpan({
245
- method: req.method,
246
- route,
247
- path: req.url,
248
- host,
249
- app: this.name,
250
- // inbound traceparent/tracestate 를 부모로 복원(W3C trace context, ADR-196) — 게이트웨이/업스트림
251
- // trace 루트 span 이 이어진다. 무효/부재 헤더는 종전대로 새 루트(fail-safe).
252
- headers: req.headers,
253
- })
254
- ;(/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL] = handle
255
- })
256
- // onError 는 핸들러/직렬화 throw 시 호출 — 예외를 span 에 기록(상태 ERROR). 종료는 onResponse 담당.
257
- this.fastify.addHook('onError', async (req, reply, err) => {
258
- const handle = (/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL]
259
- handle?.setError(err)
260
- // 보안 거부(ASP 복호/drift/replay/signal)도 활성 span 에 사유 기록(ADR-127).
261
- // CSRF/rate-limit 은 security.js 가 직접 박는다(throw 전·onExceeded). ASP 는 기존 코드 무변경 위해 여기서.
262
- if (err instanceof MegaAspDecryptError) {
263
- MegaTracing.tracer.activeSpan()?.setAttributes({ 'mega.security.reason': `asp.${err.rule}` })
264
- }
265
- })
266
- // onResponse 는 성공·에러 응답 모두에서 마지막에 호출 — 상태코드 기록 후 span 종료(단일 종료 지점).
267
- this.fastify.addHook('onResponse', async (req, reply) => {
268
- const handle = (/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL]
269
- handle?.finish(reply.statusCode)
270
- // HTTP 요청 메트릭 (ADR-131) — 옵트인 OFF 면 isEnabled() false 라 즉시 return(0 비용).
271
- // route 는 **매칭된 패턴**(routeOptions.url)만 — 매칭 안 되면(404) recordHttp 가 __unmatched__ 로 접어
272
- // 카디널리티 폭증 차단. reply.elapsedTime = onRequest~onResponse ms(Fastify 제공).
273
- if (MegaMetrics.isEnabled()) {
274
- MegaMetrics.recordHttp({
236
+ // 1b) HTTP 루트 span (ADR-126) + 메트릭 (ADR-131) **부팅 시점 분기** (G1 M-3, ADR-214).
237
+ // 이전엔 옵트인 OFF 여도 훅을 등록하고 본문에서 즉시 return 했는데, 본문 비용은 0 이어도
238
+ // Fastify async hook 디스패치(훅당 Promise 1개) 자체가 요청 남는다. 옵트인은 부팅(boot
239
+ // prepareRuntime metrics tracing MegaApp 생성 **전에** init)에 결정되므로, 생성 시점에
240
+ // OFF 훅을 아예 등록하지 않는다. 런타임 중 토글은 공개 표면에 없음(init 부팅 1회 계약 —
241
+ // 앱 생성 후 init 하는 직접 생성 경로는 미지원, ADR-214 명시).
242
+ const isTracingOn = MegaTracing.isEnabled()
243
+ const isMetricsOn = MegaMetrics.isEnabled()
244
+ if (isTracingOn) {
245
+ // onRequest 에서 span 시작 + 활성 컨텍스트 진입(enterWith) → 핸들러의 ctx.tracer.span·어댑터 호출이
246
+ // 이 span 의 자식으로 중첩됨. onResponse 에서 status_code 기록 후 종료. 각 HTTP 요청은 독립 async
247
+ // context 라 enterWith 가 요청 간 누수 없이 격리된다(실측 확인 — ADR-126).
248
+ this.fastify.addHook('onRequest', async (req, reply) => {
249
+ const route = /** @type {any} */ (req).routeOptions?.url ?? req.url
250
+ const host = String(req.headers.host ?? '').split(':')[0]
251
+ const handle = MegaTracing.enterHttpSpan({
275
252
  method: req.method,
276
- route: /** @type {any} */ (req).routeOptions?.url,
277
- statusCode: reply.statusCode,
278
- durationMs: reply.elapsedTime,
253
+ route,
254
+ path: req.url,
255
+ host,
279
256
  app: this.name,
257
+ // inbound traceparent/tracestate 를 부모로 복원(W3C trace context, ADR-196) — 게이트웨이/업스트림
258
+ // trace 에 루트 span 이 이어진다. 무효/부재 헤더는 종전대로 새 루트(fail-safe).
259
+ headers: req.headers,
280
260
  })
281
- }
282
- })
261
+ ;(/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL] = handle
262
+ })
263
+ // onError 는 핸들러/직렬화 throw 시 호출 — 예외를 span 에 기록(상태 ERROR). 종료는 onResponse 담당.
264
+ this.fastify.addHook('onError', async (req, reply, err) => {
265
+ const handle = (/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL]
266
+ handle?.setError(err)
267
+ // 보안 거부(ASP 복호/drift/replay/signal)도 활성 span 에 사유 기록(ADR-127).
268
+ // CSRF/rate-limit 은 security.js 가 직접 박는다(throw 전·onExceeded). ASP 는 기존 코드 무변경 위해 여기서.
269
+ if (err instanceof MegaAspDecryptError) {
270
+ MegaTracing.tracer.activeSpan()?.setAttributes({ 'mega.security.reason': `asp.${err.rule}` })
271
+ }
272
+ })
273
+ }
274
+ // onResponse 는 성공·에러 응답 모두에서 마지막에 호출 — 상태코드 기록 후 span 종료(단일 종료 지점).
275
+ // 트레이싱(span finish)·메트릭(recordHttp) 둘 중 하나라도 켜져 있을 때만 등록.
276
+ if (isTracingOn || isMetricsOn) {
277
+ this.fastify.addHook('onResponse', async (req, reply) => {
278
+ const handle = (/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL]
279
+ handle?.finish(reply.statusCode)
280
+ // HTTP 요청 메트릭 (ADR-131) — route 는 **매칭된 패턴**(routeOptions.url)만. 매칭 안 되면(404)
281
+ // recordHttp 가 __unmatched__ 로 접어 카디널리티 폭증 차단. reply.elapsedTime = Fastify 제공 ms.
282
+ if (isMetricsOn && MegaMetrics.isEnabled()) {
283
+ MegaMetrics.recordHttp({
284
+ method: req.method,
285
+ route: /** @type {any} */ (req).routeOptions?.url,
286
+ statusCode: reply.statusCode,
287
+ durationMs: reply.elapsedTime,
288
+ app: this.name,
289
+ })
290
+ }
291
+ })
292
+ }
283
293
 
284
294
  // 2) 자동 envelope (ADR-018, ADR-076).
285
295
  // onRoute 훅으로 각 라우트의 preSerialization 체인 "맨 끝" 에 wrapEnvelope 를 붙인다.
@@ -21,19 +21,20 @@
21
21
  *
22
22
  * @module core/pipeline
23
23
  */
24
- import { getHttpCtx } from './ctx-builder.js'
24
+ import { getLazyHttpCtx } from './ctx-builder.js'
25
25
 
26
26
  /**
27
27
  * 미들웨어를 Fastify 가 async preHandler 로 인식하는 arity-2 래퍼로 감싸고, canonical ctx 를
28
28
  * 3번째 인자로 주입한다 — 핸들러·글로벌 미들웨어와 동일한 `(req, reply, ctx)` 계약(ADR-134/184).
29
- * getHttpCtx요청당 캐싱이라 같은 요청의 모든 미들웨어·핸들러가 동일 ctx 객체를 공유한다.
29
+ * ctx**lazy 프록시**(ADR-214) 미들웨어가 실제로 속성을 만질 때만 빌드되고, 요청당 캐싱이라
30
+ * 같은 요청의 모든 미들웨어·핸들러가 동일 ctx 객체를 공유한다.
30
31
  *
31
32
  * @param {Function} fn - `(req, reply, ctx?)` 미들웨어 (arity-2 도 하위 호환 — 3번째 인자 무시).
32
33
  * @param {import('./mega-app.js').MegaApp | null} [app] - ctx 의 어댑터·서비스 접근자 출처(없으면 null).
33
34
  * @returns {(req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<any>}
34
35
  */
35
36
  export function wrapPreHandler(fn, app) {
36
- return async (req, reply) => fn(req, reply, getHttpCtx({ app: app ?? null, req, reply }))
37
+ return async (req, reply) => fn(req, reply, getLazyHttpCtx({ app: app ?? null, req, reply }))
37
38
  }
38
39
 
39
40
  /**
@@ -102,9 +103,10 @@ export function composeAfter(afters, { method, path }) {
102
103
  export function buildHttpPipeline({ app = null, method, path, handler, before = [], transform = [], after = [] }) {
103
104
  /** @type {HttpPipeline} */
104
105
  const pipeline = {
105
- // canonical 핸들러 시그니처 (req, res, ctx) (ADR-074, docs/03 §581). getHttpCtx요청당 1회
106
- // 캐싱이라 글로벌·before 미들웨어가 먼저 만든 ctx 그대로 이어받는다(ADR-134).
107
- handler: async (req, reply) => handler(req, reply, getHttpCtx({ app, req, reply })),
106
+ // canonical 핸들러 시그니처 (req, res, ctx) (ADR-074, docs/03 §581). ctxlazy 프록시
107
+ // (ADR-214) 핸들러가 쓰면 buildHttpCtx 비용(1.6 KB + 0.7 µs/req, G1 H-1)이 발생하지 않고,
108
+ // 접근부터는 요청당 1회 캐싱이라 글로벌·before 미들웨어가 먼저 만든 ctx 를 그대로 이어받는다(ADR-134).
109
+ handler: async (req, reply) => handler(req, reply, getLazyHttpCtx({ app, req, reply })),
108
110
  describe: () => ({
109
111
  method,
110
112
  path,
@@ -20,6 +20,7 @@ export const GLOBAL_ONLY_KEYS = Object.freeze([
20
20
  'jobs', // MegaJob 서브클래스 배열 — mega worker 가 소비 (ADR-123)
21
21
  'schedules', // MegaSchedule 서브클래스 배열 — mega scheduler 가 소비 (ADR-123)
22
22
  'workers', // MegaWorker 서브클래스 배열 — CPU 워커 풀, ctx.workers.<name> 배선 (ADR-124)
23
+ 'watch', // mega start --watch 의 ignore/paths 정책 (ADR-220)
23
24
  ])
24
25
 
25
26
  /** apps/<name>/app.config.js 에만 허용되는 키 */
@@ -43,8 +43,8 @@ import { registerAspPlugin } from '../lib/asp/plugin.js'
43
43
  import { MegaForbiddenError } from '../errors/http-errors.js'
44
44
  import * as MegaTracing from '../lib/mega-tracing.js'
45
45
 
46
- /** rate-limit 기본값 (ADR-048: IP 당 100 req/min). 사용자 config 로 완전 교체(ADR-073). */
47
- export const DEFAULT_RATE_LIMIT = Object.freeze({ max: 100, timeWindow: '1 minute' })
46
+ /** rate-limit 기본값 (IP 당 1000 req/min — ADR-048 의 100/min 은 평상 트래픽도 429 를 만드는 운영 함정이라 상향, ADR-214). 사용자 config 로 완전 교체(ADR-073). */
47
+ export const DEFAULT_RATE_LIMIT = Object.freeze({ max: 1000, timeWindow: '1 minute' })
48
48
 
49
49
  /** CSRF 토큰 검증을 건너뛰는 안전 메서드(상태 변경 없음). */
50
50
  const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
@@ -83,7 +83,7 @@ function annotateReject(reason, extra = {}) {
83
83
  * @param {string[]} [opts.hosts] - 앱 도메인 목록(CSRF Origin 검증 allowlist, ADR-051).
84
84
  * @param {Object|false} [opts.helmet] - fastify-helmet 옵션. false=미등록, undefined=디폴트 ON.
85
85
  * @param {Object|false} [opts.cors] - fastify-cors 옵션. false=미등록, undefined=origin:false(교차출처 거부).
86
- * @param {Object|false} [opts.rateLimit] - fastify-rate-limit 옵션. false=미등록, undefined=디폴트(100/min).
86
+ * @param {Object|false} [opts.rateLimit] - fastify-rate-limit 옵션. false=미등록, undefined=디폴트(1000/min, ADR-214).
87
87
  * @param {Object|false} [opts.csrf] - fastify-csrf-protection 옵션. false=미등록, undefined=디폴트 ON.
88
88
  * @param {{ masterSecret?: string, http?: { enabledPaths?: string[], driftMs?: number, headerSignal?: string, timestampHeader?: string, nonceCache?: any } }} [opts.asp] -
89
89
  * ASP 설정. `masterSecret` + `http.enabledPaths`(비어있지 않음)가 둘 다 있을 때만 HTTP ASP 등록.
@@ -23,6 +23,14 @@ const SESSION_DRIVERS = Object.freeze({
23
23
  redis: MegaRedisSessionAdapter,
24
24
  })
25
25
 
26
+ /**
27
+ * file driver 만료 스캔 cleanup 기본 주기(1시간, ms) — ADR-046 의 "file 모드 cleanup 자동" 약속 구현(ADR-215).
28
+ * 어댑터 자체 디폴트는 0(off)이라, 팩토리 경유 생성(=프레임워크 부팅 경로)에서만 기본 주기를 주입한다.
29
+ * 만료 파일의 lazy 삭제는 같은 sid 재접근 시에만 일어나므로, 주기 스캔이 없으면 미접근 만료 세션이
30
+ * 디스크에 무한 누적된다(G5 audit M-3). `cleanupIntervalMs: 0` 명시로 옵트아웃(외부 cron/scheduler 운용 시).
31
+ */
32
+ export const DEFAULT_FILE_CLEANUP_INTERVAL_MS = 3_600_000
33
+
26
34
  /**
27
35
  * 세션 스토어 어댑터를 만든다(connect 는 호출자 책임 — MegaApp 가 부팅 시 connect).
28
36
  *
@@ -47,7 +55,12 @@ export function createSessionStore(storeConfig, defaults = {}) {
47
55
  }
48
56
  const Cls = SESSION_DRIVERS[/** @type {'file'|'redis'} */ (driver)]
49
57
  // store 에 ttlMs 가 명시되지 않았으면 미들웨어 기본 ttlMs 를 주입(단일 출처 — session.ttlMs).
50
- const cfg = storeConfig.ttlMs === undefined && defaults.ttlMs !== undefined ? { ...storeConfig, ttlMs: defaults.ttlMs } : storeConfig
58
+ let cfg = storeConfig.ttlMs === undefined && defaults.ttlMs !== undefined ? { ...storeConfig, ttlMs: defaults.ttlMs } : storeConfig
59
+ // file driver 는 만료 스캔 cleanup 을 기본 1h 주기로 — 미지정 시에만 주입(명시 0 = 옵트아웃, ADR-215).
60
+ // 타이머는 어댑터 _connect 가 unref() 로 걸어 프로세스 종료를 막지 않는다.
61
+ if (driver === 'file' && cfg.cleanupIntervalMs === undefined) {
62
+ cfg = { ...cfg, cleanupIntervalMs: DEFAULT_FILE_CLEANUP_INTERVAL_MS }
63
+ }
51
64
  return new Cls(/** @type {any} */ (cfg))
52
65
  }
53
66
 
@@ -18,6 +18,7 @@ import { MegaHubLink } from './hub-link.js'
18
18
  import { CLOSE_CODE_REQUEUE } from './ws-upgrade.js'
19
19
  import { HUB_MESSAGE_TYPES } from '../lib/hub-protocol.js'
20
20
  import { MegaShutdown } from '../lib/mega-shutdown.js'
21
+ import * as MegaHealth from '../lib/mega-health.js'
21
22
 
22
23
  export class MegaWsPresence {
23
24
  /**
@@ -113,16 +114,26 @@ export class MegaWsPresence {
113
114
  link.on(HUB_MESSAGE_TYPES.HEARTBEAT, noopHub)
114
115
  // 재연결 성공 시 presence 재동기화(ADR-098) — hub 는 절단 시점 presence 를 잃으므로 broadcast 채널을 다시 JOIN.
115
116
  link.on(MegaHubLink.EVENTS.RECONNECTED, () => this._resyncPresence())
117
+
118
+ // shutdown hook 은 connect **전에** 등록한다(L1 dedup 유지) — 초기 연결이 실패해 백그라운드
119
+ // 재시도로 전환돼도(아래 throw) hook 이 link.close 로 백오프를 abort 해 프로세스가 행하지 않는다.
120
+ const hookName = `mega-hublink:${this._appName}`
121
+ MegaShutdown.unregister(hookName)
122
+ MegaShutdown.register(hookName, async () => link.close())
123
+
124
+ // readiness 에 hub link 상태 노출 — hub 는 best-effort(다운이어도 부팅·로컬 전달 지속)라 readiness 를
125
+ // **게이트하지 않는다**(ok 고정 true — 어댑터 자동 체크와 달리 503 원인이 되면 안 됨). 연결 상태는
126
+ // detail 필드(registered/open)로 노출된다(기본 응답은 ok 만 — 상세는 health.exposeCheckDetails 옵트인).
127
+ MegaHealth.register(`hub:${this._appName}`, () => ({ ok: true, registered: link.isRegistered, open: link.isOpen }))
128
+
129
+ // 초기 연결은 단 1회 시도(hub-link connect 계약) — 실패 시 reject 가 전파되고(호출부 boot 가 warn 후
130
+ // 부팅 계속) retry 설정이 있으면 link 가 백그라운드로 재시도한다. RECONNECTED 핸들러(위)가 성공 시
131
+ // presence 를 재동기화하므로 여기 실패해도 채널 구독은 복구된다.
116
132
  await link.connect()
117
133
 
118
134
  // 채널 구독 — bridge-subscriber 세션 JOIN(zero-config 브로드캐스트 수신용) + 이미 붙어 있는 실
119
135
  // 사용자 세션 재JOIN(예: 첫 connect 전에 클라가 연결됐을 수 있음).
120
136
  this._resyncPresence()
121
-
122
- // shutdown hook 누수 방지(L1) — 재연결 등으로 connectHub 가 다시 불려도 hook 은 항상 1개.
123
- const hookName = `mega-hublink:${this._appName}`
124
- MegaShutdown.unregister(hookName)
125
- MegaShutdown.register(hookName, async () => link.close())
126
137
  return link
127
138
  }
128
139
 
@@ -601,6 +612,7 @@ export class MegaWsPresence {
601
612
  this._hubLink = null
602
613
  this._hubBridgeId = null
603
614
  MegaShutdown.unregister(`mega-hublink:${this._appName}`)
615
+ MegaHealth.unregister(`hub:${this._appName}`) // 닫힌 link 의 stale 체크 제거(register 와 짝맞춤).
604
616
  }
605
617
  // NATS 클러스터 정리(ADR-176) — 구독·heartbeat/sweep 타이머·roster 멤버 해제. boot 의 shutdown hook 과
606
618
  // **양쪽**에서 정리해야 시그널을 안 거치는 종료(server.close()/프로그램적 재시작/멀티앱 부분 종료)에서도
@@ -74,9 +74,25 @@ export class MegaWsRedisRoster {
74
74
  if (typeof channel !== 'string' || typeof sessionId !== 'string') return
75
75
  const key = this._key(channel)
76
76
  const value = JSON.stringify({ userId: member?.userId, ...(member?.metadata ? { metadata: member.metadata } : {}), expiresAt: Date.now() + this._ttlMs })
77
- await this._redis.hset(key, sessionId, value)
78
- // 채널 HASH 전체 TTL 모든 멤버가 stale 돼도 키가 영구히 남지 않게(멤버 TTL 의 2배 여유).
79
- await this._redis.pexpire(key, this._ttlMs * 2)
77
+ // HSET + 채널 HASH 키 TTL(멤버 TTL 2배 — 전원 stale 돼도 키가 영구히 안 남게)을 pipeline 1 RT 로.
78
+ // 직렬 await 2회는 joinSession(채널 수만큼 add)·heartbeat 경로에서 왕복 수가 비용을 지배했다.
79
+ const results = await this._redis.pipeline().hset(key, sessionId, value).pexpire(key, this._ttlMs * 2).exec()
80
+ this._throwFirstPipelineError(results)
81
+ }
82
+
83
+ /**
84
+ * ioredis pipeline.exec() 결과(`[err, res][]`)에서 첫 명령 오류를 throw 한다 — pipeline 은 명령별
85
+ * 오류를 reject 가 아니라 결과 배열에 싣기 때문에, 묵히면 호출부의 기존 실패 처리(catch+warn)가
86
+ * 더는 동작하지 않는다(직렬 await 시절의 throw 계약 유지).
87
+ * @param {Array<[Error | null, any]> | null} results
88
+ * @returns {void}
89
+ * @private
90
+ */
91
+ _throwFirstPipelineError(results) {
92
+ if (!Array.isArray(results)) return
93
+ for (const [err] of results) {
94
+ if (err) throw err
95
+ }
80
96
  }
81
97
 
82
98
  /**
@@ -139,13 +155,29 @@ export class MegaWsRedisRoster {
139
155
  }
140
156
 
141
157
  /**
142
- * 로컬 멤버 전체를 다시 add(=expiresAt 갱신). @returns {Promise<void>} @private
158
+ * 로컬 멤버 전체의 `expiresAt` 을 한 pipeline 으로 일괄 갱신. 직렬 add 루프(멤버당 2 RT) 멤버
159
+ * 1,000 에 주기당 수백 ms — 갱신 지연이 TTL 윈도를 침식해 살아있는 세션이 lazy 만료될 수 있었다.
160
+ * pipeline 이면 단일 왕복 수준(실측 ~200×). 채널 키 PEXPIRE 는 채널당 1회만 싣는다.
161
+ * @returns {Promise<void>} @private
143
162
  */
144
163
  async _refreshLocal() {
145
164
  const members = this._getLocalMembers()
165
+ if (members.length === 0) return
166
+ const expiresAt = Date.now() + this._ttlMs
167
+ const pipeline = this._redis.pipeline()
168
+ /** @type {Set<string>} PEXPIRE 를 이미 실은 채널(중복 제거). */
169
+ const touchedChannels = new Set()
146
170
  for (const { channel, sessionId, member } of members) {
147
- await this.add(channel, sessionId, member)
171
+ if (typeof channel !== 'string' || typeof sessionId !== 'string') continue
172
+ const key = this._key(channel)
173
+ const value = JSON.stringify({ userId: member?.userId, ...(member?.metadata ? { metadata: member.metadata } : {}), expiresAt })
174
+ pipeline.hset(key, sessionId, value)
175
+ if (!touchedChannels.has(channel)) {
176
+ touchedChannels.add(channel)
177
+ pipeline.pexpire(key, this._ttlMs * 2)
178
+ }
148
179
  }
180
+ this._throwFirstPipelineError(await pipeline.exec())
149
181
  }
150
182
 
151
183
  /**
@@ -155,12 +187,19 @@ export class MegaWsRedisRoster {
155
187
  async stop() {
156
188
  if (this._hbTimer) clearInterval(this._hbTimer)
157
189
  this._hbTimer = null
158
- // graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리).
190
+ // graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리) — 한 pipeline 으로.
159
191
  // 실패해도 종료는 계속(lazy 만료가 정리) — 단 명단에 TTL 까지 남으므로 묵히지 않고 알린다.
160
- for (const { channel, sessionId } of this._getLocalMembers()) {
161
- await this.remove(channel, sessionId).catch((err) =>
162
- this._log?.warn?.({ err, channel, sessionId }, 'ws-roster graceful remove failed (stale until TTL)'),
163
- )
192
+ const members = this._getLocalMembers()
193
+ if (members.length === 0) return
194
+ const pipeline = this._redis.pipeline()
195
+ for (const { channel, sessionId } of members) {
196
+ if (typeof channel !== 'string' || typeof sessionId !== 'string') continue
197
+ pipeline.hdel(this._key(channel), sessionId)
198
+ }
199
+ try {
200
+ this._throwFirstPipelineError(await pipeline.exec())
201
+ } catch (err) {
202
+ this._log?.warn?.({ err }, 'ws-roster graceful remove failed (stale until TTL)')
164
203
  }
165
204
  }
166
205
  }