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
@@ -0,0 +1,624 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaWsPresence — 앱의 WS presence/클러스터 동기화 협력자 (MegaApp 에서 분리).
4
+ *
5
+ * 책임: ① 로컬 연결 인덱스 3종(ns→연결·userId→연결·sessionId→연결) 관리(_track/_untrack),
6
+ * ② hub link(connectHub — BROADCAST/DIRECT 수신·presence 재동기화·admin-kick), ③ NATS
7
+ * wsCluster/redis wsRoster 동기화, ④ broadcast/directToUser/updateMetadata 의 로컬+클러스터
8
+ * fan-out, ⑤ roster/presenceList 조회. MegaApp 은 같은 이름의 공개 메서드를 본 협력자에
9
+ * 위임한다(분리 전 호출부·테스트와 표면 동일).
10
+ *
11
+ * MegaApp(HTTP 앱 골격: 보안/세션/i18n/템플릿/헬스/upgrade 핸드셰이크)과 분리한 이유 —
12
+ * 연결 인덱스 불변식(joinSession 의 dangling 정리, _untrack 의 동일성 확인)이 한 모듈에
13
+ * 모여야 검증·유지보수가 좁아진다. 분리는 위임 기반이라 동작 변화 0.
14
+ *
15
+ * @module core/ws-presence
16
+ */
17
+ import { MegaHubLink } from './hub-link.js'
18
+ import { CLOSE_CODE_REQUEUE } from './ws-upgrade.js'
19
+ import { HUB_MESSAGE_TYPES } from '../lib/hub-protocol.js'
20
+ import { MegaShutdown } from '../lib/mega-shutdown.js'
21
+
22
+ export class MegaWsPresence {
23
+ /**
24
+ * @param {Object} opts
25
+ * @param {string} opts.appName - 소유 앱 이름(hook 이름·로그 식별자).
26
+ * @param {import('pino').Logger | { debug?: Function, warn?: Function } | null} [opts.log] -
27
+ * 앱 공유 로거(보통 `app.fastify.log`). 미주입이면 무로그.
28
+ */
29
+ constructor({ appName, log } = /** @type {any} */ ({})) {
30
+ if (typeof appName !== 'string' || appName.length === 0) {
31
+ throw new Error('MegaWsPresence: appName is required')
32
+ }
33
+ /** @type {string} */
34
+ this._appName = appName
35
+ /** @type {any} */
36
+ this._log = log ?? null
37
+ /** @type {MegaHubLink|null} hub 연결 (scaffold 권장). */
38
+ this._hubLink = null
39
+ /** @type {import('./ws-cluster.js').MegaWsCluster|null} NATS 기반 클러스터 fan-out/roster (ADR-176, boot 자동배선). */
40
+ this._wsCluster = null
41
+ /** @type {import('./ws-roster.js').MegaWsRedisRoster|null} 채널별 redis roster(ADR-177, boot 자동배선).
42
+ * 접속자 목록(상태)을 redis HASH 로 cluster-wide 관리한다(broadcast 와 별개 — 멀티 허브 정합·즉시 스냅샷). */
43
+ this._wsRoster = null
44
+ /** ns(=ws path) → 활성 로컬 연결 집합 (hub broadcast 의 local fan-out 용). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
45
+ this._wsConns = new Map()
46
+ /** userId → 활성 로컬 연결 집합 (DIRECT 타겟팅, H-latent guard). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
47
+ this._userConns = new Map()
48
+ /** sessionId → 활성 로컬 연결 1개 (세션단위 JOIN/LEAVE). @type {Map<string, import('./ws-upgrade.js').MegaWsConnection>} */
49
+ this._sessionConns = new Map()
50
+ /** @type {string[]} connectHub 으로 구독한 채널. */
51
+ this._hubChannels = []
52
+ /** connectHub 의 bridgeId (재연결 재구독·세션 JOIN 에 재사용). @type {string|null} */
53
+ this._hubBridgeId = null
54
+ }
55
+
56
+ /** 현재 연결된 hub link (미연결 시 null). */
57
+ get hubLink() {
58
+ return this._hubLink
59
+ }
60
+
61
+ /**
62
+ * 이 bridge 를 hub 에 연결한다 (ADR-033/059). REGISTER 핸드셰이크 완료 후
63
+ * 선언 채널을 구독(bridge-subscriber JOIN)하고, hub 의 BROADCAST/DIRECT 를 로컬 소켓에 전달한다.
64
+ *
65
+ * single 모드는 embedded 종단이라 hub 가 필수는 아니지만(02-architecture §3), 멀티 인스턴스
66
+ * fan-out 이 필요하면 single 에서도 사용 가능하다. scaffold(멀티앱/클러스터)에서 권장.
67
+ *
68
+ * @param {Object} config - MegaBridgeHubConfig (§2.1) + 구독 채널.
69
+ * @param {string} config.url - hub URL.
70
+ * @param {string} config.token - Bearer 토큰.
71
+ * @param {string} config.bridgeId - 운영 식별자.
72
+ * @param {string} [config.instanceId]
73
+ * @param {string[]} [config.capabilities]
74
+ * @param {string[]} [config.channels] - 자동 구독할 채널 목록.
75
+ * @param {import('../lib/mega-retry.js').MegaRetryOptions} [config.retry] - 지정 시 재연결 활성(ADR-098).
76
+ * hub 재시작·drain(4503)·네트워크 단절 시 지수 백오프로 재연결하고, 성공하면 presence(채널·세션
77
+ * JOIN)를 자동 재동기화한다(hub 는 절단 시점 presence 를 잃으므로).
78
+ * @param {import('./ws-compression.js').WsCompressionConfig} [config.compression] - Bridge↔Hub
79
+ * link 압축(ADR-078 / MegaWsHubCompressionConfig). Global `wsHub.compression`
80
+ * 블록을 그대로 전달한다 — hub 서버와 같은 스키마. 디폴트 OFF. 잘못된 threshold/windowBits 면
81
+ * 즉시 throw(부팅 fail-fast).
82
+ * @returns {Promise<MegaHubLink>} 등록 완료된 link.
83
+ */
84
+ async connectHub(config = /** @type {any} */ ({})) {
85
+ const link = new MegaHubLink({
86
+ url: config.url,
87
+ token: config.token,
88
+ bridgeId: config.bridgeId,
89
+ instanceId: config.instanceId,
90
+ capabilities: config.capabilities,
91
+ retry: config.retry,
92
+ compression: config.compression,
93
+ logger: this._log,
94
+ })
95
+ this._hubLink = link
96
+ this._hubBridgeId = config.bridgeId
97
+ this._hubChannels = Array.isArray(config.channels) ? [...config.channels] : []
98
+ // hub → bridge 푸시를 로컬 소켓에 전달.
99
+ link.on(HUB_MESSAGE_TYPES.BROADCAST, (msg) => this._deliverBroadcast(/** @type {any} */ (msg).payload))
100
+ link.on(HUB_MESSAGE_TYPES.DIRECT, (msg) => this._deliverDirect(/** @type {any} */ (msg).payload))
101
+ // admin-kick (ADR-097 양방향 DISCONNECT): 다른 bridge 의 강제 종료 요청이 세션 소유 bridge(여기)로
102
+ // 라우팅되면 해당 로컬 소켓을 닫는다. 이 핸들러가 없으면 hub 가 라우팅해도 소켓이 안 닫힌다.
103
+ link.on(HUB_MESSAGE_TYPES.DISCONNECT, (msg) => this._handleHubDisconnect(/** @type {any} */ (msg).payload))
104
+ // 허브가 fan-out 하는 presence(JOIN/LEAVE/METADATA)·heartbeat 는 broadcast 채널 멤버십·keepalive 용으로만
105
+ // 의미 있다. **roster(접속자 목록)는 redis(MegaWsRedisRoster, ADR-177)가 별도 관리**하므로 브릿지는 이들을
106
+ // 소비하지 않는다 — no-op 핸들러로 "no handler for type" 노이즈만 차단한다(JOIN 자체는 joinSession 이
107
+ // broadcast 채널 가입용으로 계속 송신). 멀티 허브에서도 redis 가 single source-of-truth 라 명단이 정합.
108
+ const noopHub = () => {}
109
+ link.on(HUB_MESSAGE_TYPES.JOIN, noopHub)
110
+ link.on(HUB_MESSAGE_TYPES.LEAVE, noopHub)
111
+ link.on(HUB_MESSAGE_TYPES.BULK_LEAVE, noopHub)
112
+ link.on(HUB_MESSAGE_TYPES.METADATA, noopHub)
113
+ link.on(HUB_MESSAGE_TYPES.HEARTBEAT, noopHub)
114
+ // 재연결 성공 시 presence 재동기화(ADR-098) — hub 는 절단 시점 presence 를 잃으므로 broadcast 채널을 다시 JOIN.
115
+ link.on(MegaHubLink.EVENTS.RECONNECTED, () => this._resyncPresence())
116
+ await link.connect()
117
+
118
+ // 채널 구독 — bridge-subscriber 세션 JOIN(zero-config 브로드캐스트 수신용) + 이미 붙어 있는 실
119
+ // 사용자 세션 재JOIN(예: 첫 connect 전에 클라가 연결됐을 수 있음).
120
+ this._resyncPresence()
121
+
122
+ // shutdown hook 누수 방지(L1) — 재연결 등으로 connectHub 가 다시 불려도 hook 은 항상 1개.
123
+ const hookName = `mega-hublink:${this._appName}`
124
+ MegaShutdown.unregister(hookName)
125
+ MegaShutdown.register(hookName, async () => link.close())
126
+ return link
127
+ }
128
+
129
+ /**
130
+ * hub presence 재동기화 — bridge-subscriber 채널 JOIN + 활성 사용자 세션 JOIN 을 모두 다시 보낸다.
131
+ * 최초 등록 직후와 재연결(RECONNECTED) 직후에 호출된다. hub 의 JOIN 처리는 멱등(같은 sessionId 덮어씀).
132
+ * @returns {void}
133
+ * @private
134
+ */
135
+ _resyncPresence() {
136
+ const link = this._hubLink
137
+ if (!link?.isRegistered) return
138
+ const bridgeId = this._hubBridgeId ?? this._appName
139
+ // 1) bridge-subscriber JOIN — bridge 가 채널 멤버가 되어 zero-config 브로드캐스트를 받게 한다.
140
+ for (const ch of this._hubChannels) {
141
+ link.join({
142
+ userId: `bridge:${bridgeId}`,
143
+ sessionId: `bridge:${bridgeId}#${ch}`,
144
+ channels: [ch],
145
+ })
146
+ }
147
+ // 2) 실 사용자 세션 JOIN — joinSession 으로 매핑된 활성 세션을 다시 등록(DIRECT 타겟 복구).
148
+ // 채널 + metadata 까지 재동기화한다(M-1) — hub 는 절단 시점 presence 를 통째로 잃으므로,
149
+ // metadata 를 빠뜨리면 재연결 후 hub presence 의 메타가 silent 사라진다.
150
+ for (const [sessionId, conn] of this._sessionConns) {
151
+ if (!conn.isOpen) continue
152
+ link.join({
153
+ userId: /** @type {string} */ (conn.userId),
154
+ sessionId,
155
+ channels: conn.channels ? [...conn.channels] : [],
156
+ ...(conn.metadata ? { metadata: conn.metadata } : {}),
157
+ })
158
+ }
159
+ }
160
+
161
+ /**
162
+ * 실 사용자 세션을 hub presence 에 등록하고 로컬 매핑을 만든다 (OQ-010/ADR-098).
163
+ *
164
+ * 표준 패턴: WS upgrade 의 `before` 미들웨어가 인증 후 신원을 `ctx.auth` 로 싣고(ADR-091 DI),
165
+ * 채널의 `onConnect(sock, ctx)` 에서 `ctx.app.joinSession(sock, { userId: ctx.auth.userId, ... })`
166
+ * 를 호출한다. 이 매핑이 있어야 DIRECT 가 **해당 userId 세션에만** 전달된다(cross-user flood 방지,
167
+ * H-latent guard). 매핑 없는 연결은 DIRECT 대상에서 제외된다.
168
+ *
169
+ * @param {import('./ws-upgrade.js').MegaWsConnection} conn - onConnect 가 받은 소켓 래퍼.
170
+ * @param {Object} entry
171
+ * @param {string} entry.userId - 인증된 사용자 식별자(비어 있으면 throw).
172
+ * @param {string} entry.sessionId - 세션 식별자(비어 있으면 throw). 전역 유일 권장.
173
+ * @param {string[]} [entry.channels] - 가입 채널 목록.
174
+ * @param {Object} [entry.metadata] - presence 메타데이터(명시 필드만, ADR-059).
175
+ * @returns {this}
176
+ * @throws {Error} conn/userId/sessionId 누락 시 — 잘못된 매핑을 silent 통과시키지 않는다.
177
+ */
178
+ joinSession(conn, { userId, sessionId, channels = [], metadata } = /** @type {any} */ ({})) {
179
+ if (!conn || typeof conn.send !== 'function') {
180
+ throw new Error('MegaApp.joinSession: conn (MegaWsConnection) is required.')
181
+ }
182
+ if (typeof userId !== 'string' || userId.length === 0) {
183
+ throw new Error('MegaApp.joinSession: userId (non-empty string) is required.')
184
+ }
185
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
186
+ throw new Error('MegaApp.joinSession: sessionId (non-empty string) is required.')
187
+ }
188
+ const chans = Array.isArray(channels) ? [...channels] : []
189
+
190
+ // L-4: 같은 sessionId 가 다른 conn 으로 다시 join 되면(전역 유일 계약 위반) 옛 conn 을 인덱스에서
191
+ // 떼어 dangling 을 막는다 — 단 소켓 자체는 닫지 않는다(클라가 정리). 옛 conn 의 신원도 비워,
192
+ // 이후 옛 conn 의 close(_untrackWsConn)가 새 conn 이 차지한 sessionId 로 LEAVE 를 잘못 보내지
193
+ // 않게 한다(그대로 두면 새 세션의 hub presence 가 silent 제거됨).
194
+ const prior = this._sessionConns.get(sessionId)
195
+ if (prior && prior !== conn) {
196
+ this._log?.warn?.(
197
+ { app: this._appName, sessionId, priorUserId: prior.userId, userId },
198
+ 'ws.joinSession duplicate sessionId — prior conn left dangling (detached, not closed)',
199
+ )
200
+ if (prior.userId !== undefined) {
201
+ const pset = this._userConns.get(prior.userId)
202
+ if (pset) {
203
+ pset.delete(prior)
204
+ if (pset.size === 0) this._userConns.delete(prior.userId)
205
+ }
206
+ }
207
+ if (prior.ns !== undefined) {
208
+ const nsset = this._wsConns.get(prior.ns)
209
+ if (nsset) {
210
+ nsset.delete(prior)
211
+ if (nsset.size === 0) this._wsConns.delete(prior.ns)
212
+ }
213
+ }
214
+ prior.userId = undefined
215
+ prior.sessionId = undefined
216
+ prior.channels = null
217
+ }
218
+
219
+ // 연결에 신원 부착(매핑 키). _untrackWsConn 이 close 시 이 값으로 인덱스를 정리한다.
220
+ conn.userId = userId
221
+ conn.sessionId = sessionId
222
+ conn.channels = new Set(chans)
223
+ conn.metadata = metadata // M-1: 재연결 재동기화(_resyncPresence)가 보존할 수 있게 저장.
224
+
225
+ let uset = this._userConns.get(userId)
226
+ if (!uset) {
227
+ uset = new Set()
228
+ this._userConns.set(userId, uset)
229
+ }
230
+ uset.add(conn)
231
+ this._sessionConns.set(sessionId, conn)
232
+
233
+ this._log?.debug?.({ app: this._appName, userId, sessionId, channels: chans }, 'ws.joinSession')
234
+ // hub presence 등록 — 등록 상태일 때만(미연결/재연결 중이면 _resyncPresence 가 나중에 복구).
235
+ if (this._hubLink?.isRegistered) {
236
+ this._hubLink.join({ userId, sessionId, channels: chans, ...(metadata ? { metadata } : {}) })
237
+ }
238
+ // NATS roster 동기화 (ADR-176) — 프레임워크가 클러스터 접속자 목록을 자동 관리한다(개발자 코드 불요).
239
+ // ns 는 연결의 namespace. roster:'none' 이면 로컬만 갱신한다.
240
+ if (this._wsCluster && typeof conn.ns === 'string') {
241
+ this._wsCluster.rosterAdd({ ns: conn.ns, sessionId, userId, ...(metadata ? { metadata } : {}) })
242
+ }
243
+ // redis roster 동기화 (ADR-177) — **채널별** 접속자 목록을 redis HASH 에 add(멀티 허브 정합). best-effort.
244
+ if (this._wsRoster && chans.length > 0) {
245
+ const member = { userId, ...(metadata ? { metadata } : {}) }
246
+ for (const ch of chans) {
247
+ this._wsRoster.add(ch, sessionId, member).catch((err) =>
248
+ this._log?.warn?.({ err, channel: ch, app: this._appName }, 'ws-roster add failed'),
249
+ )
250
+ }
251
+ }
252
+ return this
253
+ }
254
+
255
+ /**
256
+ * 채널 broadcast — 로컬 ns 소켓에 즉시 전달 + (hub 연결 시) 클러스터 fan-out.
257
+ *
258
+ * @param {{ ns: string, channel: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }} args
259
+ * @returns {void}
260
+ * @throws {Error} message.type(string) 누락 시 — 호출부 입력 오류를 silent drop 하지 않고 즉시 알린다(L6).
261
+ */
262
+ broadcast({ ns, channel, message, exceptSessionIds }) {
263
+ // 입력 검증을 한곳에서(L6) — 로컬은 받고 hub 는 message.type 없이 전송하던 비대칭을 제거.
264
+ if (!message || typeof message.type !== 'string') {
265
+ throw new Error('MegaApp.broadcast: message.type (string) is required')
266
+ }
267
+ this._deliverBroadcast({ ns, channel, message, exceptSessionIds })
268
+ if (this._hubLink?.isRegistered) {
269
+ // L-7: 빈 배열도 truthy 라 `exceptSessionIds: []` 가 wire 로 새던 비대칭 제거 — 비어 있으면 생략.
270
+ const hasExcept = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0
271
+ try {
272
+ this._hubLink.broadcast({ ns, channel, message, ...(hasExcept ? { exceptSessionIds } : {}) })
273
+ } catch (err) {
274
+ // 로컬 전달은 이미 성공. 클러스터 fan-out 은 best-effort(at-most-once, ADR-033)이며 소켓이
275
+ // 닫히는 중이면 비치명적 — 재연결 시 presence 가 재동기화된다. warn 후 호출자 보호.
276
+ const log = /** @type {any} */ (this._log)
277
+ log?.warn?.({ err, ns, channel, app: this._appName }, 'app.broadcast hub fan-out failed (local delivered)')
278
+ }
279
+ }
280
+ // NATS 클러스터 fan-out (ADR-176, boot 자동배선). 로컬은 위에서 전달했고, 다른 인스턴스는 구독으로
281
+ // 받아 각자 전달한다(echo 는 instanceId 로 스킵). publish 실패는 best-effort — local 은 이미 성공.
282
+ if (this._wsCluster) {
283
+ this._wsCluster.publishBroadcast({ ns, channel, message, exceptSessionIds }).catch((err) => {
284
+ const log = /** @type {any} */ (this._log)
285
+ log?.warn?.({ err, ns, channel, app: this._appName }, 'app.broadcast nats fan-out failed (local delivered)')
286
+ })
287
+ }
288
+ }
289
+
290
+ /**
291
+ * 특정 사용자에게 직접 전송 (directToUser, ADR-035) — 로컬에서 그 userId 로 매핑된 연결에만 전달
292
+ * (H-latent guard) + (hub 연결 시) 클러스터 fan-out(다른 bridge 의 같은 userId 세션까지).
293
+ *
294
+ * {@link MegaWsPresence#joinSession} 으로 매핑된 연결만 대상이다 — 매핑 없는 userId 면 로컬 no-op.
295
+ *
296
+ * @param {string} userId - 대상 사용자.
297
+ * @param {{ type: string, payload?: Object }} message - 내부 envelope `{ type, payload }`.
298
+ * @returns {void}
299
+ * @throws {Error} userId/message.type 누락 시(broadcast 와 동일한 입력 보호, L6).
300
+ */
301
+ directToUser(userId, message) {
302
+ if (typeof userId !== 'string' || userId.length === 0) {
303
+ throw new Error('MegaApp.directToUser: userId (non-empty string) is required')
304
+ }
305
+ if (!message || typeof message.type !== 'string') {
306
+ throw new Error('MegaApp.directToUser: message.type (string) is required')
307
+ }
308
+ this._deliverDirect({ userId, message })
309
+ if (this._hubLink?.isRegistered) {
310
+ try {
311
+ this._hubLink.direct({ userId, message })
312
+ } catch (err) {
313
+ // 로컬 전달은 이미 성공. 클러스터 fan-out 은 best-effort(at-most-once, ADR-033). warn 후 보호.
314
+ const log = /** @type {any} */ (this._log)
315
+ log?.warn?.({ err, userId, app: this._appName }, 'app.directToUser hub fan-out failed (local delivered)')
316
+ }
317
+ }
318
+ // NATS 클러스터 direct (ADR-176) — 다른 인스턴스의 같은 userId 세션까지. echo 는 instanceId 로 스킵.
319
+ if (this._wsCluster) {
320
+ this._wsCluster.publishDirect(userId, message).catch((err) => {
321
+ const log = /** @type {any} */ (this._log)
322
+ log?.warn?.({ err, userId, app: this._appName }, 'app.directToUser nats fan-out failed (local delivered)')
323
+ })
324
+ }
325
+ }
326
+
327
+ /**
328
+ * 세션 presence 메타데이터 갱신 (METADATA, ADR-059) — 로컬 conn 에 저장 + (hub 연결 시) 전파.
329
+ *
330
+ * 로컬 conn 의 `metadata` 를 갱신해 두면 이후 재연결 시 {@link MegaWsPresence#_resyncPresence} 가 최신
331
+ * 메타까지 복구한다(M-1). 매핑 없는 sessionId 면 no-op(로컬 저장 없이 hub 전파만은 하지 않음 —
332
+ * 재연결 보존 대상이 없으므로). broadcast/directToUser 와 같은 best-effort fan-out.
333
+ *
334
+ * @param {string} sessionId - 대상 세션(joinSession 으로 매핑된 것).
335
+ * @param {Object} metadata - 갱신할 메타데이터(명시 필드만).
336
+ * @returns {this}
337
+ * @throws {Error} sessionId/metadata 누락 시(입력 보호, L6 와 동일 원칙).
338
+ */
339
+ updateMetadata(sessionId, metadata) {
340
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
341
+ throw new Error('MegaApp.updateMetadata: sessionId (non-empty string) is required')
342
+ }
343
+ if (!metadata || typeof metadata !== 'object') {
344
+ throw new Error('MegaApp.updateMetadata: metadata (object) is required')
345
+ }
346
+ const conn = this._sessionConns.get(sessionId)
347
+ if (!conn) {
348
+ // 매핑 없는 세션 — 재연결로 보존할 로컬 대상이 없으므로 no-op(다른 bridge 세션은 그쪽이 관리).
349
+ this._log?.debug?.({ app: this._appName, sessionId }, 'ws.updateMetadata — no local session (no-op)')
350
+ return this
351
+ }
352
+ conn.metadata = metadata // 재연결 재동기화가 최신 메타를 복구하도록 저장(M-1).
353
+ if (this._hubLink?.isRegistered) {
354
+ try {
355
+ this._hubLink.updateMetadata({ sessionId, metadata })
356
+ } catch (err) {
357
+ // hub 전파 실패는 비치명적 — 로컬 저장은 됐고 재연결 시 _resyncPresence 가 복구.
358
+ const log = /** @type {any} */ (this._log)
359
+ log?.warn?.({ err, sessionId, app: this._appName }, 'app.updateMetadata hub propagate failed (local stored)')
360
+ }
361
+ }
362
+ return this
363
+ }
364
+
365
+ /**
366
+ * NATS 클러스터 fan-out/roster 를 이 앱에 배선한다 (ADR-176). boot 가 `wsCluster` config 를 보고
367
+ * 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
368
+ * @param {import('./ws-cluster.js').MegaWsCluster|null} cluster
369
+ * @returns {this}
370
+ */
371
+ setWsCluster(cluster) {
372
+ this._wsCluster = cluster
373
+ return this
374
+ }
375
+
376
+ /**
377
+ * 해당 ns(WS 채널 경로)의 **클러스터 전역 접속자 목록**을 반환한다 (ADR-176). `wsCluster` 자동배선 +
378
+ * `joinSession`/disconnect 훅으로 프레임워크가 동기화하므로 개발자는 roster 코드를 짜지 않고 읽기만 한다.
379
+ * wsCluster 미배선(또는 roster:'none')이면 로컬 멤버만 반환한다.
380
+ *
381
+ * @param {string} ns - WS namespace(채널 경로, 예: '/ws/chat').
382
+ * @returns {Array<{ sessionId: string, userId: string, metadata?: Object }>}
383
+ */
384
+ roster(ns) {
385
+ if (this._wsCluster) return this._wsCluster.roster(ns) // NATS: 이미 cluster-wide(roster 동기화 포함).
386
+ // ns 기준 로컬 명단(동기 API). **채널 기준 redis roster(ADR-177)** 는 `ctx.presence.list()`(async)가
387
+ // 다룬다 — 이 메서드는 NATS/로컬용 ns 기준 동기 API 로 유지(하위호환).
388
+ /** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
389
+ const out = []
390
+ for (const [sessionId, conn] of this._sessionConns) {
391
+ if (conn.ns !== ns || !conn.isOpen) continue
392
+ out.push({ sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
393
+ }
394
+ return out
395
+ }
396
+
397
+ /**
398
+ * 채널별 redis roster(ADR-177)를 이 앱에 배선한다. boot 가 `bridgeHub.roster.driver==='redis'` 일 때
399
+ * 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
400
+ * @param {import('./ws-roster.js').MegaWsRedisRoster|null} roster
401
+ * @returns {this}
402
+ */
403
+ setWsRoster(roster) {
404
+ this._wsRoster = roster
405
+ return this
406
+ }
407
+
408
+ /**
409
+ * 주어진 **채널들**의 cluster-wide 접속자 목록 — redis roster(원격 포함) + 로컬 세션을 병합한다(ADR-177).
410
+ * 로컬을 항상 포함해 join 직후 redis HSET 반영 전에도 본인이 빠지지 않게 한다. `ctx.presence.list()` 의
411
+ * redis 경로가 호출(async). roster 미배선이면 로컬 채널 멤버만.
412
+ * @param {string[]} channels
413
+ * @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
414
+ */
415
+ async presenceList(channels) {
416
+ const want = new Set(Array.isArray(channels) ? channels : [])
417
+ /** @type {Map<string, { sessionId: string, userId: string, metadata?: Object }>} */
418
+ const out = new Map()
419
+ // 로컬 세션(이 워커) — 요청 채널에 가입한 것. 항상 fresh(본인 포함).
420
+ for (const [sessionId, conn] of this._sessionConns) {
421
+ if (!conn.isOpen || !conn.channels) continue
422
+ let inCh = false
423
+ for (const ch of conn.channels) {
424
+ if (want.has(ch)) {
425
+ inCh = true
426
+ break
427
+ }
428
+ }
429
+ if (inCh) out.set(sessionId, { sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
430
+ }
431
+ // redis(cluster-wide) — 다른 워커/허브의 세션까지.
432
+ if (this._wsRoster) {
433
+ // 여러 채널 redis 조회를 **병렬화**(Promise.all) — onConnect 핫패스의 직렬 라운드트립 누적 제거(Med).
434
+ const lists = await Promise.all([...want].map((ch) => this._wsRoster.list(ch)))
435
+ for (const list of lists) for (const m of list) if (!out.has(m.sessionId)) out.set(m.sessionId, m)
436
+ }
437
+ return [...out.values()]
438
+ }
439
+
440
+ /**
441
+ * 이 워커의 로컬 멤버 목록 — redis roster heartbeat 갱신 대상(ADR-177). joinSession 으로 매핑된
442
+ * (채널 × 세션)마다 1개. MegaWsRedisRoster 가 주기적으로 이 목록의 expiresAt 을 갱신한다.
443
+ * @returns {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>}
444
+ */
445
+ localRosterMembers() {
446
+ /** @type {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>} */
447
+ const out = []
448
+ for (const [sessionId, conn] of this._sessionConns) {
449
+ if (!conn.isOpen || !conn.channels) continue
450
+ const member = { userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) }
451
+ for (const ch of conn.channels) out.push({ channel: ch, sessionId, member })
452
+ }
453
+ return out
454
+ }
455
+
456
+ /**
457
+ * broadcast payload 를 로컬 ns 소켓에 전달한다. message 는 `{ type, payload }` 내부 envelope.
458
+ *
459
+ * `exceptSessionIds` 에 든 sessionId 로 매핑된 연결은 제외한다(ADR-098). 세션 매핑이 없는
460
+ * 연결(zero-config·미JOIN)은 sessionId 가 없어 제외 대상에 걸리지 않으므로 그대로 받는다.
461
+ *
462
+ * @param {{ ns: string, channel?: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }} payload
463
+ * @returns {void}
464
+ */
465
+ _deliverBroadcast({ ns, message, exceptSessionIds }) {
466
+ const set = this._wsConns.get(ns)
467
+ if (!set || !message || typeof message.type !== 'string') return
468
+ const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
469
+ for (const conn of set) {
470
+ if (!conn.isOpen) continue
471
+ if (except && conn.sessionId !== undefined && except.has(conn.sessionId)) continue
472
+ conn.send({ type: message.type, ns, payload: message.payload })
473
+ }
474
+ }
475
+
476
+ /**
477
+ * direct payload 를 **해당 userId 로 매핑된 로컬 연결에만** 전달한다 (H-latent guard).
478
+ *
479
+ * 초기에는 매핑이 없어 모든 연결에 flood 됐다(cross-user 누출). {@link MegaWsPresence#joinSession}
480
+ * 으로 만든 `userId → 연결` 매핑을 통해 대상 사용자에게만 보낸다. 매핑이 없는 userId 면 no-op
481
+ * (다른 사용자에게 새지 않음).
482
+ *
483
+ * @param {{ userId: string, message: { type: string, payload?: Object } }} payload
484
+ * @returns {void}
485
+ */
486
+ _deliverDirect({ userId, message }) {
487
+ if (!message || typeof message.type !== 'string') return
488
+ if (typeof userId !== 'string' || userId.length === 0) return
489
+ const set = this._userConns.get(userId)
490
+ if (!set) return
491
+ for (const conn of set) {
492
+ if (conn.isOpen) conn.send({ type: message.type, payload: message.payload })
493
+ }
494
+ }
495
+
496
+ /**
497
+ * hub 가 라우팅한 DISCONNECT(admin-kick/재배치, ADR-097)를 처리한다 — sessionId 로 매핑된 로컬
498
+ * 소켓을 닫는다. `requeue` 로 두 시맨틱이 갈린다:
499
+ *
500
+ * - `requeue: false`(기본) = **kick** — RFC 6455 `1008`(policy violation, hub Bearer 거부와 동일
501
+ * 코드). "돌아오지 마라" — 클라이언트는 재연결하지 않아야 한다.
502
+ * - `requeue: true` = **우아한 재배치** — `4503`({@link import('./ws-upgrade.js').CLOSE_CODE_REQUEUE},
503
+ * bridge↔hub drain 과 동일 규약). "세션 유지한 채 즉시 재연결하라" — HTTP 세션은 그대로이므로
504
+ * 재연결이 LB 를 거쳐 다른 워커에 닿아도 `before` 인증이 세션 쿠키로 자연 통과한다
505
+ * (transparent re-route — 워커 drain·리밸런싱용).
506
+ *
507
+ * reason 은 WS close frame 한도(123 bytes)에 맞춰 자른다.
508
+ *
509
+ * @param {{ sessionId: string, reason?: string, requeue?: boolean }} payload
510
+ * @returns {void}
511
+ */
512
+ _handleHubDisconnect({ sessionId, reason, requeue } = /** @type {any} */ ({})) {
513
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return
514
+ const conn = this._sessionConns.get(sessionId)
515
+ this._log?.debug?.(
516
+ { sessionId, reason, requeue: requeue === true, found: Boolean(conn), app: this._appName },
517
+ 'ws.hub disconnect received',
518
+ )
519
+ if (!conn) return // 이미 닫혔거나 다른 인스턴스로 옮겨간 세션 — hub presence 정리는 LEAVE 가 처리.
520
+ if (conn.isOpen) {
521
+ if (requeue === true) {
522
+ const closeReason = typeof reason === 'string' ? reason.slice(0, 123) : 'requeued — reconnect'
523
+ conn.close(CLOSE_CODE_REQUEUE, closeReason)
524
+ } else {
525
+ const closeReason = typeof reason === 'string' ? reason.slice(0, 123) : 'disconnected by hub'
526
+ conn.close(1008, closeReason)
527
+ }
528
+ }
529
+ }
530
+
531
+ /**
532
+ * 로컬 WS 연결 등록 (driveWsConnection 이 호출 — framework-internal). hub broadcast 의 local 전달 대상.
533
+ * @param {import('./ws-upgrade.js').MegaWsConnection} conn
534
+ * @returns {void}
535
+ */
536
+ _trackWsConn(conn) {
537
+ if (!conn.ns) return
538
+ let set = this._wsConns.get(conn.ns)
539
+ if (!set) {
540
+ set = new Set()
541
+ this._wsConns.set(conn.ns, set)
542
+ }
543
+ set.add(conn)
544
+ }
545
+
546
+ /**
547
+ * 로컬 WS 연결 해제 (close 시 driveWsConnection 이 호출 — framework-internal).
548
+ * @param {import('./ws-upgrade.js').MegaWsConnection} conn
549
+ * @returns {void}
550
+ */
551
+ _untrackWsConn(conn) {
552
+ const set = conn.ns ? this._wsConns.get(conn.ns) : undefined
553
+ if (set) {
554
+ set.delete(conn)
555
+ if (set.size === 0) this._wsConns.delete(conn.ns)
556
+ }
557
+ // 세션·유저 매핑 정리 + hub presence LEAVE (joinSession 으로 매핑된 연결만).
558
+ if (conn.userId !== undefined) {
559
+ const uset = this._userConns.get(conn.userId)
560
+ if (uset) {
561
+ uset.delete(conn)
562
+ if (uset.size === 0) this._userConns.delete(conn.userId)
563
+ }
564
+ }
565
+ if (conn.sessionId !== undefined) {
566
+ // 같은 sessionId 가 다른(새) 연결로 교체된 경우엔 이 연결만 지운다(오래된 연결의 close 가
567
+ // 새 매핑을 지우지 않게 동일성 확인).
568
+ if (this._sessionConns.get(conn.sessionId) === conn) this._sessionConns.delete(conn.sessionId)
569
+ if (this._hubLink?.isRegistered) {
570
+ try {
571
+ this._hubLink.leave(conn.sessionId)
572
+ } catch (err) {
573
+ // 소켓이 닫히는 중이면 LEAVE 송신 실패 — 비치명적. hub 의 bridge-gone 정리가 보강한다.
574
+ this._log?.debug?.({ err, sessionId: conn.sessionId, app: this._appName }, 'ws.leave send failed')
575
+ }
576
+ }
577
+ // NATS roster 제거 (ADR-176) — disconnect 시 클러스터 접속자 목록에서 자동 제거(개발자 코드 불요).
578
+ this._wsCluster?.rosterRemove(conn.sessionId)
579
+ // redis roster 제거 (ADR-177) — 이 세션이 가입한 모든 채널에서 제거. best-effort.
580
+ if (this._wsRoster && conn.channels) {
581
+ for (const ch of conn.channels) {
582
+ this._wsRoster.remove(ch, /** @type {string} */ (conn.sessionId)).catch((err) =>
583
+ this._log?.warn?.({ err, channel: ch, app: this._appName }, 'ws-roster remove failed'),
584
+ )
585
+ }
586
+ }
587
+ }
588
+ }
589
+
590
+
591
+ /**
592
+ * presence 전체 정리 — hub link → NATS cluster → redis roster → 연결 인덱스 순.
593
+ * 각 자원의 shutdown hook 도 짝맞춰 해제한다(닫힌 앱의 hook 잔존·중복 정리 방지).
594
+ * MegaApp.close 가 WS 클라이언트 종료(1001) **전에** 호출한다.
595
+ * @returns {Promise<void>}
596
+ */
597
+ async close() {
598
+ // hub link 먼저 끊는다 (더 이상 fan-out 수신 불필요). shutdown hook 도 함께 떼어 누수 방지(L1).
599
+ if (this._hubLink) {
600
+ this._hubLink.close()
601
+ this._hubLink = null
602
+ this._hubBridgeId = null
603
+ MegaShutdown.unregister(`mega-hublink:${this._appName}`)
604
+ }
605
+ // NATS 클러스터 정리(ADR-176) — 구독·heartbeat/sweep 타이머·roster 멤버 해제. boot 의 shutdown hook 과
606
+ // **양쪽**에서 정리해야 시그널을 안 거치는 종료(server.close()/프로그램적 재시작/멀티앱 부분 종료)에서도
607
+ // 누수가 없다(_hubLink/_wsRoster 와 동일 대칭, H1). stop() 은 _isStarted 가드로 멱등이라 중복 안전.
608
+ if (this._wsCluster) {
609
+ await this._wsCluster.stop().catch((err) => this._log?.warn?.({ err, app: this._appName }, 'ws-cluster stop failed'))
610
+ this._wsCluster = null
611
+ MegaShutdown.unregister(`mega-ws-cluster:${this._appName}`)
612
+ }
613
+ // redis roster 정리(ADR-177) — heartbeat 중지 + 로컬 멤버 즉시 제거. _sessionConns 를 비우기 **전에**
614
+ // 호출해야 stop() 이 localRosterMembers 로 자기 멤버를 redis 에서 뺄 수 있다.
615
+ if (this._wsRoster) {
616
+ await this._wsRoster.stop().catch((err) => this._log?.warn?.({ err, app: this._appName }, 'ws-roster stop failed'))
617
+ this._wsRoster = null
618
+ MegaShutdown.unregister(`mega-wsroster:${this._appName}`)
619
+ }
620
+ this._wsConns.clear()
621
+ this._userConns.clear()
622
+ this._sessionConns.clear()
623
+ }
624
+ }
@@ -156,8 +156,11 @@ export class MegaWsRedisRoster {
156
156
  if (this._hbTimer) clearInterval(this._hbTimer)
157
157
  this._hbTimer = null
158
158
  // graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리).
159
+ // 실패해도 종료는 계속(lazy 만료가 정리) — 단 명단에 TTL 까지 남으므로 묵히지 않고 알린다.
159
160
  for (const { channel, sessionId } of this._getLocalMembers()) {
160
- await this.remove(channel, sessionId).catch(() => {})
161
+ await this.remove(channel, sessionId).catch((err) =>
162
+ this._log?.warn?.({ err, channel, sessionId }, 'ws-roster graceful remove failed (stale until TTL)'),
163
+ )
161
164
  }
162
165
  }
163
166
  }