mega-framework 0.1.5 → 0.1.7

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 (236) hide show
  1. package/bin/mega-ws-hub.js +2 -2
  2. package/package.json +32 -8
  3. package/sample/crud/.env +156 -8
  4. package/sample/crud/.env.example +153 -28
  5. package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
  6. package/sample/crud/.mega/journal/snapshot.json +261 -0
  7. package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
  8. package/sample/crud/apps/main/controllers/web-controller.js +7 -5
  9. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
  10. package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
  11. package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
  12. package/sample/crud/apps/main/models/log-partition-model.js +105 -0
  13. package/sample/crud/apps/main/models/note-model.js +79 -0
  14. package/sample/crud/apps/main/models/user-level-model.js +24 -0
  15. package/sample/crud/apps/main/models/user-model.js +146 -0
  16. package/sample/crud/apps/main/models/user-type-model.js +21 -0
  17. package/sample/crud/apps/main/models/wallet-model.js +24 -0
  18. package/sample/crud/apps/main/routes/users.js +55 -10
  19. package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
  20. package/sample/crud/apps/main/services/auth-service.js +39 -24
  21. package/sample/crud/apps/main/services/log-partition-service.js +101 -0
  22. package/sample/crud/apps/main/services/note-service.js +6 -6
  23. package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
  24. package/sample/crud/apps/main/services/user-service.js +62 -21
  25. package/sample/crud/apps/main/views/auth/login.ejs +6 -6
  26. package/sample/crud/apps/main/views/auth/register.ejs +46 -5
  27. package/sample/crud/apps/main/views/users/edit.ejs +42 -5
  28. package/sample/crud/apps/main/views/users/list.ejs +6 -2
  29. package/sample/crud/apps/main/views/users/new.ejs +56 -4
  30. package/sample/crud/docs/log_partition_design.mm.md +23 -0
  31. package/sample/crud/mega.config.js +63 -3
  32. package/sample/crud/package.json +3 -3
  33. package/sample/crud/scripts/start-ws-hub.sh +2 -2
  34. package/sample/simple/package.json +2 -2
  35. package/src/adapters/adapter-manager.js +2 -1
  36. package/src/adapters/adapter-options.js +30 -0
  37. package/src/adapters/maria-adapter.js +26 -3
  38. package/src/adapters/mega-db-adapter.js +7 -1
  39. package/src/adapters/mongo-adapter.js +19 -1
  40. package/src/adapters/postgres-adapter.js +25 -2
  41. package/src/adapters/sqlite-adapter.js +20 -1
  42. package/src/cli/commands/new.js +13 -3
  43. package/src/cli/commands/scaffold.js +137 -33
  44. package/src/cli/generators/index.js +82 -2
  45. package/src/cli/index.js +478 -104
  46. package/src/core/ajv-mapper.js +27 -2
  47. package/src/core/boot.js +485 -237
  48. package/src/core/cluster-metrics.js +13 -4
  49. package/src/core/config-validator.js +25 -0
  50. package/src/core/ctx-builder.js +6 -2
  51. package/src/core/envelope.js +112 -12
  52. package/src/core/hub-link.js +65 -4
  53. package/src/core/i18n.js +11 -1
  54. package/src/core/index.js +6 -2
  55. package/src/core/mega-app.js +223 -481
  56. package/src/core/mega-cluster.js +54 -13
  57. package/src/core/mega-server.js +40 -9
  58. package/src/core/migration/dialect-registry.js +107 -0
  59. package/src/core/migration/dialects/README.md +62 -0
  60. package/src/core/migration/dialects/maria.js +496 -0
  61. package/src/core/migration/dialects/mongo.js +824 -0
  62. package/src/core/migration/dialects/postgres.js +563 -0
  63. package/src/core/migration/dialects/sqlite.js +476 -0
  64. package/src/core/migration/differ.js +456 -0
  65. package/src/core/migration/generate.js +508 -0
  66. package/src/core/migration/journal.js +167 -0
  67. package/src/core/migration/model-scan.js +84 -0
  68. package/src/core/migration/mongo-migration-db.js +97 -0
  69. package/src/core/migration/schema-builder.js +400 -0
  70. package/src/core/migration/schema-validator.js +315 -0
  71. package/src/core/migration-lock.js +205 -0
  72. package/src/core/migration-runner.js +166 -38
  73. package/src/core/multipart.js +28 -5
  74. package/src/core/pipeline.js +129 -0
  75. package/src/core/router.js +70 -65
  76. package/src/core/scope-registry.js +0 -1
  77. package/src/core/security.js +67 -9
  78. package/src/core/workers-manager.js +12 -1
  79. package/src/core/ws-cluster.js +10 -3
  80. package/src/core/ws-message.js +48 -4
  81. package/src/core/ws-presence.js +624 -0
  82. package/src/core/ws-roster.js +4 -1
  83. package/src/core/ws-upgrade.js +118 -12
  84. package/src/index.js +1 -1
  85. package/src/lib/hub-protocol.js +29 -0
  86. package/src/lib/mega-health.js +25 -4
  87. package/src/lib/mega-job-queue.js +98 -21
  88. package/src/lib/mega-job.js +29 -0
  89. package/src/lib/mega-logger.js +1 -1
  90. package/src/lib/mega-metrics.js +3 -12
  91. package/src/lib/mega-plugin.js +34 -3
  92. package/src/lib/mega-schedule.js +40 -22
  93. package/src/lib/mega-shutdown.js +162 -49
  94. package/src/lib/mega-tracing.js +66 -19
  95. package/src/lib/mega-worker.js +5 -1
  96. package/src/lib/otel-resource.js +36 -0
  97. package/src/{cli → lib}/ws-hub.js +51 -8
  98. package/src/models/crud-sql-builder.js +133 -0
  99. package/src/models/mega-model.js +82 -2
  100. package/src/models/model-crud.js +483 -0
  101. package/src/models/mongo-crud.js +285 -0
  102. package/templates/model/code-mongo.tpl +35 -0
  103. package/templates/model/code.tpl +15 -1
  104. package/templates/model/test-mongo.tpl +38 -0
  105. package/templates/model/test.tpl +4 -0
  106. package/types/adapters/adapter-manager.d.ts +95 -0
  107. package/types/adapters/adapter-options.d.ts +91 -0
  108. package/types/adapters/file-adapter.d.ts +94 -0
  109. package/types/adapters/file-session-adapter.d.ts +101 -0
  110. package/types/adapters/index.d.ts +20 -0
  111. package/types/adapters/maria-adapter.d.ts +115 -0
  112. package/types/adapters/mega-adapter.d.ts +215 -0
  113. package/types/adapters/mega-bus-adapter.d.ts +45 -0
  114. package/types/adapters/mega-cache-adapter.d.ts +47 -0
  115. package/types/adapters/mega-db-adapter.d.ts +47 -0
  116. package/types/adapters/mega-lock-adapter.d.ts +62 -0
  117. package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
  118. package/types/adapters/mega-session-adapter.d.ts +32 -0
  119. package/types/adapters/mongo-adapter.d.ts +139 -0
  120. package/types/adapters/nats-adapter.d.ts +108 -0
  121. package/types/adapters/postgres-adapter.d.ts +139 -0
  122. package/types/adapters/redis-adapter.d.ts +70 -0
  123. package/types/adapters/redis-session-adapter.d.ts +82 -0
  124. package/types/adapters/redlock-adapter.d.ts +149 -0
  125. package/types/adapters/registry.d.ts +46 -0
  126. package/types/adapters/sqlite-adapter.d.ts +106 -0
  127. package/types/auth/index.d.ts +24 -0
  128. package/types/cli/commands/console-cmd.d.ts +37 -0
  129. package/types/cli/commands/new.d.ts +16 -0
  130. package/types/cli/commands/routes.d.ts +36 -0
  131. package/types/cli/commands/scaffold.d.ts +78 -0
  132. package/types/cli/commands/test-cmd.d.ts +14 -0
  133. package/types/cli/generators/index.d.ts +112 -0
  134. package/types/cli/index.d.ts +249 -0
  135. package/types/cli/template-engine.d.ts +40 -0
  136. package/types/core/ajv-mapper.d.ts +27 -0
  137. package/types/core/boot.d.ts +233 -0
  138. package/types/core/cluster-metrics.d.ts +52 -0
  139. package/types/core/config-loader.d.ts +13 -0
  140. package/types/core/config-validator.d.ts +30 -0
  141. package/types/core/ctx-builder.d.ts +80 -0
  142. package/types/core/envelope.d.ts +79 -0
  143. package/types/core/error-mapper.d.ts +17 -0
  144. package/types/core/formbody.d.ts +41 -0
  145. package/types/core/hub-link.d.ts +264 -0
  146. package/types/core/i18n.d.ts +178 -0
  147. package/types/core/index.d.ts +28 -0
  148. package/types/core/mega-app.d.ts +529 -0
  149. package/types/core/mega-cluster.d.ts +104 -0
  150. package/types/core/mega-server.d.ts +91 -0
  151. package/types/core/mega-service.d.ts +31 -0
  152. package/types/core/migration/dialect-registry.d.ts +22 -0
  153. package/types/core/migration/dialects/maria.d.ts +99 -0
  154. package/types/core/migration/dialects/mongo.d.ts +89 -0
  155. package/types/core/migration/dialects/postgres.d.ts +117 -0
  156. package/types/core/migration/dialects/sqlite.d.ts +111 -0
  157. package/types/core/migration/differ.d.ts +47 -0
  158. package/types/core/migration/generate.d.ts +56 -0
  159. package/types/core/migration/journal.d.ts +52 -0
  160. package/types/core/migration/model-scan.d.ts +19 -0
  161. package/types/core/migration/mongo-migration-db.d.ts +7 -0
  162. package/types/core/migration/schema-builder.d.ts +197 -0
  163. package/types/core/migration/schema-validator.d.ts +20 -0
  164. package/types/core/migration-lock.d.ts +33 -0
  165. package/types/core/migration-runner.d.ts +101 -0
  166. package/types/core/multipart.d.ts +86 -0
  167. package/types/core/openapi.d.ts +62 -0
  168. package/types/core/pipeline.d.ts +92 -0
  169. package/types/core/router.d.ts +159 -0
  170. package/types/core/routes-loader.d.ts +21 -0
  171. package/types/core/scope-registry.d.ts +14 -0
  172. package/types/core/security.d.ts +77 -0
  173. package/types/core/services-loader.d.ts +27 -0
  174. package/types/core/session-cleanup-schedule.d.ts +19 -0
  175. package/types/core/session-store.d.ts +18 -0
  176. package/types/core/session.d.ts +77 -0
  177. package/types/core/static-assets.d.ts +73 -0
  178. package/types/core/template.d.ts +106 -0
  179. package/types/core/workers-manager.d.ts +79 -0
  180. package/types/core/ws-cluster.d.ts +208 -0
  181. package/types/core/ws-compression.d.ts +112 -0
  182. package/types/core/ws-controller.d.ts +65 -0
  183. package/types/core/ws-message.d.ts +106 -0
  184. package/types/core/ws-presence.d.ts +273 -0
  185. package/types/core/ws-roster.d.ts +96 -0
  186. package/types/core/ws-upgrade.d.ts +231 -0
  187. package/types/errors/config-error.d.ts +10 -0
  188. package/types/errors/http-errors.d.ts +120 -0
  189. package/types/errors/index.d.ts +3 -0
  190. package/types/errors/mega-error.d.ts +32 -0
  191. package/types/index.d.ts +39 -0
  192. package/types/lib/asp/config.d.ts +49 -0
  193. package/types/lib/asp/crypto.d.ts +43 -0
  194. package/types/lib/asp/errors.d.ts +30 -0
  195. package/types/lib/asp/nonce-cache.d.ts +52 -0
  196. package/types/lib/asp/plugin.d.ts +30 -0
  197. package/types/lib/asp/ws-terminator.d.ts +45 -0
  198. package/types/lib/env-mapper.d.ts +14 -0
  199. package/types/lib/hub-protocol.d.ts +106 -0
  200. package/types/lib/index.d.ts +22 -0
  201. package/types/lib/logger/telegram-core.d.ts +104 -0
  202. package/types/lib/logger/telegram-transport.d.ts +45 -0
  203. package/types/lib/mega-brute-force.d.ts +66 -0
  204. package/types/lib/mega-circuit-breaker.d.ts +241 -0
  205. package/types/lib/mega-cron.d.ts +66 -0
  206. package/types/lib/mega-hash.d.ts +32 -0
  207. package/types/lib/mega-health.d.ts +41 -0
  208. package/types/lib/mega-job-queue.d.ts +176 -0
  209. package/types/lib/mega-job-worker.d.ts +130 -0
  210. package/types/lib/mega-job.d.ts +138 -0
  211. package/types/lib/mega-logger.d.ts +45 -0
  212. package/types/lib/mega-metrics.d.ts +285 -0
  213. package/types/lib/mega-plugin.d.ts +245 -0
  214. package/types/lib/mega-retry.d.ts +85 -0
  215. package/types/lib/mega-schedule.d.ts +260 -0
  216. package/types/lib/mega-shutdown.d.ts +135 -0
  217. package/types/lib/mega-tracing.d.ts +224 -0
  218. package/types/lib/mega-worker.d.ts +127 -0
  219. package/types/lib/otel-resource.d.ts +16 -0
  220. package/types/lib/worker-runner/process-entry.d.ts +1 -0
  221. package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
  222. package/types/lib/worker-runner/thread-entry.d.ts +1 -0
  223. package/types/lib/ws-hub.d.ts +234 -0
  224. package/types/models/crud-sql-builder.d.ts +48 -0
  225. package/types/models/index.d.ts +1 -0
  226. package/types/models/mega-model.d.ts +138 -0
  227. package/types/models/model-crud.d.ts +82 -0
  228. package/types/models/mongo-crud.d.ts +59 -0
  229. package/types/test/index.d.ts +84 -0
  230. package/.env +0 -127
  231. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
  232. package/sample/crud/apps/main/models/note.js +0 -71
  233. package/sample/crud/apps/main/models/user.js +0 -86
  234. package/sample/crud/package-lock.json +0 -5665
  235. package/sample/crud/yarn.lock +0 -2142
  236. package/sample/simple/package-lock.json +0 -1851
