mega-framework 0.1.0 → 0.1.1

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.
@@ -182,6 +182,8 @@ export class MegaApp {
182
182
  this._wss = null
183
183
  /** @type {MegaHubLink|null} hub 연결 (scaffold 권장). */
184
184
  this._hubLink = null
185
+ /** @type {import('./ws-cluster.js').MegaWsCluster|null} NATS 기반 클러스터 fan-out/roster (ADR-176, boot 자동배선). */
186
+ this._wsCluster = null
185
187
  /** ns(=ws path) → 활성 로컬 연결 집합 (hub broadcast 의 local fan-out 용). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
186
188
  this._wsConns = new Map()
187
189
  /** userId → 활성 로컬 연결 집합 (DIRECT 타겟팅, H-latent guard). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
@@ -758,6 +760,11 @@ export class MegaApp {
758
760
  if (this._hubLink?.isRegistered) {
759
761
  this._hubLink.join({ userId, sessionId, channels: chans, ...(metadata ? { metadata } : {}) })
760
762
  }
763
+ // NATS roster 동기화 (ADR-176) — 프레임워크가 클러스터 접속자 목록을 자동 관리한다(개발자 코드 불요).
764
+ // ns 는 연결의 namespace. roster:'none' 이면 로컬만 갱신한다.
765
+ if (this._wsCluster && typeof conn.ns === 'string') {
766
+ this._wsCluster.rosterAdd({ ns: conn.ns, sessionId, userId, ...(metadata ? { metadata } : {}) })
767
+ }
761
768
  return this
762
769
  }
763
770
 
@@ -786,6 +793,14 @@ export class MegaApp {
786
793
  log?.warn?.({ err, ns, channel, app: this.name }, 'app.broadcast hub fan-out failed (local delivered)')
787
794
  }
788
795
  }
796
+ // NATS 클러스터 fan-out (ADR-176, boot 자동배선). 로컬은 위에서 전달했고, 다른 인스턴스는 구독으로
797
+ // 받아 각자 전달한다(echo 는 instanceId 로 스킵). publish 실패는 best-effort — local 은 이미 성공.
798
+ if (this._wsCluster) {
799
+ this._wsCluster.publishBroadcast({ ns, channel, message, exceptSessionIds }).catch((err) => {
800
+ const log = /** @type {any} */ (this.fastify.log)
801
+ log?.warn?.({ err, ns, channel, app: this.name }, 'app.broadcast nats fan-out failed (local delivered)')
802
+ })
803
+ }
789
804
  }
790
805
 
791
806
  /**
@@ -816,6 +831,13 @@ export class MegaApp {
816
831
  log?.warn?.({ err, userId, app: this.name }, 'app.directToUser hub fan-out failed (local delivered)')
817
832
  }
818
833
  }
834
+ // NATS 클러스터 direct (ADR-176) — 다른 인스턴스의 같은 userId 세션까지. echo 는 instanceId 로 스킵.
835
+ if (this._wsCluster) {
836
+ this._wsCluster.publishDirect(userId, message).catch((err) => {
837
+ const log = /** @type {any} */ (this.fastify.log)
838
+ log?.warn?.({ err, userId, app: this.name }, 'app.directToUser nats fan-out failed (local delivered)')
839
+ })
840
+ }
819
841
  }
820
842
 
