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.
- package/package.json +1 -1
- package/sample/crud/apps/main/channels/chat-channel.js +73 -76
- package/sample/crud/mega.config.js +9 -0
- package/sample/crud/test/apps/main/chat-channel.test.js +66 -61
- package/sample/crud/test/apps/main/ws-chat.integration.test.js +7 -6
- package/src/core/boot.js +30 -0
- package/src/core/config-validator.js +68 -0
- package/src/core/mega-app.js +55 -0
- package/src/core/scope-registry.js +1 -0
- package/src/core/ws-cluster.js +352 -0
- package/src/core/ws-upgrade.js +31 -0
- package/sample/crud/apps/main/channels/chat-bus.js +0 -115
- package/sample/crud/test/apps/main/chat-bus.test.js +0 -101
package/src/core/mega-app.js
CHANGED
|
@@ -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
|
+
}
|
package/src/core/ws-upgrade.js
CHANGED
|
@@ -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
|
-
}
|