@@ -1,17 +1,18 @@
1
1
  // @ts-check
2
2
  import Fastify from 'fastify'
3
3
  import { WebSocketServer } from 'ws'
4
- import { wrapEnvelope, REPLY_START_SYMBOL } from './envelope.js'
4
+ import { wrapEnvelope, synthesizeEnvelopeResponseSchema, REPLY_START_SYMBOL } from './envelope.js'
5
5
  import { buildErrorHandler } from './error-mapper.js'
6
6
  import { Router } from './router.js'
7
7
  import { driveWsConnection, createPlainCodec, createAspCodec, rejectUpgrade } from './ws-upgrade.js'
8
+ import { negotiateWsProtocol, WS_SUBPROTOCOL_PATTERN, WS_PROTOCOL_VERSION } from './ws-message.js'
8
9
  import { buildPerMessageDeflate } from './ws-compression.js'
10
+ import { MegaWsPresence } from './ws-presence.js'
9
11
  import { MegaAspTerminator } from '../lib/asp/ws-terminator.js'
10
- import { MegaHubLink } from './hub-link.js'
11
- import { HUB_MESSAGE_TYPES } from '../lib/hub-protocol.js'
12
12
  import * as MegaHealth from '../lib/mega-health.js'
13
13
  import { MegaShutdown } from '../lib/mega-shutdown.js'
14
14
  import { buildAdapterAccessors, getHttpCtx } from './ctx-builder.js'
15
+ import { wrapPreHandler, composeTransform, composeAfter } from './pipeline.js'
15
16
  import { registerSecurityPlugins } from './security.js'
16
17
  import { registerMultipart } from './multipart.js'
17
18
  import { registerSession } from './session.js'
@@ -33,7 +34,7 @@ const HTTP_SPAN_SYMBOL = Symbol('mega.httpSpan')
33
34
 
34
35
  /**
35
36
  * 브라우저↔Bridge WS 프레임 최대 크기 디폴트 (bytes, L-3 / ADR-099).
36
- * 1 MiB — Hub(`DEFAULT_MAX_PAYLOAD_BYTES`, src/cli/ws-hub.js)와 대칭. 초과 프레임은 ws 가 1009 close.
37
+ * 1 MiB — Hub(`DEFAULT_MAX_PAYLOAD_BYTES`, src/lib/ws-hub.js)와 대칭. 초과 프레임은 ws 가 1009 close.
37
38
  * core→cli 역방향 import 를 피하려 값을 여기 별도 정의(두 곳 모두 1 MiB 로 동일).
38
39
  */
39
40
  export const DEFAULT_WS_MAX_PAYLOAD_BYTES = 1_048_576
