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