821
843
  /**
@@ -856,6 +878,37 @@ export class MegaApp {
856
878
  return this
857
879
  }
858
880
 
881
+ /**
882
+ * NATS 클러스터 fan-out/roster 를 이 앱에 배선한다 (ADR-176). boot 가 `wsCluster` config 를 보고
883
+ * 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
884
+ * @param {import('./ws-cluster.js').MegaWsCluster|null} cluster
885
+ * @returns {this}
886
+ */
887
+ setWsCluster(cluster) {
888
+ this._wsCluster = cluster
889
+ return this
890
+ }
891
+
892
+ /**
893
+ * 해당 ns(WS 채널 경로)의 **클러스터 전역 접속자 목록**을 반환한다 (ADR-176). `wsCluster` 자동배선 +
894
+ * `joinSession`/disconnect 훅으로 프레임워크가 동기화하므로 개발자는 roster 코드를 짜지 않고 읽기만 한다.
895
+ * wsCluster 미배선(또는 roster:'none')이면 로컬 멤버만 반환한다.
896
+ *
897
+ * @param {string} ns - WS namespace(채널 경로, 예: '/ws/chat').
898
+ * @returns {Array<{ sessionId: string, userId: string, metadata?: Object }>}
899
+ */
900
+ roster(ns) {
901
+ if (this._wsCluster) return this._wsCluster.roster(ns)
902
+ // wsCluster 미배선 — joinSession 으로 매핑된 로컬 세션 중 해당 ns 만.
903
+ /** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
904
+ const out = []
905
+ for (const [sessionId, conn] of this._sessionConns) {
906
+ if (conn.ns !== ns || !conn.isOpen) continue
907
+ out.push({ sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
908
+ }
909
+ return out
910
+ }
911
+
859
912
  /**
860
913
  * broadcast payload 를 로컬 ns 소켓에 전달한다. message 는 `{ type, payload }` 내부 envelope.
861
914
  *
@@ -944,6 +997,8 @@ export class MegaApp {
944
997
  this.fastify.log?.debug?.({ err, sessionId: conn.sessionId, app: this.name }, 'ws.leave send failed')
945
998
  }
946
999
  }
1000
+ // NATS roster 제거 (ADR-176) — disconnect 시 클러스터 접속자 목록에서 자동 제거(개발자 코드 불요).
1001
+ this._wsCluster?.rosterRemove(conn.sessionId)
947
1002
  }
948
1003
  }
949
1004
 
@@ -11,6 +11,7 @@ export const GLOBAL_ONLY_KEYS = Object.freeze([
11
11
  'services', // databases/caches/buses 그룹 정의
12
12
  'server', // port, cluster, sessionSecret
13
13
  'wsHub', // mega ws-hub 명령용 (ADR-068)
14
+ 'wsCluster', // NATS 기반 WS 클러스터 broadcast/roster 자동배선 (ADR-176)
14
15
  'logger', // 전역 로거 sinks
15
16
  'apps', // 활성 앱 whitelist (ADR-066)
16
17
  'asp', // masterSecret 등 시크릿
@@ -0,0 +1,352 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaWsCluster — NATS 기반 WS 클러스터 fan-out + roster(접속자 목록) 동기화 (ADR-176).
4
+ *
5
+ * `app.broadcast`/`directToUser`(로컬 전달)는 단일 프로세스 안에서만 닿는다 — 클러스터(워커 N개·다중
6
+ * 인스턴스)에서는 다른 인스턴스에 붙은 클라에게 닿지 않는다. 본 모듈이 **프레임워크 레벨에서** NATS
7
+ * 버스(`services.buses.<key>`, driver:'nats')로 그 간극을 메운다. config 에 `wsCluster.bus` 가 있으면
8
+ * boot 가 **자동 배선**한다(ADR-176 이 ADR-137 의 "자동배선 거부"를 wsCluster 에 한해 번복) — 개발자는
9
+ * `connectHub` 같은 배선 코드를 쓰지 않고 `app.broadcast`/`joinSession` 만 호출하면 된다.
10
+ *
11
+ * # broadcast / direct — core NATS pub/sub
12
+ * - 송신: `app.broadcast` 가 로컬 전달 후 본 클러스터의 {@link publishBroadcast} 로 NATS publish.
13
+ * - 수신: 모든 인스턴스가 같은 subject 를 subscribe 하고, 도착하면 자기 로컬 연결에 전달.
14
+ * - echo 회피: 각 인스턴스는 고유 `instanceId` 를 갖고 envelope 에 `o`(origin) 로 싣는다. 자기 자신이
15
+ * publish 한 메시지(`o === instanceId`)는 **이미 로컬 전달했으므로** 수신 시 건너뛴다(중복 방지).
16
+ *
17
+ * # roster — 설정형(ADR-176, `wsCluster.roster.driver`)
18
+ * - `'nats'`: join/leave 델타 publish + 주기 heartbeat(멤버 전체) + TTL sweep(crash 인스턴스의 stale
19
+ * 멤버 자동 제거) + 신규 인스턴스의 sync_request(전원 즉시 heartbeat 응답 → 빠른 수렴).
20
+ * - `'none'`: 클러스터 roster 미동기화(로컬 연결만). `roster(ns)` 는 로컬 멤버만 반환.
21
+ *
22
+ * NATS 는 단일 멀티플렉스 연결이라 redis pub/sub 처럼 구독 전용 연결을 `duplicate()` 할 필요가 없다.
23
+ * 직렬화는 NatsAdapter 의 JSONCodec 이 표준화하므로 본 모듈은 평범한 객체만 주고받는다.
24
+ *
25
+ * @module core/ws-cluster
26
+ */
27
+
28
+ /**
29
+ * roster 멤버(클러스터 공유). sessionId 는 joinSession 전역 유일 계약(ADR-098)으로 키가 된다.
30
+ * @typedef {Object} RosterMember
31
+ * @property {string} ns - WS namespace(채널 경로, 예: '/ws/chat').
32
+ * @property {string} sessionId - 세션 식별자(전역 유일).
33
+ * @property {string} userId - 사용자 식별자.
34
+ * @property {Object} [metadata] - presence 메타(명시 필드만).
35
+ */
36
+
37
+ /** roster 델타 op. */
38
+ const ROSTER_OP = Object.freeze({
39
+ ADD: 'add',
40
+ REMOVE: 'remove',
41
+ HEARTBEAT: 'heartbeat', // 멤버 전체 재공지(TTL 갱신).
42
+ SYNC_REQUEST: 'sync_request', // 신규 인스턴스가 전원에게 즉시 heartbeat 를 요청.
43
+ })
44
+
45
+ /** 기본 subject 접두. */
46
+ const DEFAULT_SUBJECT_PREFIX = 'mega.ws'
47
+ /** 기본 roster 멤버 TTL(ms) — heartbeat 미수신 시 stale 로 간주하고 제거. */
48
+ const DEFAULT_ROSTER_TTL_MS = 30_000
49
+
50
+ /**
51
+ * 짧은 랜덤 식별자(instanceId) — Math.random 기반. 인스턴스 echo 구분용이라 암호학적 강도 불필요.
52
+ * @returns {string}
53
+ */
54
+ function randomId() {
55
+ return Math.random().toString(36).slice(2, 10) + Date.now().toString(36)
56
+ }
57
+
58
+ /**
59
+ * NATS 기반 WS 클러스터 fan-out + roster.
60
+ *
61
+ * 앱 1개당 1 인스턴스. boot 가 생성·start·shutdown 을 관리한다(자동 배선). subject 는 앱 이름으로
62
+ * 격리되어(`<prefix>.<appName>.*`) 다중 앱이 한 NATS 를 공유해도 섞이지 않는다.
63
+ */
64
+ export class MegaWsCluster {
65
+ /**
66
+ * @param {Object} opts
67
+ * @param {import('../adapters/mega-bus-adapter.js').MegaBusAdapter} opts.bus - 연결된 NATS 버스 어댑터.
68
+ * @param {string} opts.appName - 앱 이름(subject 격리 + 로그).
69
+ * @param {(payload: { ns: string, channel?: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }) => void} opts.deliverBroadcast -
70
+ * 수신한 broadcast 를 로컬 연결에 전달하는 콜백(보통 `app._deliverBroadcast`).
71
+ * @param {(payload: { userId: string, message: { type: string, payload?: Object } }) => void} opts.deliverDirect -
72
+ * 수신한 direct 를 로컬 연결에 전달하는 콜백(보통 `app._deliverDirect`).
73
+ * @param {{ debug?: Function, info?: Function, warn?: Function, error?: Function }} [opts.logger]
74
+ * @param {string} [opts.subjectPrefix] - subject 접두(기본 'mega.ws').
75
+ * @param {{ driver?: 'nats'|'none', ttlMs?: number }} [opts.roster] - roster 동기화 설정.
76
+ */
77
+ constructor({ bus, appName, deliverBroadcast, deliverDirect, logger, subjectPrefix, roster } = /** @type {any} */ ({})) {
78
+ if (!bus || typeof bus.publish !== 'function' || typeof bus.subscribe !== 'function') {
79
+ throw new Error('MegaWsCluster: a connected NATS bus adapter (publish/subscribe) is required.')
80
+ }
81
+ if (typeof appName !== 'string' || appName.length === 0) {
82
+ throw new Error('MegaWsCluster: appName (non-empty string) is required.')
83
+ }
84
+ /** @type {import('../adapters/mega-bus-adapter.js').MegaBusAdapter} */
85
+ this._bus = bus
86
+ this._appName = appName
87
+ this._deliverBroadcast = deliverBroadcast
88
+ this._deliverDirect = deliverDirect
89
+ this._log = logger
90
+ /** 이 프로세스의 고유 식별자 — echo 구분. */
91
+ this.instanceId = randomId()
92
+
93
+ const prefix = typeof subjectPrefix === 'string' && subjectPrefix.length > 0 ? subjectPrefix : DEFAULT_SUBJECT_PREFIX
94
+ this._subjBroadcast = `${prefix}.${appName}.bcast`
95
+ this._subjDirect = `${prefix}.${appName}.direct`
96
+ this._subjRoster = `${prefix}.${appName}.roster`
97
+
98
+ this._rosterDriver = roster?.driver === 'nats' ? 'nats' : 'none'
99
+ this._rosterTtlMs = Number.isInteger(roster?.ttlMs) && /** @type {number} */ (roster?.ttlMs) > 0 ? /** @type {number} */ (roster?.ttlMs) : DEFAULT_ROSTER_TTL_MS
100
+
101
+ /** 이 인스턴스가 보유한 로컬 멤버. sessionId → RosterMember. */
102
+ this._localMembers = new Map()
103
+ /** 클러스터 전역 view. sessionId → RosterMember & { instanceId, expiresAt }. */
104
+ this._view = new Map()
105
+
106
+ /** @type {Array<{ unsubscribe: () => Promise<void> }>} 활성 구독 핸들. */
107
+ this._subs = []
108
+ /** @type {ReturnType<typeof setInterval>|null} */
109
+ this._heartbeatTimer = null
110
+ /** @type {ReturnType<typeof setInterval>|null} */
111
+ this._sweepTimer = null
112
+ this._isStarted = false
113
+ }
114
+
115
+ /** 시작됐는지. @returns {boolean} */
116
+ get isStarted() {
117
+ return this._isStarted
118
+ }
119
+
120
+ /**
121
+ * 구독 시작 — broadcast/direct subject + (roster:'nats' 면) roster subject 를 subscribe 하고, roster
122
+ * 동기화 타이머(heartbeat/sweep)를 켜고, 신규 인스턴스 sync_request 를 보낸다. 멱등(중복 호출 무시).
123
+ * @returns {Promise<void>}
124
+ */
125
+ async start() {
126
+ if (this._isStarted) return
127
+ this._isStarted = true
128
+
129
+ // broadcast 수신 — 자기 echo 는 건너뛴다(이미 로컬 전달함).
130
+ this._subs.push(
131
+ await this._bus.subscribe(this._subjBroadcast, (/** @type {any} */ env) => {
132
+ if (!env || env.o === this.instanceId) return
133
+ this._deliverBroadcast?.({ ns: env.ns, channel: env.channel, message: env.message, exceptSessionIds: env.exceptSessionIds })
134
+ }),
135
+ )
136
+ // direct 수신 — 자기 echo skip. 로컬에 그 userId 매핑이 없으면 deliverDirect 가 no-op.
137
+ this._subs.push(
138
+ await this._bus.subscribe(this._subjDirect, (/** @type {any} */ env) => {
139
+ if (!env || env.o === this.instanceId) return
140
+ this._deliverDirect?.({ userId: env.userId, message: env.message })
141
+ }),
142
+ )
143
+
144
+ if (this._rosterDriver === 'nats') {
145
+ this._subs.push(await this._bus.subscribe(this._subjRoster, (/** @type {any} */ msg) => this._onRosterMessage(msg)))
146
+ // crash 정리: heartbeat 로 자기 멤버를 주기 재공지, sweep 로 만료 멤버 제거.
147
+ this._heartbeatTimer = setInterval(() => this._publishHeartbeat(), Math.max(1000, Math.floor(this._rosterTtlMs / 2)))
148
+ this._sweepTimer = setInterval(() => this._sweepExpired(), this._rosterTtlMs)
149
+ // setInterval 이 이벤트루프를 살리지 않게 unref(프로세스 종료 차단 방지).
150
+ this._heartbeatTimer.unref?.()
151
+ this._sweepTimer.unref?.()
152
+ // 신규 인스턴스 — 전원에게 즉시 heartbeat 요청(빠른 수렴) + 자기 현재 멤버 공지.
153
+ await this._publishRoster({ op: ROSTER_OP.SYNC_REQUEST }).catch(() => {})
154
+ await this._publishHeartbeat()
155
+ }
156
+
157
+ this._log?.debug?.(
158
+ { app: this._appName, instanceId: this.instanceId, roster: this._rosterDriver, subjects: { b: this._subjBroadcast, d: this._subjDirect } },
159
+ 'ws-cluster started',
160
+ )
161
+ }
162
+
163
+ /**
164
+ * broadcast 를 클러스터로 publish. 송신측은 이미 로컬 전달했으므로 `o`(origin)로 자기 echo 를 표시한다.
165
+ * @param {{ ns: string, channel?: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }} payload
166
+ * @returns {Promise<void>}
167
+ */
168
+ async publishBroadcast(payload) {
169
+ await this._bus.publish(this._subjBroadcast, {
170
+ o: this.instanceId,
171
+ ns: payload.ns,
172
+ channel: payload.channel,
173
+ message: payload.message,
174
+ ...(Array.isArray(payload.exceptSessionIds) && payload.exceptSessionIds.length > 0
175
+ ? { exceptSessionIds: payload.exceptSessionIds }
176
+ : {}),
177
+ })
178
+ }
179
+
180
+ /**
181
+ * direct(특정 userId)를 클러스터로 publish — 다른 인스턴스의 같은 userId 세션까지 닿게 한다.
182
+ * @param {string} userId
183
+ * @param {{ type: string, payload?: Object }} message
184
+ * @returns {Promise<void>}
185
+ */
186
+ async publishDirect(userId, message) {
187
+ await this._bus.publish(this._subjDirect, { o: this.instanceId, userId, message })
188
+ }
189
+
190
+ /**
191
+ * roster 에 멤버 추가(joinSession 훅). 로컬 보유 + 전역 view 반영 + 델타 publish. roster:'none' 이면
192
+ * 로컬만 갱신한다.
193
+ * @param {RosterMember} member
194
+ * @returns {void}
195
+ */
196
+ rosterAdd(member) {
197
+ if (!member || typeof member.sessionId !== 'string' || typeof member.ns !== 'string') return
198
+ this._localMembers.set(member.sessionId, member)
199
+ if (this._rosterDriver === 'none') return
200
+ this._upsertView(member, this.instanceId)
201
+ this._publishRoster({ op: ROSTER_OP.ADD, member }).catch((err) =>
202
+ this._log?.warn?.({ err, app: this._appName }, 'ws-cluster roster add publish failed'),
203
+ )
204
+ }
205
+
206
+ /**
207
+ * roster 에서 멤버 제거(disconnect 훅). 로컬 제거 + 전역 view 제거 + 델타 publish.
208
+ * @param {string} sessionId
209
+ * @returns {void}
210
+ */
211
+ rosterRemove(sessionId) {
212
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return
213
+ const had = this._localMembers.delete(sessionId)
214
+ if (this._rosterDriver === 'none') return
215
+ this._view.delete(sessionId)
216
+ if (!had) return // 우리 멤버가 아니면 publish 안 함(다른 인스턴스가 관리).
217
+ this._publishRoster({ op: ROSTER_OP.REMOVE, sessionId }).catch((err) =>
218
+ this._log?.warn?.({ err, app: this._appName }, 'ws-cluster roster remove publish failed'),
219
+ )
220
+ }
221
+
222
+ /**
223
+ * 해당 ns 의 클러스터 전역 접속자 목록. roster:'none' 이면 로컬 멤버만.
224
+ * @param {string} ns
225
+ * @returns {Array<{ sessionId: string, userId: string, metadata?: Object }>}
226
+ */
227
+ roster(ns) {
228
+ const src = this._rosterDriver === 'nats' ? this._view : this._localMembers
229
+ /** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
230
+ const out = []
231
+ for (const m of src.values()) {
232
+ if (m.ns !== ns) continue
233
+ out.push({ sessionId: m.sessionId, userId: m.userId, ...(m.metadata ? { metadata: m.metadata } : {}) })
234
+ }
235
+ return out
236
+ }
237
+
238
+ /**
239
+ * 정리 — 타이머 해제 + 구독 해제 + 자기 멤버 LEAVE 공지(graceful 종료 시 다른 인스턴스 roster 즉시 정리).
240
+ * @returns {Promise<void>}
241
+ */
242
+ async stop() {
243
+ if (!this._isStarted) return
244
+ this._isStarted = false
245
+ if (this._heartbeatTimer) clearInterval(this._heartbeatTimer)
246
+ if (this._sweepTimer) clearInterval(this._sweepTimer)
247
+ this._heartbeatTimer = null
248
+ this._sweepTimer = null
249
+ // graceful: 자기 멤버를 LEAVE 로 공지(crash 가 아닌 정상 종료라 즉시 정리되게).
250
+ if (this._rosterDriver === 'nats' && this._localMembers.size > 0) {
251
+ for (const sessionId of this._localMembers.keys()) {
252
+ await this._publishRoster({ op: ROSTER_OP.REMOVE, sessionId }).catch(() => {})
253
+ }
254
+ }
255
+ for (const sub of this._subs) {
256
+ await sub.unsubscribe().catch(() => {})
257
+ }
258
+ this._subs = []
259
+ this._localMembers.clear()
260
+ this._view.clear()
261
+ this._log?.debug?.({ app: this._appName, instanceId: this.instanceId }, 'ws-cluster stopped')
262
+ }
263
+
264
+ // ── 내부 ───────────────────────────────────────────────────────────────────
265
+
266
+ /**
267
+ * roster subject 수신 처리. 자기 echo 는 건너뛰되, sync_request 만은 자기 요청이어도 무관(상대가 응답).
268
+ * @param {any} msg
269
+ * @returns {void}
270
+ * @private
271
+ */
272
+ _onRosterMessage(msg) {
273
+ if (!msg || typeof msg.op !== 'string') return
274
+ // 다른 인스턴스의 sync_request → 우리 멤버를 즉시 heartbeat 로 응답.
275
+ if (msg.op === ROSTER_OP.SYNC_REQUEST) {
276
+ if (msg.o !== this.instanceId) this._publishHeartbeat().catch(() => {})
277
+ return
278
+ }
279
+ if (msg.o === this.instanceId) return // 자기 add/remove/heartbeat echo — 로컬에서 이미 반영.
280
+ switch (msg.op) {
281
+ case ROSTER_OP.ADD:
282
+ if (msg.member) this._upsertView(msg.member, msg.o)
283
+ break
284
+ case ROSTER_OP.REMOVE:
285
+ if (typeof msg.sessionId === 'string') this._view.delete(msg.sessionId)
286
+ break
287
+ case ROSTER_OP.HEARTBEAT:
288
+ if (Array.isArray(msg.members)) for (const m of msg.members) this._upsertView(m, msg.o)
289
+ break
290
+ default:
291
+ // 알 수 없는 op 는 무시하되 로그(silent 금지) — 프로토콜 진화 시 디버깅 단서.
292
+ this._log?.debug?.({ app: this._appName, op: msg.op }, 'ws-cluster roster unknown op (ignored)')
293
+ }
294
+ }
295
+
296
+ /**
297
+ * view 에 멤버 upsert + 만료시각 갱신. 멤버 출처 instanceId 를 함께 저장(sweep 판단·자기멤버 구분).
298
+ * @param {RosterMember} member @param {string} instanceId @returns {void}
299
+ * @private
300
+ */
301
+ _upsertView(member, instanceId) {
302
+ if (!member || typeof member.sessionId !== 'string' || typeof member.ns !== 'string') return
303
+ this._view.set(member.sessionId, {
304
+ ns: member.ns,
305
+ sessionId: member.sessionId,
306
+ userId: member.userId,
307
+ ...(member.metadata ? { metadata: member.metadata } : {}),
308
+ instanceId,
309
+ expiresAt: Date.now() + this._rosterTtlMs,
310
+ })
311
+ }
312
+
313
+ /**
314
+ * 만료(heartbeat 끊긴 crash 인스턴스) 멤버를 view 에서 제거. 자기 멤버는 항상 fresh 하므로 영향 없음.
315
+ * @returns {void}
316
+ * @private
317
+ */
318
+ _sweepExpired() {
319
+ const now = Date.now()
320
+ let removed = 0
321
+ for (const [sessionId, m] of this._view) {
322
+ if (m.instanceId !== this.instanceId && m.expiresAt < now) {
323
+ this._view.delete(sessionId)
324
+ removed++
325
+ }
326
+ }
327
+ if (removed > 0) this._log?.debug?.({ app: this._appName, removed }, 'ws-cluster roster swept stale members')
328
+ }
329
+
330
+ /**
331
+ * 자기 로컬 멤버 전체를 heartbeat 로 공지(다른 인스턴스의 TTL 갱신) + 자기 view 도 fresh 유지.
332
+ * @returns {Promise<void>}
333
+ * @private
334
+ */
335
+ async _publishHeartbeat() {
336
+ if (this._rosterDriver !== 'nats') return
337
+ const members = [...this._localMembers.values()]
338
+ for (const m of members) this._upsertView(m, this.instanceId) // 자기 멤버 만료시각 갱신.
339
+ await this._publishRoster({ op: ROSTER_OP.HEARTBEAT, members }).catch((err) =>
340
+ this._log?.warn?.({ err, app: this._appName }, 'ws-cluster roster heartbeat publish failed'),
341
+ )
342
+ }
343
+
344
+ /**
345
+ * roster subject 로 메시지 publish(자기 instanceId 를 origin 으로).
346
+ * @param {Object} body @returns {Promise<void>}
347
+ * @private
348
+ */
349
+ async _publishRoster(body) {
350
+ await this._bus.publish(this._subjRoster, { o: this.instanceId, ...body })
351
+ }
352
+ }
@@ -166,6 +166,34 @@ export class MegaWsConnection {
166
166
  }
167
167
  }
168
168
 
169
+ /**
170
+ * `ctx.presence` — 채널 핸들러용 presence 단축 API (ADR-176). ns·conn 을 미리 바인딩해, 채널이
171
+ * `ctx.presence.list()` / `.join({...})` / `.broadcast({...})` / `.directToUser(...)` 로 클러스터 presence 를
172
+ * 다룬다. roster 동기화·클러스터 fan-out 은 프레임워크(wsCluster, NATS)가 처리하므로 채널은 비즈니스
173
+ * 로직만 작성한다(개발자가 redis/배선 코드를 쓰지 않는다). mock app(단위 테스트)·ns 부재면 null.
174
+ *
175
+ * @param {import('./mega-app.js').MegaApp|null|undefined} app
176
+ * @param {MegaWsConnection} conn
177
+ * @param {string|undefined} ns
178
+ * @returns {{ list: () => Array<object>, join: (entry: object) => void, directToUser: (userId: string, message: object) => void, broadcast: (args: object) => void } | null}
179
+ */
180
+ function buildWsPresence(app, conn, ns) {
181
+ if (!app || typeof (/** @type {any} */ (app).joinSession) !== 'function' || typeof ns !== 'string') return null
182
+ const a = /** @type {any} */ (app)
183
+ return {
184
+ /** 이 채널 ns 의 **클러스터 전역** 접속자 목록(wsCluster roster). 미배선이면 로컬 멤버만. */
185
+ list: () => a.roster(ns),
186
+ /** 연결을 신원에 매핑 — `{ userId, sessionId, channels?, metadata? }`. roster 등록이 자동으로 따라온다. */
187
+ join: (entry) => {
188
+ a.joinSession(conn, entry)
189
+ },
190
+ /** 특정 userId 에게 직접 전송(클러스터 전역, joinSession 매핑된 세션만). */
191
+ directToUser: (userId, message) => a.directToUser(userId, message),
192
+ /** 이 채널 ns 전체 broadcast(클러스터 전역) — `{ channel?, message, exceptSessionIds? }`. */
193
+ broadcast: (args) => a.broadcast({ ns, ...args }),
194
+ }
195
+ }
196
+
169
197
  /**
170
198
  * 핸드셰이크 완료된 raw 소켓에 채널 라이프사이클을 구동한다.
171
199
  *
@@ -204,6 +232,9 @@ export function driveWsConnection({ raw, req, route, app, codec, log, auth = nul
204
232
  req,
205
233
  connId: conn.id,
206
234
  tracer: MegaTracing.tracer, // ctx.tracer.span(name, fn) — WS 핸들러에서도 사용자 직접 span(ADR-126).
235
+ // presence 단축 API (ADR-176) — list(클러스터 roster)/join/directToUser/broadcast 를 ns·conn 바인딩으로
236
+ // 노출. 클러스터 동기화는 wsCluster 가 처리하므로 채널은 비즈니스 로직만 쓴다. mock app 이면 null.
237
+ presence: buildWsPresence(app, conn, route.ns),
207
238
  ...(app?.adapterAccessors ?? {}),
208
239
  }
209
240
  channel._bind({ ctx, app, log, services: null })
@@ -1,115 +0,0 @@
1
- // @ts-check
2
- /**
3
- * chat-bus.js — redis pub/sub 기반 cluster-wide 채팅 broadcast (ADR-158).
4
- *
5
- * `app.broadcast`(로컬 ns fan-out)는 단일 프로세스 안에서만 전달된다 — 클러스터(워커 N개)에선 다른
6
- * 워커에 붙은 클라에게 닿지 않는다. hub 프로세스 없이 cluster-wide 전파를 하기 위해 redis pub/sub 를 쓴다:
7
- * - 송신: 메시지를 redis 채널(`ws:chat:bcast`)에 **PUBLISH** 만 한다(로컬 직접 전달 안 함).
8
- * - 수신: **워커마다** 전용 구독 연결(ioredis `duplicate()`)이 그 채널을 **SUBSCRIBE** 하고, 도착한
9
- * 메시지를 자기 워커의 로컬 연결들에 전달한다.
10
- * 결과: PUBLISH 1건 → 모든 워커의 구독자가 받아 각자 로컬 전달 → 전 클러스터 클라가 정확히 1회씩 수신
11
- * (송신자 본인 워커도 구독자 경로로 전달받아 echo). 구독 연결은 명령 전용 연결과 분리해야 하므로
12
- * (subscribe 모드 연결은 일반 명령 불가) `demo` 캐시의 raw ioredis 를 `duplicate()` 한다.
13
- *
14
- * 접속자 명단(roster)도 cluster-wide 다 — redis HASH(`ws:chat:roster`, field=sessionId→userName)에 두어
15
- * 어느 워커에서든 같은 명단·인원수를 본다(INCR/DECR 카운터의 비정상종료 드리프트 회피, 명단까지 공유).
16
- *
17
- * @module channels/chat-bus
18
- */
19
- import { MegaShutdown } from 'mega-framework'
20
-
21
- /** cluster-wide 전파 채널(redis pub/sub 은 db 와 무관한 글로벌 네임스페이스). */
22
- export const BCAST_CHANNEL = 'ws:chat:bcast'
23
- /** 접속자 명단 HASH 키 — field=sessionId, value=userName. */
24
- export const ROSTER_KEY = 'ws:chat:roster'
25
-
26
- /** 이 워커의 로컬 연결 레지스트리. sock → { sessionId, userName }. */
27
- const localConns = new Map()
28
- /** 이 워커의 전용 구독 연결(1개, lazy). */
29
- let subscriber = null
30
-
31
- /**
32
- * 이 워커의 구독자를 1회 보장한다(첫 연결 시). 채널 SUBSCRIBE + 메시지 → 로컬 전달 + graceful 종료 hook.
33
- *
34
- * @param {any} app - MegaApp(로깅용).
35
- * @param {import('ioredis').Redis} redisNative - `demo` 캐시의 raw ioredis(여기서 duplicate).
36
- * @returns {void}
37
- */
38
- export function ensureSubscriber(app, redisNative) {
39
- if (subscriber) return
40
- const log = app?.fastify?.log
41
- subscriber = redisNative.duplicate()
42
- subscriber.on('message', (/** @type {string} */ _channel, /** @type {string} */ raw) => {
43
- let env
44
- try {
45
- env = JSON.parse(raw)
46
- } catch (err) {
47
- // 손상 페이로드는 전달하지 않는다(사유 명시 + 로그, silent 금지).
48
- log?.warn?.({ err }, 'chat-bus subscribe payload parse failed')
49
- return
50
- }
51
- deliverLocal(env)
52
- })
53
- subscriber.on('error', (/** @type {Error} */ err) => {
54
- // 구독 연결 오류는 비치명적(ioredis 가 재연결) — 로그만.
55
- log?.warn?.({ err }, 'chat-bus subscriber connection error')
56
- })
57
- // subscribe 실패는 전파 자체가 죽는 심각한 신호 — 로그 + throw(fail-closed, silent 금지).
58
- subscriber.subscribe(BCAST_CHANNEL).catch((err) => {
59
- log?.error?.({ err, channel: BCAST_CHANNEL }, 'chat-bus subscribe failed')
60
- throw err
61
- })
62
- // 워커 graceful 종료 시 구독 연결 정리(이벤트루프 누수 방지).
63
- MegaShutdown.register('sample-chat-bus', async () => {
64
- const s = subscriber
65
- subscriber = null
66
- localConns.clear()
67
- if (s) await s.quit().catch(() => {})
68
- })
69
- }
70
-
71
- /**
72
- * 구독으로 받은 메시지를 이 워커의 로컬 연결에 전달한다. exceptSessionIds 에 든 세션은 건너뛴다.
73
- * @param {{ message: { type: string, payload?: object }, exceptSessionIds?: string[] }} env
74
- * @returns {void}
75
- */
76
- function deliverLocal({ message, exceptSessionIds }) {
77
- if (!message || typeof message.type !== 'string') return
78
- const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
79
- for (const [sock, meta] of localConns) {
80
- if (!sock.isOpen) continue
81
- if (except && except.has(meta.sessionId)) continue
82
- sock.send({ type: message.type, payload: message.payload })
83
- }
84
- }
85
-
86
- /**
87
- * 로컬 연결 등록(onConnect). @param {any} sock @param {{ sessionId: string, userName: string }} meta
88
- * @returns {void}
89
- */
90
- export function registerConn(sock, meta) {
91
- localConns.set(sock, meta)
92
- }
93
-
94
- /** 로컬 연결 해제(onDisconnect). @param {any} sock @returns {void} */
95
- export function unregisterConn(sock) {
96
- localConns.delete(sock)
97
- }
98
-
99
- /**
100
- * cluster-wide 전파 — redis 채널에 PUBLISH(모든 워커 구독자가 받아 로컬 전달).
101
- * @param {import('ioredis').Redis} redisNative
102
- * @param {{ message: { type: string, payload?: object }, exceptSessionIds?: string[] }} env
103
- * @returns {Promise<void>}
104
- */
105
- export async function publish(redisNative, env) {
106
- await redisNative.publish(BCAST_CHANNEL, JSON.stringify(env))
107
- }
108
-
109
- /** 테스트 정리용 — 구독 연결 종료 + 레지스트리 초기화. @returns {Promise<void>} */
110
- export async function closeChatBus() {
111
- const s = subscriber
112
- subscriber = null
113
- localConns.clear()
114
- if (s) await s.quit().catch(() => {})
115
- }