@@ -116,8 +117,10 @@ export class MegaApp {
116
117
  * (디폴트 OFF). `cacheControl` = raw `Cache-Control` 헤더 문자열(예 `'public, max-age=3600'`). `dotfiles`
117
118
  * 디폴트 false(`.git`/`.env` 차단). `enabled:true` 인데 `dir` 누락/미존재 시 프로덕션 부팅 throw / dev warn+skip.
118
119
  * prefix 가 `health.metricsPath` 와 같으면 부팅 throw(ADR-072).
119
- * @param {{ enabled?: boolean, paths?: { live?: string, ready?: string }, exposeMetrics?: boolean, metricsPath?: string, metricsAllowList?: string[] }} [opts.health] -
120
+ * @param {{ enabled?: boolean, paths?: { live?: string, ready?: string }, exposeCheckDetails?: boolean, exposeMetrics?: boolean, metricsPath?: string, metricsAllowList?: string[] }} [opts.health] -
120
121
  * 운영 관측성 config(Global-only, ADR-072/131). bootApp 이 global `health` 블록을 주입한다.
122
+ * `exposeCheckDetails:true` 면 readiness 응답에 체크별 전체 필드(error 메시지 등)를 노출 — 기본 false
123
+ * (체크별 `{ ok }` 만, ADR-186).
121
124
  * `exposeMetrics:true` 면 `metricsPath`(디폴트 `/metrics`)에 Prometheus `/metrics` 라우트를 등록(보안 면제).
122
125
  * `metricsAllowList` = 접근 허용 IP/CIDR(빈 배열이면 메인 포트 전체 노출, ADR-131). metricsPath 가 health
123
126
  * 경로와 충돌하면 부팅 throw. SDK 초기화(MegaMetrics.init)는 bootApp/prepareRuntime 이 담당.
@@ -130,6 +133,13 @@ export class MegaApp {
130
133
  * — 라우트 핸들러와 동일한 canonical 시그니처(ADR-074/134). `ctx` 는 요청당 1회 만들어 핸들러와
131
134
  * 공유하므로, 미들웨어가 `ctx` 에 심은 값을 핸들러가 본다. 기존 `(req, reply)` 미들웨어는 3번째
132
135
  * 인자를 무시하므로 하위 호환.
136
+ * @param {Function[]} [opts.globalTransforms] - 앱/전역 레벨 응답 변환(ADR-021 체인의 "앱 → 전역"
137
+ * 구간, ADR-194). 계약: `async (req, reply, payload) => payload`. 라우트(+파일) transform 뒤·
138
+ * 자동 envelope wrap 직전에 배열 순서대로 실행(주입자가 앱 항목 먼저·전역 항목 뒤로 배열).
139
+ * health/metrics 등 `config.skipGlobalLifecycle` 라우트는 제외.
140
+ * @param {Function[]} [opts.globalAfters] - 앱/전역 레벨 응답 후 side-effect(ADR-194). 계약:
141
+ * `async (req, reply) => void` — 응답 전송 후(onResponse), throw 는 warn 로그 후 무시(ADR-091).
142
+ * 라우트(+파일) after 뒤에 실행. `config.skipGlobalLifecycle` 라우트는 제외.
133
143
  */
134
144
  constructor(opts) {
135
145
  if (!opts || typeof opts.name !== 'string' || opts.name.length === 0) {
@@ -162,6 +172,22 @@ export class MegaApp {
162
172
  /** @type {string|null} OpenAPI 옵트인 시 swagger-ui 경로(ADR-140). envelope onRoute 가 이 경로를 제외. */
163
173
  this._openapiPath = null
164
174
 
175
+ // 앱/전역 레벨 transform·after 슬롯 (ADR-021 체인의 "앱 → 전역" 구간, ADR-194). orchestrator/
176
+ // 플러그인이 주입하며 배열 순서가 곧 실행 순서(앱 항목 먼저·전역 항목 뒤를 주입자가 표현).
177
+ // onRoute 가 라우트별 체인에 합성한다 — transform 은 envelope wrap 직전, after 는 체인 끝.
178
+ for (const [key, list] of /** @type {Array<[string, unknown]>} */ ([
179
+ ['globalTransforms', opts.globalTransforms],
180
+ ['globalAfters', opts.globalAfters],
181
+ ])) {
182
+ if (list !== undefined && (!Array.isArray(list) || list.some((f) => typeof f !== 'function'))) {
183
+ throw new TypeError(`MegaApp('${this.name}'): ${key} must be an array of functions.`)
184
+ }
185
+ }
186
+ /** @type {Function[]} */
187
+ this._globalTransforms = Array.isArray(opts.globalTransforms) ? [...opts.globalTransforms] : []
188
+ /** @type {Function[]} */
189
+ this._globalAfters = Array.isArray(opts.globalAfters) ? [...opts.globalAfters] : []
190
+
165
191
  // WS ASP 옵트인 정규화. masterSecret 없으면 null = 평문 WS.
166
192
  this._wsAsp = MegaApp._normalizeWsAsp(opts.asp)
167
193
  // 브라우저↔Bridge WS 압축 (ADR-078). enabled=false → false (압축 OFF).
@@ -180,33 +206,26 @@ export class MegaApp {
180
206
  this._router = null
181
207
  /** @type {WebSocketServer|null} noServer 모드 WS 핸드셰이커 (lazy). */
182
208
  this._wss = null
183
- /** @type {MegaHubLink|null} hub 연결 (scaffold 권장). */
184
- this._hubLink = null
185
- /** @type {import('./ws-cluster.js').MegaWsCluster|null} NATS 기반 클러스터 fan-out/roster (ADR-176, boot 자동배선). */
186
- this._wsCluster = null
187
- /** @type {import('./ws-roster.js').MegaWsRedisRoster|null} 채널별 redis roster(ADR-177, boot 자동배선).
188
- * 접속자 목록(상태)을 redis HASH 로 cluster-wide 관리한다(broadcast 와 별개 — 멀티 허브 정합·즉시 스냅샷). */
189
- this._wsRoster = null
190
- /** ns(=ws path) → 활성 로컬 연결 집합 (hub broadcast 의 local fan-out 용). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
191
- this._wsConns = new Map()
192
- /** userId → 활성 로컬 연결 집합 (DIRECT 타겟팅, H-latent guard). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
193
- this._userConns = new Map()
194
- /** sessionId → 활성 로컬 연결 1개 (세션단위 JOIN/LEAVE). @type {Map<string, import('./ws-upgrade.js').MegaWsConnection>} */
195
- this._sessionConns = new Map()
196
- /** @type {string[]} connectHub 으로 구독한 채널. */
197
- this._hubChannels = []
198
- /** connectHub 의 bridgeId (재연결 재구독·세션 JOIN 에 재사용). @type {string|null} */
199
- this._hubBridgeId = null
200
209
 
201
210
  this.fastify = Fastify({
202
211
  // pino 로거(ADR-023/141) — bootApp 이 global.logger 로 만든 **인스턴스**를 주입(opts.logger). Fastify v5 는
203
212
  // 인스턴스를 `loggerInstance` 로 받는다(`logger` 는 config 객체/bool 전용). 미주입이면 logger:false(무로그,
204
213
  // 기존 동작·테스트 호환). 인스턴스를 받으면 Fastify 가 요청별 child(reqId 바인딩)를 만든다.
205
214
  ...(opts.logger ? { loggerInstance: opts.logger } : { logger: false }),
215
+ // AJV 위반 전수 수집 (ADR-193) — 디폴트는 첫 위반 1개만 보고해 envelope details 배열(ADR-075)
216
+ // 표준·WS 검증(allErrors:true, ADR-096)과 비대칭이었다. customOptions 는 @fastify/ajv-compiler 가
217
+ // 디폴트(coerceTypes/useDefaults/removeAdditional 등) **위에 merge** 하므로(소스 확인:
218
+ // lib/validator-compiler.js Object.assign) allErrors 만 바꾼다. DoS 방어는 이중: 입력은 Fastify
219
+ // bodyLimit(기본 1 MiB)이, 응답·로그는 ajv-mapper 의 details cap(MAX_VALIDATION_DETAILS)이 bound.
220
+ ajv: { customOptions: { allErrors: true } },
206
221
  // request id 자동 부여는 Fastify v5 기본 제공 (meta.request_id 용)
207
222
  ...opts.fastifyOptions,
208
223
  })
209
224
 
225
+ // WS presence/hub 협력자 — 연결 인덱스 3종·hub link·cluster/roster 동기화를 전담(ws-presence.js).
226
+ // fastify 생성 직후 만들어 WS 경로 로그가 같은 pino 인스턴스를 쓴다.
227
+ this._presence = new MegaWsPresence({ appName: this.name, log: this.fastify.log })
228
+
210
229
  // 1) 응답 시작 시각 기록 (meta.took_ms 용, ADR-014)
211
230
  this.fastify.addHook('onRequest', async (req, reply) => {
212
231
  // REPLY_START_SYMBOL 은 Fastify 타입에 없는 우리 전용 symbol 키 — 부착 시 cast.
@@ -228,6 +247,9 @@ export class MegaApp {
228
247
  path: req.url,
229
248
  host,
230
249
  app: this.name,
250
+ // inbound traceparent/tracestate 를 부모로 복원(W3C trace context, ADR-196) — 게이트웨이/업스트림
251
+ // trace 에 루트 span 이 이어진다. 무효/부재 헤더는 종전대로 새 루트(fail-safe).
252
+ headers: req.headers,
231
253
  })
232
254
  ;(/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL] = handle
233
255
  })
@@ -272,13 +294,34 @@ export class MegaApp {
272
294
  // 을 기대하므로 `{ok,data,meta}` 로 감싸면 UI 가 깨진다(ADR-140). 옵트인 OFF(_openapiPath=null)면 무영향.
273
295
  const url = /** @type {string} */ (routeOptions.url)
274
296
  if (this._openapiPath && (url === this._openapiPath || url.startsWith(`${this._openapiPath}/`))) return
297
+ // response schema 는 envelope 모양으로 합성 — raw data 모양 그대로 두면 직렬화기가 envelope 를
298
+ // 그 스키마로 직렬화해 ok/data/meta 가 통째로 사라진다(silent 데이터 소실). 합성하면 사용자는
299
+ // raw data 모양만 선언(ADR-091)하면서 strict 직렬화(ADR-020)·OpenAPI 명세 정합이 함께 성립한다.
300
+ const schema = /** @type {Record<string, any> | undefined} */ (routeOptions.schema)
301
+ if (schema?.response && typeof schema.response === 'object') {
302
+ routeOptions.schema = { ...schema, response: synthesizeEnvelopeResponseSchema(schema.response) }
303
+ }
275
304
  const existing = routeOptions.preSerialization
276
305
  const chain = existing ? (Array.isArray(existing) ? [...existing] : [existing]) : []
306
+ // 앱/전역 transform·after (ADR-194) — health/metrics 류 인프라 라우트는 제외
307
+ // (config.skipGlobalLifecycle — 프로브 응답 모양은 인프라 계약이라 사용자 전역 변환 대상 아님).
308
+ const skipGlobalLifecycle = /** @type {any} */ (routeOptions.config)?.skipGlobalLifecycle === true
309
+ if (!skipGlobalLifecycle && this._globalTransforms.length > 0) {
310
+ // 라우트(+파일) transform 뒤·envelope wrap 앞 = ADR-021 순서(라우트 → 파일 → 앱/전역 → wrap).
311
+ chain.push(composeTransform(this._globalTransforms))
312
+ }
277
313
  // async 어댑터로 감싼다: Fastify 는 done 콜백 없는 preSerialization 훅을
278
314
  // "Promise 반환(async)" 으로만 인식한다. wrapEnvelope 는 순수 동기 함수(단위 테스트·
279
315
  // 재사용 용이)라 그대로 넣으면 Fastify 가 콜백 스타일로 오인해 done 을 영원히 기다린다.
280
316
  chain.push(async (req, reply, payload) => wrapEnvelope(req, reply, payload))
281
317
  routeOptions.preSerialization = chain
318
+ if (!skipGlobalLifecycle && this._globalAfters.length > 0) {
319
+ // after: 라우트(+파일) onResponse 뒤에 앱/전역 합성 append (ADR-021 — 라우트 → 파일 → 앱/전역).
320
+ const existingAfter = routeOptions.onResponse
321
+ const afterChain = existingAfter ? (Array.isArray(existingAfter) ? [...existingAfter] : [existingAfter]) : []
322
+ afterChain.push(composeAfter(this._globalAfters, { method: String(routeOptions.method), path: url }))
323
+ routeOptions.onResponse = afterChain
324
+ }
282
325
  })
283
326
 
284
327
  // 3) 글로벌 에러 핸들러 (AJV → MegaValidationError, MegaError → envelope, ADR-090).
@@ -402,37 +445,54 @@ export class MegaApp {
402
445
  // MegaHealth 통합 — /health (liveness) + /health/ready (readiness).
403
446
  // onRoute 훅 등록 이후라 자동 envelope 적용됨. config.skip{Asp,Csrf,RateLimit} 3종이 각 보안 hook 의
404
447
  // 면제 신호다(ADR-072 면제 실효, ADR-127): ASP onRequest·CSRF preHandler·rate-limit allowList 가 검사.
405
- const HEALTH_EXEMPT = { skipAsp: true, skipCsrf: true, skipRateLimit: true }
406
-
407
- // /health liveness (항상 200)
408
- this.fastify.get('/health', { config: HEALTH_EXEMPT }, async () => ({
409
- status: 'ok',
410
- app: this.name,
411
- uptime_ms: Math.floor(process.uptime() * 1000),
412
- ts: Date.now(),
413
- }))
414
-
415
- // /health/ready — readiness (checkAll 200 or 503)
416
- this.fastify.get('/health/ready', { config: HEALTH_EXEMPT }, async (req, reply) => {
417
- const snapshot = await MegaHealth.checkAll()
418
- if (!snapshot.ok) reply.code(503)
419
- return {
448
+ // skipGlobalLifecycle(ADR-194): health/metrics 응답 모양은 인프라(프로브) 계약 사용자
449
+ // 앱/전역 transform·after 의 대상이 아니므로 제외한다(자동 envelope 는 기존대로 적용).
450
+ const HEALTH_EXEMPT = { skipAsp: true, skipCsrf: true, skipRateLimit: true, skipGlobalLifecycle: true }
451
+ const healthCfg = opts.health && typeof opts.health === 'object' ? opts.health : {}
452
+ // 헬스 경로(ADR-072/181) — 설정 가능. 미지정/빈 문자열이면 기본 /health · /health/ready.
453
+ const livePath = typeof healthCfg.paths?.live === 'string' && healthCfg.paths.live.length > 0 ? healthCfg.paths.live : '/health'
454
+ const readyPath = typeof healthCfg.paths?.ready === 'string' && healthCfg.paths.ready.length > 0 ? healthCfg.paths.ready : '/health/ready'
455
+
456
+ // health 라우트 등록 — `health.enabled:false` 면 생략(ADR-181). 기본(미지정/true)은 등록한다.
457
+ if (healthCfg.enabled !== false) {
458
+ // liveness (항상 200)
459
+ this.fastify.get(livePath, { config: HEALTH_EXEMPT }, async () => ({
460
+ status: 'ok',
420
461
  app: this.name,
421
- ...snapshot,
422
462
  uptime_ms: Math.floor(process.uptime() * 1000),
423
463
  ts: Date.now(),
424
- }
425
- })
464
+ }))
465
+
466
+ // readiness (checkAll 후 200 or 503). 응답의 체크 상세는 기본 ok 불리언만 — 체크 함수가 담는
467
+ // error 메시지·부가 필드(내부 호스트·드라이버 정보)가 비인증 경로로 새지 않게 한다(ADR-186).
468
+ // 전체 상세가 필요하면 `health.exposeCheckDetails: true` 옵트인(내부망/가드 전제, 운영자 결정).
469
+ // 실패 상세는 응답 대신 서버 로그(warn)로 — 운영자는 항상 원인을 본다.
470
+ const exposeCheckDetails = healthCfg.exposeCheckDetails === true
471
+ this.fastify.get(readyPath, { config: HEALTH_EXEMPT }, async (req, reply) => {
472
+ const snapshot = await MegaHealth.checkAll()
473
+ if (!snapshot.ok) {
474
+ reply.code(503)
475
+ this.fastify.log.warn?.({ app: this.name, checks: snapshot.checks }, 'health.ready failed')
476
+ }
477
+ const checks = exposeCheckDetails
478
+ ? snapshot.checks
479
+ : Object.fromEntries(Object.entries(snapshot.checks).map(([name, r]) => [name, { ok: /** @type {any} */ (r)?.ok === true }]))
480
+ return {
481
+ app: this.name,
482
+ ok: snapshot.ok,
483
+ checks,
484
+ uptime_ms: Math.floor(process.uptime() * 1000),
485
+ ts: Date.now(),
486
+ }
487
+ })
488
+ }
426
489
 
427
490
  // /metrics — Prometheus 옵트인 (ADR-072/131). exposeMetrics:true 일 때만 등록 →
428
491
  // 디폴트(미등록)는 Fastify 가 404(roadmap 검증 기준). /health 와 동일 보안 면제(HEALTH_EXEMPT).
429
492
  // 접근 제어 = IP allowList(ADR-131) — 빈 list 면 메인 포트 전체 노출(운영자 결정).
430
- const healthCfg = opts.health && typeof opts.health === 'object' ? opts.health : {}
431
493
  if (healthCfg.exposeMetrics === true) {
432
494
  const metricsPath = typeof healthCfg.metricsPath === 'string' && healthCfg.metricsPath.length > 0 ? healthCfg.metricsPath : '/metrics'
433
- // metricsPath 충돌 검증(ADR-072/04-data-models) — health 경로와 겹치면 부팅 throw(fail-fast).
434
- const livePath = healthCfg.paths?.live ?? '/health'
435
- const readyPath = healthCfg.paths?.ready ?? '/health/ready'
495
+ // metricsPath 충돌 검증(ADR-072/04-data-models) — health 경로(위에서 해석)와 겹치면 부팅 throw(fail-fast).
436
496
  if (metricsPath === livePath || metricsPath === readyPath) {
437
497
  throw new MegaConfigError(
438
498
  'health.metrics_path_conflict',
@@ -470,18 +530,13 @@ export class MegaApp {
470
530
  }
471
531
  }
472
532
  if (Array.isArray(opts.globalMiddlewares)) {
473
- const self = this
474
533
  for (const mw of opts.globalMiddlewares) {
475
534
  if (typeof mw !== 'function') {
476
535
  throw new TypeError(`MegaApp('${this.name}'): globalMiddlewares[] entry must be a function.`)
477
536
  }
478
- // 래퍼 arity 2(`(req, reply)`)라 Fastify async preHandler 로 인식한다(arity 3 done 콜백 모드로
479
- // 오인). 래퍼 안에서 ctx 만들어 미들웨어를 `(req, reply, ctx)` 호출한다(ADR-134). getHttpCtx 가
480
- // 요청당 캐싱이라 같은 요청의 라우트 핸들러가 동일 ctx 를 이어받는다.
481
- this.fastify.addHook('preHandler', async (req, reply) => {
482
- const ctx = getHttpCtx({ app: self, req, reply })
483
- return /** @type {any} */ (mw)(req, reply, ctx)
484
- })
537
+ // arity-2 래퍼 + canonical ctx 주입(ADR-134) 합성 정본은 pipeline.js(ADR-185). 라우트
538
+ // before/use 같은 래퍼라 같은 요청의 핸들러가 동일 ctx 이어받는다(요청당 캐싱).
539
+ this.fastify.addHook('preHandler', wrapPreHandler(/** @type {Function} */ (mw), this))
485
540
  }
486
541
  }
487
542
 
@@ -546,11 +601,6 @@ export class MegaApp {
546
601
  return Array.isArray(f._megaWsRoutes) ? f._megaWsRoutes : []
547
602
  }
548
603
 
549
- /** 현재 연결된 hub link (미연결 시 null). */
550
- get hubLink() {
551
- return this._hubLink
552
- }
553
-
554
604
  /**
555
605
  * 이 앱의 `ctx.db/cache/bus` 접근자 3종 (ADR-102). 요청 ctx 빌더(HTTP·WS)가 spread 해서 노출한다.
556
606
  * 별명→globalKey→전역 공유 어댑터로 해석하며, 미선언 별명·미등록 키는 호출 시 throw.
@@ -599,453 +649,149 @@ export class MegaApp {
599
649
  return this._bruteForce
600
650
  }
601
651
 
602
- /**
603
- * bridge hub 연결한다 (ADR-033/059). REGISTER 핸드셰이크 완료 후
604
- * 선언 채널을 구독(bridge-subscriber JOIN)하고, hub BROADCAST/DIRECT 로컬 소켓에 전달한다.
605
- *
606
- * single 모드는 embedded 종단이라 hub 가 필수는 아니지만(02-architecture §3), 멀티 인스턴스
607
- * fan-out 필요하면 single 에서도 사용 가능하다. scaffold(멀티앱/클러스터)에서 권장.
608
- *
609
- * @param {Object} config - MegaBridgeHubConfig (§2.1) + 구독 채널.
610
- * @param {string} config.url - hub URL.
611
- * @param {string} config.token - Bearer 토큰.
612
- * @param {string} config.bridgeId - 운영 식별자.
613
- * @param {string} [config.instanceId]
614
- * @param {string[]} [config.capabilities]
615
- * @param {string[]} [config.channels] - 자동 구독할 채널 목록.
616
- * @param {import('../lib/mega-retry.js').MegaRetryOptions} [config.retry] - 지정 시 재연결 활성(ADR-098).
617
- * hub 재시작·drain(4503)·네트워크 단절 시 지수 백오프로 재연결하고, 성공하면 presence(채널·세션
618
- * JOIN)를 자동 재동기화한다(hub 는 절단 시점 presence 를 잃으므로).
619
- * @param {import('./ws-compression.js').WsCompressionConfig} [config.compression] - Bridge↔Hub
620
- * link 압축(ADR-078 / MegaWsHubCompressionConfig). Global `wsHub.compression`
621
- * 블록을 그대로 전달한다 — hub 서버와 같은 스키마. 디폴트 OFF. 잘못된 threshold/windowBits 면
622
- * 즉시 throw(부팅 fail-fast).
623
- * @returns {Promise<MegaHubLink>} 등록 완료된 link.
624
- */
625
- async connectHub(config = /** @type {any} */ ({})) {
626
- const link = new MegaHubLink({
627
- url: config.url,
628
- token: config.token,
629
- bridgeId: config.bridgeId,
630
- instanceId: config.instanceId,
631
- capabilities: config.capabilities,
632
- retry: config.retry,
633
- compression: config.compression,
634
- logger: this.fastify.log,
635
- })
636
- this._hubLink = link
637
- this._hubBridgeId = config.bridgeId
638
- this._hubChannels = Array.isArray(config.channels) ? [...config.channels] : []
639
- // hub → bridge 푸시를 로컬 소켓에 전달.
640
- link.on(HUB_MESSAGE_TYPES.BROADCAST, (msg) => this._deliverBroadcast(/** @type {any} */ (msg).payload))
641
- link.on(HUB_MESSAGE_TYPES.DIRECT, (msg) => this._deliverDirect(/** @type {any} */ (msg).payload))
642
- // 허브가 fan-out 하는 presence(JOIN/LEAVE/METADATA)·heartbeat 는 broadcast 채널 멤버십·keepalive 용으로만
643
- // 의미 있다. **roster(접속자 목록)는 redis(MegaWsRedisRoster, ADR-177)가 별도 관리**하므로 브릿지는 이들을
644
- // 소비하지 않는다 — no-op 핸들러로 "no handler for type" 노이즈만 차단한다(JOIN 자체는 joinSession 이
645
- // broadcast 채널 가입용으로 계속 송신). 멀티 허브에서도 redis 가 single source-of-truth 라 명단이 정합.
646
- const noopHub = () => {}
647
- link.on(HUB_MESSAGE_TYPES.JOIN, noopHub)
648
- link.on(HUB_MESSAGE_TYPES.LEAVE, noopHub)
649
- link.on(HUB_MESSAGE_TYPES.BULK_LEAVE, noopHub)
650
- link.on(HUB_MESSAGE_TYPES.METADATA, noopHub)
651
- link.on(HUB_MESSAGE_TYPES.HEARTBEAT, noopHub)
652
- // 재연결 성공 시 presence 재동기화(ADR-098) — hub 는 절단 시점 presence 를 잃으므로 broadcast 채널을 다시 JOIN.
653
- link.on(MegaHubLink.EVENTS.RECONNECTED, () => this._resyncPresence())
654
- await link.connect()
655
-
656
- // 채널 구독 — bridge-subscriber 세션 JOIN(zero-config 브로드캐스트 수신용) + 이미 붙어 있는 실
657
- // 사용자 세션 재JOIN(예: 첫 connect 전에 클라가 연결됐을 수 있음).
658
- this._resyncPresence()
659
-
660
- // shutdown hook 누수 방지(L1) — 재연결 등으로 connectHub 가 다시 불려도 hook 은 항상 1개.
661
- const hookName = `mega-hublink:${this.name}`
662
- MegaShutdown.unregister(hookName)
663
- MegaShutdown.register(hookName, async () => link.close())
664
- return link
652
+ // ── WS presence/hub — MegaWsPresence(ws-presence.js) 위임 ─────────────────────────────
653
+ // 연결 인덱스 3종·hub link·cluster/roster 동기화는 협력자(MegaWsPresence) 전담한다.
654
+ // MegaApp 공개 표면(체이닝 포함) framework-internal(_접두) 멤버를 위임으로 보존한다
655
+ // driveWsConnection(_track/_untrack)·boot 자동배선(_deliver*/localRosterMembers)·기존 테스트 호환.
656
+
657
+ /** 현재 연결된 hub link (미연결 null). */
658
+ get hubLink() {
659
+ return this._presence.hubLink
665
660
  }
666
661
 
667
662
  /**
668
- * hub presence 재동기화 — bridge-subscriber 채널 JOIN + 활성 사용자 세션 JOIN 모두 다시 보낸다.
669
- * 최초 등록 직후와 재연결(RECONNECTED) 직후에 호출된다. hub 의 JOIN 처리는 멱등(같은 sessionId 덮어씀).
670
- * @returns {void}
671
- * @private
663
+ * bridge hub 연결한다 (ADR-033/059) 계약·옵션은 {@link MegaWsPresence#connectHub} 정본.
664
+ * @param {any} [config] - MegaBridgeHubConfig(§2.1) + 구독 채널.
665
+ * @returns {Promise<import('./hub-link.js').MegaHubLink>} 등록 완료된 link.
672
666
  */
673
- _resyncPresence() {
674
- const link = this._hubLink
675
- if (!link?.isRegistered) return
676
- const bridgeId = this._hubBridgeId ?? this.name
677
- // 1) bridge-subscriber JOIN — bridge 가 채널 멤버가 되어 zero-config 브로드캐스트를 받게 한다.
678
- for (const ch of this._hubChannels) {
679
- link.join({
680
- userId: `bridge:${bridgeId}`,
681
- sessionId: `bridge:${bridgeId}#${ch}`,
682
- channels: [ch],
683
- })
684
- }
685
- // 2) 실 사용자 세션 JOIN — joinSession 으로 매핑된 활성 세션을 다시 등록(DIRECT 타겟 복구).
686
- // 채널 + metadata 까지 재동기화한다(M-1) — hub 는 절단 시점 presence 를 통째로 잃으므로,
687
- // metadata 를 빠뜨리면 재연결 후 hub presence 의 메타가 silent 사라진다.
688
- for (const [sessionId, conn] of this._sessionConns) {
689
- if (!conn.isOpen) continue
690
- link.join({
691
- userId: /** @type {string} */ (conn.userId),
692
- sessionId,
693
- channels: conn.channels ? [...conn.channels] : [],
694
- ...(conn.metadata ? { metadata: conn.metadata } : {}),
695
- })
696
- }
667
+ async connectHub(config) {
668
+ return this._presence.connectHub(config)
697
669
  }
698
670
 
699
671
  /**
700
- * 실 사용자 세션을 hub presence 에 등록하고 로컬 매핑을 만든다 (OQ-010/ADR-098).
701
- *
702
- * 표준 패턴: WS upgrade `before` 미들웨어가 인증 신원을 `ctx.auth` 로 싣고(ADR-091 DI),
703
- * 채널의 `onConnect(sock, ctx)` 에서 `ctx.app.joinSession(sock, { userId: ctx.auth.userId, ... })`
704
- * 를 호출한다. 이 매핑이 있어야 DIRECT 가 **해당 userId 세션에만** 전달된다(cross-user flood 방지,
705
- * H-latent guard). 매핑 없는 연결은 DIRECT 대상에서 제외된다.
706
- *
707
- * @param {import('./ws-upgrade.js').MegaWsConnection} conn - onConnect 가 받은 소켓 래퍼.
708
- * @param {Object} entry
709
- * @param {string} entry.userId - 인증된 사용자 식별자(비어 있으면 throw).
710
- * @param {string} entry.sessionId - 세션 식별자(비어 있으면 throw). 전역 유일 권장.
711
- * @param {string[]} [entry.channels] - 가입 채널 목록.
712
- * @param {Object} [entry.metadata] - presence 메타데이터(명시 필드만, ADR-059).
672
+ * 실 사용자 세션을 hub presence 에 등록하고 로컬 매핑을 만든다 — {@link MegaWsPresence#joinSession} 위임.
673
+ * @param {import('./ws-upgrade.js').MegaWsConnection} conn
674
+ * @param {{ userId: string, sessionId: string, channels?: string[], metadata?: Object }} [entry]
713
675
  * @returns {this}
714
- * @throws {Error} conn/userId/sessionId 누락 시 — 잘못된 매핑을 silent 통과시키지 않는다.
715
676
  */
716
- joinSession(conn, { userId, sessionId, channels = [], metadata } = /** @type {any} */ ({})) {
717
- if (!conn || typeof conn.send !== 'function') {
718
- throw new Error('MegaApp.joinSession: conn (MegaWsConnection) is required.')
719
- }
720
- if (typeof userId !== 'string' || userId.length === 0) {
721
- throw new Error('MegaApp.joinSession: userId (non-empty string) is required.')
722
- }
723
- if (typeof sessionId !== 'string' || sessionId.length === 0) {
724
- throw new Error('MegaApp.joinSession: sessionId (non-empty string) is required.')
725
- }
726
- const chans = Array.isArray(channels) ? [...channels] : []
727
-
728
- // L-4: 같은 sessionId 가 다른 conn 으로 다시 join 되면(전역 유일 계약 위반) 옛 conn 을 인덱스에서
729
- // 떼어 dangling 을 막는다 — 단 소켓 자체는 닫지 않는다(클라가 정리). 옛 conn 의 신원도 비워,
730
- // 이후 옛 conn 의 close(_untrackWsConn)가 새 conn 이 차지한 sessionId 로 LEAVE 를 잘못 보내지
731
- // 않게 한다(그대로 두면 새 세션의 hub presence 가 silent 제거됨).
732
- const prior = this._sessionConns.get(sessionId)
733
- if (prior && prior !== conn) {
734
- this.fastify.log?.warn?.(
735
- { app: this.name, sessionId, priorUserId: prior.userId, userId },
736
- 'ws.joinSession duplicate sessionId — prior conn left dangling (detached, not closed)',
737
- )
738
- if (prior.userId !== undefined) {
739
- const pset = this._userConns.get(prior.userId)
740
- if (pset) {
741
- pset.delete(prior)
742
- if (pset.size === 0) this._userConns.delete(prior.userId)
743
- }
744
- }
745
- if (prior.ns !== undefined) {
746
- const nsset = this._wsConns.get(prior.ns)
747
- if (nsset) {
748
- nsset.delete(prior)
749
- if (nsset.size === 0) this._wsConns.delete(prior.ns)
750
- }
751
- }
752
- prior.userId = undefined
753
- prior.sessionId = undefined
754
- prior.channels = null
755
- }
756
-
757
- // 연결에 신원 부착(매핑 키). _untrackWsConn 이 close 시 이 값으로 인덱스를 정리한다.
758
- conn.userId = userId
759
- conn.sessionId = sessionId
760
- conn.channels = new Set(chans)
761
- conn.metadata = metadata // M-1: 재연결 재동기화(_resyncPresence)가 보존할 수 있게 저장.
762
-
763
- let uset = this._userConns.get(userId)
764
- if (!uset) {
765
- uset = new Set()
766
- this._userConns.set(userId, uset)
767
- }
768
- uset.add(conn)
769
- this._sessionConns.set(sessionId, conn)
770
-
771
- this.fastify.log?.debug?.({ app: this.name, userId, sessionId, channels: chans }, 'ws.joinSession')
772
- // hub presence 등록 — 등록 상태일 때만(미연결/재연결 중이면 _resyncPresence 가 나중에 복구).
773
- if (this._hubLink?.isRegistered) {
774
- this._hubLink.join({ userId, sessionId, channels: chans, ...(metadata ? { metadata } : {}) })
775
- }
776
- // NATS roster 동기화 (ADR-176) — 프레임워크가 클러스터 접속자 목록을 자동 관리한다(개발자 코드 불요).
777
- // ns 는 연결의 namespace. roster:'none' 이면 로컬만 갱신한다.
778
- if (this._wsCluster && typeof conn.ns === 'string') {
779
- this._wsCluster.rosterAdd({ ns: conn.ns, sessionId, userId, ...(metadata ? { metadata } : {}) })
780
- }
781
- // redis roster 동기화 (ADR-177) — **채널별** 접속자 목록을 redis HASH 에 add(멀티 허브 정합). best-effort.
782
- if (this._wsRoster && chans.length > 0) {
783
- const member = { userId, ...(metadata ? { metadata } : {}) }
784
- for (const ch of chans) {
785
- this._wsRoster.add(ch, sessionId, member).catch((err) =>
786
- this.fastify.log?.warn?.({ err, channel: ch, app: this.name }, 'ws-roster add failed'),
787
- )
788
- }
789
- }
677
+ joinSession(conn, entry) {
678
+ this._presence.joinSession(conn, entry)
790
679
  return this
791
680
  }
792
681
 
793
682
  /**
794
- * 채널 broadcast — 로컬 ns 소켓에 즉시 전달 + (hub 연결 시) 클러스터 fan-out.
795
- *
683
+ * 채널 broadcast — 로컬 즉시 전달 + (hub/NATS 연결 시) 클러스터 fan-out. {@link MegaWsPresence#broadcast} 위임.
796
684
  * @param {{ ns: string, channel: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }} args
797
685
  * @returns {void}
798
- * @throws {Error} message.type(string) 누락 시 — 호출부 입력 오류를 silent drop 하지 않고 즉시 알린다(L6).
799
686
  */
800
- broadcast({ ns, channel, message, exceptSessionIds }) {
801
- // 입력 검증을 한곳에서(L6) — 로컬은 받고 hub 는 message.type 없이 전송하던 비대칭을 제거.
802
- if (!message || typeof message.type !== 'string') {
803
- throw new Error('MegaApp.broadcast: message.type (string) is required')
804
- }
805
- this._deliverBroadcast({ ns, channel, message, exceptSessionIds })
806
- if (this._hubLink?.isRegistered) {
807
- // L-7: 빈 배열도 truthy 라 `exceptSessionIds: []` 가 wire 로 새던 비대칭 제거 — 비어 있으면 생략.
808
- const hasExcept = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0
809
- try {
810
- this._hubLink.broadcast({ ns, channel, message, ...(hasExcept ? { exceptSessionIds } : {}) })
811
- } catch (err) {
812
- // 로컬 전달은 이미 성공. 클러스터 fan-out 은 best-effort(at-most-once, ADR-033)이며 소켓이
813
- // 닫히는 중이면 비치명적 — 재연결 시 presence 가 재동기화된다. warn 후 호출자 보호.
814
- const log = /** @type {any} */ (this.fastify.log)
815
- log?.warn?.({ err, ns, channel, app: this.name }, 'app.broadcast hub fan-out failed (local delivered)')
816
- }
817
- }
818
- // NATS 클러스터 fan-out (ADR-176, boot 자동배선). 로컬은 위에서 전달했고, 다른 인스턴스는 구독으로
819
- // 받아 각자 전달한다(echo 는 instanceId 로 스킵). publish 실패는 best-effort — local 은 이미 성공.
820
- if (this._wsCluster) {
821
- this._wsCluster.publishBroadcast({ ns, channel, message, exceptSessionIds }).catch((err) => {
822
- const log = /** @type {any} */ (this.fastify.log)
823
- log?.warn?.({ err, ns, channel, app: this.name }, 'app.broadcast nats fan-out failed (local delivered)')
824
- })
825
- }
687
+ broadcast(args) {
688
+ this._presence.broadcast(args)
826
689
  }
827
690
 
828
691
  /**
829
- * 특정 사용자에게 직접 전송 (directToUser, ADR-035) — 로컬에서 userId 로 매핑된 연결에만 전달
830
- * (H-latent guard) + (hub 연결 시) 클러스터 fan-out(다른 bridge 의 같은 userId 세션까지).
831
- *
832
- * {@link MegaApp#joinSession} 으로 매핑된 연결만 대상이다 — 매핑 없는 userId 면 로컬 no-op.
833
- *
834
- * @param {string} userId - 대상 사용자.
835
- * @param {{ type: string, payload?: Object }} message - 내부 envelope `{ type, payload }`.
692
+ * 특정 사용자에게 직접 전송 (ADR-035) — {@link MegaWsPresence#directToUser} 위임.
693
+ * @param {string} userId
694
+ * @param {{ type: string, payload?: Object }} message
836
695
  * @returns {void}
837
- * @throws {Error} userId/message.type 누락 시(broadcast 와 동일한 입력 보호, L6).
838
696
  */
839
697
  directToUser(userId, message) {
840
- if (typeof userId !== 'string' || userId.length === 0) {
841
- throw new Error('MegaApp.directToUser: userId (non-empty string) is required')
842
- }
843
- if (!message || typeof message.type !== 'string') {
844
- throw new Error('MegaApp.directToUser: message.type (string) is required')
845
- }
846
- this._deliverDirect({ userId, message })
847
- if (this._hubLink?.isRegistered) {
848
- try {
849
- this._hubLink.direct({ userId, message })
850
- } catch (err) {
851
- // 로컬 전달은 이미 성공. 클러스터 fan-out 은 best-effort(at-most-once, ADR-033). warn 후 보호.
852
- const log = /** @type {any} */ (this.fastify.log)
853
- log?.warn?.({ err, userId, app: this.name }, 'app.directToUser hub fan-out failed (local delivered)')
854
- }
855
- }
856
- // NATS 클러스터 direct (ADR-176) — 다른 인스턴스의 같은 userId 세션까지. echo 는 instanceId 로 스킵.
857
- if (this._wsCluster) {
858
- this._wsCluster.publishDirect(userId, message).catch((err) => {
859
- const log = /** @type {any} */ (this.fastify.log)
860
- log?.warn?.({ err, userId, app: this.name }, 'app.directToUser nats fan-out failed (local delivered)')
861
- })
862
- }
698
+ this._presence.directToUser(userId, message)
863
699
  }
864
700
 
865
701
  /**
866
- * 세션 presence 메타데이터 갱신 (METADATA, ADR-059) — 로컬 conn 에 저장 + (hub 연결 시) 전파.
867
- *
868
- * 로컬 conn 의 `metadata` 를 갱신해 두면 이후 재연결 시 {@link MegaApp#_resyncPresence} 가 최신
869
- * 메타까지 복구한다(M-1). 매핑 없는 sessionId 면 no-op(로컬 저장 없이 hub 전파만은 하지 않음 —
870
- * 재연결 보존 대상이 없으므로). broadcast/directToUser 와 같은 best-effort fan-out.
871
- *
872
- * @param {string} sessionId - 대상 세션(joinSession 으로 매핑된 것).
873
- * @param {Object} metadata - 갱신할 메타데이터(명시 필드만).
702
+ * 세션 presence 메타데이터 갱신 (ADR-059) — {@link MegaWsPresence#updateMetadata} 위임.
703
+ * @param {string} sessionId
704
+ * @param {Object} metadata
874
705
  * @returns {this}
875
- * @throws {Error} sessionId/metadata 누락 시(입력 보호, L6 와 동일 원칙).
876
706
  */
877
707
  updateMetadata(sessionId, metadata) {
878
- if (typeof sessionId !== 'string' || sessionId.length === 0) {
879
- throw new Error('MegaApp.updateMetadata: sessionId (non-empty string) is required')
880
- }
881
- if (!metadata || typeof metadata !== 'object') {
882
- throw new Error('MegaApp.updateMetadata: metadata (object) is required')
883
- }
884
- const conn = this._sessionConns.get(sessionId)
885
- if (!conn) {
886
- // 매핑 없는 세션 — 재연결로 보존할 로컬 대상이 없으므로 no-op(다른 bridge 세션은 그쪽이 관리).
887
- this.fastify.log?.debug?.({ app: this.name, sessionId }, 'ws.updateMetadata — no local session (no-op)')
888
- return this
889
- }
890
- conn.metadata = metadata // 재연결 재동기화가 최신 메타를 복구하도록 저장(M-1).
891
- if (this._hubLink?.isRegistered) {
892
- try {
893
- this._hubLink.updateMetadata({ sessionId, metadata })
894
- } catch (err) {
895
- // hub 전파 실패는 비치명적 — 로컬 저장은 됐고 재연결 시 _resyncPresence 가 복구.
896
- const log = /** @type {any} */ (this.fastify.log)
897
- log?.warn?.({ err, sessionId, app: this.name }, 'app.updateMetadata hub propagate failed (local stored)')
898
- }
899
- }
708
+ this._presence.updateMetadata(sessionId, metadata)
900
709
  return this
901
710
  }
902
711
 
903
712
  /**
904
- * NATS 클러스터 fan-out/roster 이 앱에 배선한다 (ADR-176). boot `wsCluster` config 보고
905
- * 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
713
+ * NATS 클러스터 fan-out/roster 배선 (ADR-176, boot 자동 호출) {@link MegaWsPresence#setWsCluster} 위임.
906
714
  * @param {import('./ws-cluster.js').MegaWsCluster|null} cluster
907
715
  * @returns {this}
908
716
  */
909
717
  setWsCluster(cluster) {
910
- this._wsCluster = cluster
718
+ this._presence.setWsCluster(cluster)
911
719
  return this
912
720
  }
913
721
 
914
722
  /**
915
- * 해당 ns(WS 채널 경로) **클러스터 전역 접속자 목록**을 반환한다 (ADR-176). `wsCluster` 자동배선 +
916
- * `joinSession`/disconnect 훅으로 프레임워크가 동기화하므로 개발자는 roster 코드를 짜지 않고 읽기만 한다.
917
- * wsCluster 미배선(또는 roster:'none')이면 로컬 멤버만 반환한다.
918
- *
919
- * @param {string} ns - WS namespace(채널 경로, 예: '/ws/chat').
723
+ * ns(WS 채널 경로) 기준 접속자 목록 (ADR-176) {@link MegaWsPresence#roster} 위임.
724
+ * @param {string} ns
920
725
  * @returns {Array<{ sessionId: string, userId: string, metadata?: Object }>}
921
726
  */
922
727
  roster(ns) {
923
- if (this._wsCluster) return this._wsCluster.roster(ns) // NATS: 이미 cluster-wide(roster 동기화 포함).
924
- // ns 기준 로컬 명단(동기 API). **채널 기준 redis roster(ADR-177)** 는 `ctx.presence.list()`(async)가
925
- // 다룬다 — 이 메서드는 NATS/로컬용 ns 기준 동기 API 로 유지(하위호환).
926
- /** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
927
- const out = []
928
- for (const [sessionId, conn] of this._sessionConns) {
929
- if (conn.ns !== ns || !conn.isOpen) continue
930
- out.push({ sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
931
- }
932
- return out
728
+ return this._presence.roster(ns)
933
729
  }
934
730
 
935
731
  /**
936
- * 채널별 redis roster(ADR-177)를 앱에 배선한다. boot `bridgeHub.roster.driver==='redis'` 일 때
937
- * 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
732
+ * 채널별 redis roster 배선 (ADR-177, boot 자동 호출) {@link MegaWsPresence#setWsRoster} 위임.
938
733
  * @param {import('./ws-roster.js').MegaWsRedisRoster|null} roster
939
734
  * @returns {this}
940
735
  */
941
736
  setWsRoster(roster) {
942
- this._wsRoster = roster
737
+ this._presence.setWsRoster(roster)
943
738
  return this
944
739
  }
945
740
 
946
741
  /**
947
- * 주어진 **채널들**의 cluster-wide 접속자 목록 — redis roster(원격 포함) + 로컬 세션을 병합한다(ADR-177).
948
- * 로컬을 항상 포함해 join 직후 redis HSET 반영 전에도 본인이 빠지지 않게 한다. `ctx.presence.list()` 의
949
- * redis 경로가 호출(async). roster 미배선이면 로컬 채널 멤버만.
742
+ * 채널들의 cluster-wide 접속자 목록 (ADR-177) {@link MegaWsPresence#presenceList} 위임.
950
743
  * @param {string[]} channels
951
744
  * @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
952
745
  */
953
746
  async presenceList(channels) {
954
- const want = new Set(Array.isArray(channels) ? channels : [])
955
- /** @type {Map<string, { sessionId: string, userId: string, metadata?: Object }>} */
956
- const out = new Map()
957
- // 로컬 세션(이 워커) — 요청 채널에 가입한 것. 항상 fresh(본인 포함).
958
- for (const [sessionId, conn] of this._sessionConns) {
959
- if (!conn.isOpen || !conn.channels) continue
960
- let inCh = false
961
- for (const ch of conn.channels) {
962
- if (want.has(ch)) {
963
- inCh = true
964
- break
965
- }
966
- }
967
- if (inCh) out.set(sessionId, { sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
968
- }
969
- // redis(cluster-wide) — 다른 워커/허브의 세션까지.
970
- if (this._wsRoster) {
971
- // 여러 채널 redis 조회를 **병렬화**(Promise.all) — onConnect 핫패스의 직렬 라운드트립 누적 제거(Med).
972
- const lists = await Promise.all([...want].map((ch) => this._wsRoster.list(ch)))
973
- for (const list of lists) for (const m of list) if (!out.has(m.sessionId)) out.set(m.sessionId, m)
974
- }
975
- return [...out.values()]
747
+ return this._presence.presenceList(channels)
976
748
  }
977
749
 
978
750
  /**
979
- * 이 워커의 로컬 멤버 목록 redis roster heartbeat 갱신 대상(ADR-177). joinSession 으로 매핑된
980
- * (채널 × 세션)마다 1개. MegaWsRedisRoster 가 주기적으로 이 목록의 expiresAt 을 갱신한다.
751
+ * 이 워커의 로컬 멤버 목록 (redis roster heartbeat 갱신 대상, ADR-177) {@link MegaWsPresence#localRosterMembers} 위임.
981
752
  * @returns {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>}
982
753
  */
983
754
  localRosterMembers() {
984
- /** @type {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>} */
985
- const out = []
986
- for (const [sessionId, conn] of this._sessionConns) {
987
- if (!conn.isOpen || !conn.channels) continue
988
- const member = { userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) }
989
- for (const ch of conn.channels) out.push({ channel: ch, sessionId, member })
990
- }
991
- return out
755
+ return this._presence.localRosterMembers()
992
756
  }
993
757
 
994
758
  /**
995
- * broadcast payload 로컬 ns 소켓에 전달한다. message `{ type, payload }` 내부 envelope.
996
- *
997
- * `exceptSessionIds` 에 든 sessionId 로 매핑된 연결은 제외한다(ADR-098). 세션 매핑이 없는
998
- * 연결(zero-config·미JOIN)은 sessionId 가 없어 제외 대상에 걸리지 않으므로 그대로 받는다.
999
- *
759
+ * broadcast payload 로컬 전달 (framework-internal boot MegaWsCluster 배선이 사용).
1000
760
  * @param {{ ns: string, channel?: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }} payload
1001
761
  * @returns {void}
1002
762
  * @private
1003
763
  */
1004
- _deliverBroadcast({ ns, message, exceptSessionIds }) {
1005
- const set = this._wsConns.get(ns)
1006
- if (!set || !message || typeof message.type !== 'string') return
1007
- const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
1008
- for (const conn of set) {
1009
- if (!conn.isOpen) continue
1010
- if (except && conn.sessionId !== undefined && except.has(conn.sessionId)) continue
1011
- conn.send({ type: message.type, ns, payload: message.payload })
1012
- }
764
+ _deliverBroadcast(payload) {
765
+ this._presence._deliverBroadcast(payload)
1013
766
  }
1014
767
 
1015
768
  /**
1016
- * direct payload **해당 userId 매핑된 로컬 연결에만** 전달한다 (H-latent guard).
1017
- *
1018
- * 초기에는 매핑이 없어 모든 연결에 flood 됐다(cross-user 누출). {@link MegaApp#joinSession}
1019
- * 으로 만든 `userId → 연결` 매핑을 통해 대상 사용자에게만 보낸다. 매핑이 없는 userId 면 no-op
1020
- * (다른 사용자에게 새지 않음).
1021
- *
769
+ * direct payload 로컬 전달 (framework-internal boot MegaWsCluster 배선이 사용).
1022
770
  * @param {{ userId: string, message: { type: string, payload?: Object } }} payload
1023
771
  * @returns {void}
1024
772
  * @private
1025
773
  */
1026
- _deliverDirect({ userId, message }) {
1027
- if (!message || typeof message.type !== 'string') return
1028
- if (typeof userId !== 'string' || userId.length === 0) return
1029
- const set = this._userConns.get(userId)
1030
- if (!set) return
1031
- for (const conn of set) {
1032
- if (conn.isOpen) conn.send({ type: message.type, payload: message.payload })
1033
- }
774
+ _deliverDirect(payload) {
775
+ this._presence._deliverDirect(payload)
776
+ }
777
+
778
+ /**
779
+ * hub 의 DISCONNECT(admin-kick, ADR-097) 라우팅 처리 (framework-internal).
780
+ * @param {{ sessionId: string, reason?: string, requeue?: boolean }} payload
781
+ * @returns {void}
782
+ * @private
783
+ */
784
+ _handleHubDisconnect(payload) {
785
+ this._presence._handleHubDisconnect(payload)
1034
786
  }
1035
787
 
1036
788
  /**
1037
- * 로컬 WS 연결 등록 (driveWsConnection 이 호출 — framework-internal). hub broadcast 의 local 전달 대상.
789
+ * 로컬 WS 연결 등록 (driveWsConnection 이 호출 — framework-internal).
1038
790
  * @param {import('./ws-upgrade.js').MegaWsConnection} conn
1039
791
  * @returns {void}
1040
792
  */
1041
793
  _trackWsConn(conn) {
1042
- if (!conn.ns) return
1043
- let set = this._wsConns.get(conn.ns)
1044
- if (!set) {
1045
- set = new Set()
1046
- this._wsConns.set(conn.ns, set)
1047
- }
1048
- set.add(conn)
794
+ this._presence._trackWsConn(conn)
1049
795
  }
1050
796
 
1051
797
  /**
@@ -1054,42 +800,43 @@ export class MegaApp {
1054
800
  * @returns {void}
1055
801
  */
1056
802
  _untrackWsConn(conn) {
1057
- const set = conn.ns ? this._wsConns.get(conn.ns) : undefined
1058
- if (set) {
1059
- set.delete(conn)
1060
- if (set.size === 0) this._wsConns.delete(conn.ns)
1061
- }
1062
- // 세션·유저 매핑 정리 + hub presence LEAVE (joinSession 으로 매핑된 연결만).
1063
- if (conn.userId !== undefined) {
1064
- const uset = this._userConns.get(conn.userId)
1065
- if (uset) {
1066
- uset.delete(conn)
1067
- if (uset.size === 0) this._userConns.delete(conn.userId)
1068
- }
1069
- }
1070
- if (conn.sessionId !== undefined) {
1071
- // 같은 sessionId 가 다른(새) 연결로 교체된 경우엔 이 연결만 지운다(오래된 연결의 close 가
1072
- // 새 매핑을 지우지 않게 동일성 확인).
1073
- if (this._sessionConns.get(conn.sessionId) === conn) this._sessionConns.delete(conn.sessionId)
1074
- if (this._hubLink?.isRegistered) {
1075
- try {
1076
- this._hubLink.leave(conn.sessionId)
1077
- } catch (err) {
1078
- // 소켓이 닫히는 중이면 LEAVE 송신 실패 — 비치명적. hub 의 bridge-gone 정리가 보강한다.
1079
- this.fastify.log?.debug?.({ err, sessionId: conn.sessionId, app: this.name }, 'ws.leave send failed')
1080
- }
1081
- }
1082
- // NATS roster 제거 (ADR-176) — disconnect 시 클러스터 접속자 목록에서 자동 제거(개발자 코드 불요).
1083
- this._wsCluster?.rosterRemove(conn.sessionId)
1084
- // redis roster 제거 (ADR-177) — 이 세션이 가입한 모든 채널에서 제거. best-effort.
1085
- if (this._wsRoster && conn.channels) {
1086
- for (const ch of conn.channels) {
1087
- this._wsRoster.remove(ch, /** @type {string} */ (conn.sessionId)).catch((err) =>
1088
- this.fastify.log?.warn?.({ err, channel: ch, app: this.name }, 'ws-roster remove failed'),
1089
- )
1090
- }
1091
- }
1092
- }
803
+ this._presence._untrackWsConn(conn)
804
+ }
805
+
806
+ // 상태 필드 호환 접근자 — 외부 @private 사용처(ws-upgrade `_wsRoster` 게이트, 테스트의 mock 주입·
807
+ // 검증)가 분리 전 필드명을 그대로 쓸 수 있게 presence 로 위임한다.
808
+ /** @returns {import('./hub-link.js').MegaHubLink|null} */
809
+ get _hubLink() {
810
+ return this._presence._hubLink
811
+ }
812
+ set _hubLink(v) {
813
+ this._presence._hubLink = v
814
+ }
815
+ /** @returns {import('./ws-cluster.js').MegaWsCluster|null} */
816
+ get _wsCluster() {
817
+ return this._presence._wsCluster
818
+ }
819
+ set _wsCluster(v) {
820
+ this._presence._wsCluster = v
821
+ }
822
+ /** @returns {import('./ws-roster.js').MegaWsRedisRoster|null} */
823
+ get _wsRoster() {
824
+ return this._presence._wsRoster
825
+ }
826
+ set _wsRoster(v) {
827
+ this._presence._wsRoster = v
828
+ }
829
+ /** @returns {Map<string, import('./ws-upgrade.js').MegaWsConnection>} */
830
+ get _sessionConns() {
831
+ return this._presence._sessionConns
832
+ }
833
+ /** @returns {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
834
+ get _userConns() {
835
+ return this._presence._userConns
836
+ }
837
+ /** @returns {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
838
+ get _wsConns() {
839
+ return this._presence._wsConns
1093
840
  }
1094
841
 
1095
842
  /**
@@ -1153,7 +900,11 @@ export class MegaApp {
1153
900
  // WebSocket 인스턴스 생성됨 → ws 가 소켓 error 를 인수한다. 임시 가드 해제.
1154
901
  detachSocketGuard()
1155
902
  const codec = this._buildWsCodec(route, req)
1156
- driveWsConnection({ raw, req, route, app: this, codec, log, auth })
903
+ // 핸드셰이크에서 협상된 envelope 버전(_ensureWss handleProtocols, `mega.v<N>` subprotocol).
904
+ // 미협상(레거시·subprotocol 없음)이면 v1 — 연결별 envelope 검증 기준이 된다.
905
+ const m = WS_SUBPROTOCOL_PATTERN.exec(raw.protocol ?? '')
906
+ const protocolVersion = m ? Number(m[1]) : WS_PROTOCOL_VERSION
907
+ driveWsConnection({ raw, req, route, app: this, codec, log, auth, protocolVersion })
1157
908
  })
1158
909
  })
1159
910
  .catch((err) => {
@@ -1202,6 +953,16 @@ export class MegaApp {
1202
953
  noServer: true,
1203
954
  perMessageDeflate: this._wsPerMessageDeflate,
1204
955
  maxPayload: this._wsMaxPayloadBytes,
956
+ // envelope 버전 협상 — 클라가 `mega.v<N>` subprotocol 을 제안하면 최고 상호 버전을 채택해
957
+ // 응답 Sec-WebSocket-Protocol 로 확정 통지한다. mega.* 미제안(레거시)·상호 버전 없음이면
958
+ // subprotocol 없이 수락(= v1 폴백) — 핸드셰이크를 절대 거부하지 않아 구버전 클라가 깨지지 않는다.
959
+ // ⚠️ ws 기본(handleProtocols 미설정)은 첫 제안 토큰을 맹목 echo 한다(미지원 버전을 "지원한다"고
960
+ // 답하는 거짓 신호) — 명시 핸들러가 협상 정확성의 전제다.
961
+ handleProtocols: (/** @type {Set<string>} */ protocols) => {
962
+ const result = negotiateWsProtocol(protocols)
963
+ if (!result || result.subprotocol === undefined) return false // subprotocol 미선택 = v1 폴백.
964
+ return result.subprotocol
965
+ },
1205
966
  })
1206
967
  }
1207
968
  return this._wss
@@ -1252,31 +1013,8 @@ export class MegaApp {
1252
1013
 
1253
1014
  /** 인스턴스 close — hub link · WS 연결 정리 후 진행 중 요청 grace 후 종료. */
1254
1015
  async close() {
1255
- // hub link 먼저 끊는다 (더 이상 fan-out 수신 불필요). shutdown hook 함께 떼어 누수 방지(L1).
1256
- if (this._hubLink) {
1257
- this._hubLink.close()
1258
- this._hubLink = null
1259
- this._hubBridgeId = null
1260
- MegaShutdown.unregister(`mega-hublink:${this.name}`)
1261
- }
1262
- // NATS 클러스터 정리(ADR-176) — 구독·heartbeat/sweep 타이머·roster 멤버 해제. boot 의 shutdown hook 과
1263
- // **양쪽**에서 정리해야 시그널을 안 거치는 종료(server.close()/프로그램적 재시작/멀티앱 부분 종료)에서도
1264
- // 누수가 없다(_hubLink/_wsRoster 와 동일 대칭, H1). stop() 은 _isStarted 가드로 멱등이라 중복 안전.
1265
- if (this._wsCluster) {
1266
- await this._wsCluster.stop().catch((err) => this.fastify.log?.warn?.({ err, app: this.name }, 'ws-cluster stop failed'))
1267
- this._wsCluster = null
1268
- MegaShutdown.unregister(`mega-ws-cluster:${this.name}`)
1269
- }
1270
- // redis roster 정리(ADR-177) — heartbeat 중지 + 로컬 멤버 즉시 제거. _sessionConns 를 비우기 **전에**
1271
- // 호출해야 stop() 이 localRosterMembers 로 자기 멤버를 redis 에서 뺄 수 있다.
1272
- if (this._wsRoster) {
1273
- await this._wsRoster.stop().catch((err) => this.fastify.log?.warn?.({ err, app: this.name }, 'ws-roster stop failed'))
1274
- this._wsRoster = null
1275
- MegaShutdown.unregister(`mega-wsroster:${this.name}`)
1276
- }
1277
- this._wsConns.clear()
1278
- this._userConns.clear()
1279
- this._sessionConns.clear()
1016
+ // WS presence 정리(hub link cluster roster 연결 인덱스, 협력자가 자기 hook 까지 짝맞춰 해제).
1017
+ await this._presence.close()
1280
1018
  // 활성 WS 연결을 먼저 정리 (1001 going away). noServer wss 는 clientTracking 기본 on.
1281
1019
  if (this._wss) {
1282
1020
  for (const client of this._wss.clients) client.close(1001, 'server shutting down')
@@ -1284,6 +1022,10 @@ export class MegaApp {
1284
1022
  this._wss = null
1285
1023
  }
1286
1024
  await this.fastify.close()
1025
+ // 생성자가 등록한 자기 close hook 도 해제 — hublink/ws-cluster/wsroster/session 4종과 동일한
1026
+ // 짝맞춤. 남겨두면 graceful shutdown 시 이미 닫힌 fastify 를 중복 close 하고, 앱을 만들고 닫는
1027
+ // 장수 프로세스(테스트·동적 마운트)에서 handlers 배열이 누적된다.
1028
+ MegaShutdown.unregister(`mega-app:${this.name}`)
1287
1029
  // 세션 store 정리 (있으면) + shutdown hook 해제(누수 방지, hublink 패턴 정합).
1288
1030
  if (this._sessionStore) {
1289
1031
  MegaShutdown.unregister(`mega-session:${this.name}`)