mega-framework 0.1.0 → 0.1.2
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/.env +1 -1
- package/package.json +2 -2
- package/sample/crud/.env +10 -1
- package/sample/crud/apps/main/app.config.js +12 -0
- package/sample/crud/apps/main/channels/chat-channel.js +73 -76
- package/sample/crud/mega.config.js +13 -0
- package/sample/crud/scripts/start-ws-hub.sh +40 -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/sample/crud/yarn.lock +1 -1
- package/sample/simple/package.json +1 -1
- package/src/core/boot.js +42 -0
- package/src/core/config-validator.js +92 -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/boot.js
CHANGED
|
@@ -49,6 +49,7 @@ import { MegaShutdown } from '../lib/mega-shutdown.js'
|
|
|
49
49
|
import { buildLogger } from '../lib/mega-logger.js'
|
|
50
50
|
import { buildFromGlobalConfig, connectAll, get as getAdapter, entries as adapterEntries } from '../adapters/adapter-manager.js'
|
|
51
51
|
import { buildWorkers, startAll as startWorkers, contextProxy as workersContext } from './workers-manager.js'
|
|
52
|
+
import { MegaWsCluster } from './ws-cluster.js'
|
|
52
53
|
import * as MegaMetrics from '../lib/mega-metrics.js'
|
|
53
54
|
import * as MegaTracing from '../lib/mega-tracing.js'
|
|
54
55
|
import { MegaWsHub } from '../cli/ws-hub.js'
|
|
@@ -293,6 +294,47 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
|
|
|
293
294
|
megaApps.push(app)
|
|
294
295
|
}
|
|
295
296
|
|
|
297
|
+
// 클러스터 전송 자동배선 (ADR-176) — 앱당 **하나**(상호배타, config-validator 가 충돌 fail-fast). config 로 선택:
|
|
298
|
+
// - app.config `bridgeHub` → **WS Hub**(`app.connectHub`, 평문 12-타입). `mega ws-hub` 서버에 자동 연결.
|
|
299
|
+
// - global `wsCluster.bus`(NATS) → **MegaWsCluster**(NATS pub/sub broadcast + roster 동기화).
|
|
300
|
+
// 둘 다 개발자가 connectHub 같은 코드를 쓰지 않고 config 만으로 동작한다(ADR-176 이 ADR-137 의 자동배선
|
|
301
|
+
// 거부를 **명시 config 가 있을 때만** 번복 — 설정이 곧 의도라 "추측 배선" 아님). megaApps[i] 와 apps[i] 동순서.
|
|
302
|
+
const wsClusterCfg = /** @type {any} */ (global).wsCluster
|
|
303
|
+
const wsClusterBus = wsClusterCfg?.bus ? getAdapter('bus', wsClusterCfg.bus) : null
|
|
304
|
+
for (let i = 0; i < megaApps.length; i++) {
|
|
305
|
+
const app = megaApps[i]
|
|
306
|
+
const a = /** @type {any} */ (app) // _deliverBroadcast/_deliverDirect 는 framework-internal(@private).
|
|
307
|
+
const bridgeHub = /** @type {any} */ (apps[i].config).bridgeHub
|
|
308
|
+
if (bridgeHub?.url) {
|
|
309
|
+
// WS Hub 자동연결(ADR-065/176). connectHub 가 _hubLink 를 세워 app.broadcast/joinSession 의 hub 경로를
|
|
310
|
+
// 활성화한다(shutdown hook 도 connectHub 가 등록). 허브 다운이어도 boot 를 막지 않는다 — 초기 연결
|
|
311
|
+
// 실패는 warn 하고(retry 설정 시 백그라운드 재연결, ADR-098) 앱은 계속 뜬다(로컬 전달은 동작).
|
|
312
|
+
try {
|
|
313
|
+
await app.connectHub(bridgeHub)
|
|
314
|
+
logger?.debug?.({ app: app.name, url: bridgeHub.url }, 'boot.bridgeHub connected (ADR-176)')
|
|
315
|
+
} catch (err) {
|
|
316
|
+
logger?.warn?.({ err, app: app.name, url: bridgeHub.url }, 'boot.bridgeHub initial connect failed (retry if configured)')
|
|
317
|
+
}
|
|
318
|
+
} else if (wsClusterBus) {
|
|
319
|
+
const cluster = new MegaWsCluster({
|
|
320
|
+
bus: /** @type {any} */ (wsClusterBus),
|
|
321
|
+
appName: app.name,
|
|
322
|
+
deliverBroadcast: (/** @type {any} */ p) => a._deliverBroadcast(p),
|
|
323
|
+
deliverDirect: (/** @type {any} */ p) => a._deliverDirect(p),
|
|
324
|
+
subjectPrefix: wsClusterCfg.subjectPrefix,
|
|
325
|
+
roster: wsClusterCfg.roster,
|
|
326
|
+
logger: /** @type {any} */ (app.fastify.log),
|
|
327
|
+
})
|
|
328
|
+
await cluster.start()
|
|
329
|
+
app.setWsCluster(cluster)
|
|
330
|
+
// MegaShutdown LIFO — 어댑터 disconnect 보다 먼저 정리(graceful LEAVE 가 NATS 끊기기 전에 나가게).
|
|
331
|
+
MegaShutdown.register(`mega-ws-cluster:${app.name}`, async () => cluster.stop())
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (wsClusterBus) {
|
|
335
|
+
logger?.debug?.({ bus: wsClusterCfg.bus, roster: wsClusterCfg.roster?.driver ?? 'none' }, 'boot.wsCluster wired (ADR-176)')
|
|
336
|
+
}
|
|
337
|
+
|
|
296
338
|
// 9단계: HTTP listen(분기 — CLI/테스트가 listen=false 로 mount 까지만 받을 수 있음).
|
|
297
339
|
if (listen) {
|
|
298
340
|
await server.listen({ port: resolvedPort, host: resolvedHost })
|
|
@@ -75,6 +75,12 @@ export function validateGlobalConfig(globalConfig) {
|
|
|
75
75
|
})
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// 4b) wsCluster 검증 (ADR-176) — NATS 기반 WS 클러스터 broadcast/roster 자동배선.
|
|
79
|
+
// `bus` 가 있으면 boot 가 자동 배선하므로, 가리키는 글로벌 버스 키가 실제 NATS 인지 부팅 시 확정한다.
|
|
80
|
+
if (globalConfig.wsCluster !== undefined) {
|
|
81
|
+
validateWsClusterConfig(globalConfig.wsCluster, globalConfig.services?.buses)
|
|
82
|
+
}
|
|
83
|
+
|
|
78
84
|
// 5) jobs / schedules / workers 명시 등록 배열 검증 (M-2 + ADR-124 / 04-data-models §1.1).
|
|
79
85
|
// GLOBAL_ONLY_KEYS 에 키만 등록돼 있고 shape 검증이 없어, `jobs: SendEmailJob`(배열 잊음)이나
|
|
80
86
|
// 원소가 클래스 아닌 값이면 흡수 로직이 silent drop 했다. 부팅 시 fail-fast 로 드러낸다.
|
|
@@ -119,6 +125,68 @@ function validateSessionSecret(secret) {
|
|
|
119
125
|
}
|
|
120
126
|
}
|
|
121
127
|
|
|
128
|
+
/** wsCluster.roster.driver 허용값. */
|
|
129
|
+
const WS_CLUSTER_ROSTER_DRIVERS = Object.freeze(['nats', 'none'])
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `wsCluster` config 검증 (ADR-176). 자동배선의 트리거라 잘못된 설정을 부팅 시 fail-fast 한다.
|
|
133
|
+
* (1) `bus`(string) 필수 — 가리키는 글로벌 버스 키가 `services.buses` 에 있고 driver:'nats' 여야 한다.
|
|
134
|
+
* (2) `roster.driver` 가 있으면 'nats'|'none' 중 하나.
|
|
135
|
+
* (3) `roster.ttlMs` 가 있으면 양의 정수.
|
|
136
|
+
*
|
|
137
|
+
* @param {any} wsCluster - globalConfig.wsCluster.
|
|
138
|
+
* @param {Record<string, any>|undefined} buses - globalConfig.services.buses.
|
|
139
|
+
* @throws {MegaConfigError} 위 위반 시.
|
|
140
|
+
*/
|
|
141
|
+
function validateWsClusterConfig(wsCluster, buses) {
|
|
142
|
+
if (typeof wsCluster !== 'object' || wsCluster === null || Array.isArray(wsCluster)) {
|
|
143
|
+
throw new MegaConfigError('config.wsCluster_invalid', 'wsCluster must be an object ({ bus, roster? }).', {
|
|
144
|
+
details: { type: Array.isArray(wsCluster) ? 'array' : typeof wsCluster },
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
const bus = wsCluster.bus
|
|
148
|
+
if (typeof bus !== 'string' || bus.length === 0) {
|
|
149
|
+
throw new MegaConfigError('config.wsCluster_bus_required', 'wsCluster.bus (a global buses key) is required.', {
|
|
150
|
+
details: { bus },
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
const busDef = buses?.[bus]
|
|
154
|
+
if (!busDef) {
|
|
155
|
+
throw new MegaConfigError(
|
|
156
|
+
'config.wsCluster_bus_not_found',
|
|
157
|
+
`wsCluster.bus '${bus}' is not defined in services.buses. Declared: [${Object.keys(buses ?? {}).join(', ') || '(none)'}].`,
|
|
158
|
+
{ details: { bus, declared: Object.keys(buses ?? {}) } },
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
if (busDef.driver !== 'nats') {
|
|
162
|
+
throw new MegaConfigError(
|
|
163
|
+
'config.wsCluster_bus_not_nats',
|
|
164
|
+
`wsCluster.bus '${bus}' must be a NATS bus (driver:'nats'), got driver:'${busDef.driver}'. Cluster fan-out uses NATS pub/sub (ADR-176).`,
|
|
165
|
+
{ details: { bus, driver: busDef.driver } },
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
const roster = wsCluster.roster
|
|
169
|
+
if (roster !== undefined) {
|
|
170
|
+
if (typeof roster !== 'object' || roster === null || Array.isArray(roster)) {
|
|
171
|
+
throw new MegaConfigError('config.wsCluster_roster_invalid', 'wsCluster.roster must be an object ({ driver, ttlMs? }).', {
|
|
172
|
+
details: { type: Array.isArray(roster) ? 'array' : typeof roster },
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
if (roster.driver !== undefined && !WS_CLUSTER_ROSTER_DRIVERS.includes(roster.driver)) {
|
|
176
|
+
throw new MegaConfigError(
|
|
177
|
+
'config.wsCluster_roster_driver_invalid',
|
|
178
|
+
`wsCluster.roster.driver must be one of [${WS_CLUSTER_ROSTER_DRIVERS.join(', ')}], got '${roster.driver}'.`,
|
|
179
|
+
{ details: { driver: roster.driver } },
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
if (roster.ttlMs !== undefined && (!Number.isInteger(roster.ttlMs) || roster.ttlMs <= 0)) {
|
|
183
|
+
throw new MegaConfigError('config.wsCluster_roster_ttl_invalid', 'wsCluster.roster.ttlMs must be a positive integer (ms).', {
|
|
184
|
+
details: { ttlMs: roster.ttlMs },
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
122
190
|
/**
|
|
123
191
|
* `jobs`/`schedules`/`workers` 같은 "클래스(함수) 배열" config 키를 검증한다 (M-2/ADR-124). 미정의면
|
|
124
192
|
* 통과(선택 키). 정의됐으면 (1) 배열이어야 하고 (2) 모든 원소가 함수(클래스)여야 한다 — 위반 시 부팅 fail-fast.
|
|
@@ -197,6 +265,30 @@ export function validateAppConfig(appConfig, expectedFolderName, globalConfig) {
|
|
|
197
265
|
)
|
|
198
266
|
}
|
|
199
267
|
|
|
268
|
+
// 2b) bridgeHub(WS Hub 브릿지, ADR-065/176) 검증 + wsCluster 상호배타.
|
|
269
|
+
// 클러스터 전송은 앱당 **하나**다 — bridgeHub(WS Hub) 와 global wsCluster(NATS) 를 동시에 쓰면
|
|
270
|
+
// app.broadcast 가 양쪽으로 나가 이중 전파된다. 부팅 시 fail-fast 로 막는다(boot 가 둘 중 하나만 배선).
|
|
271
|
+
if (appConfig.bridgeHub !== undefined) {
|
|
272
|
+
const bh = appConfig.bridgeHub
|
|
273
|
+
if (typeof bh !== 'object' || bh === null || Array.isArray(bh)) {
|
|
274
|
+
throw new MegaConfigError('config.bridgeHub_invalid', `app '${expectedFolderName}': bridgeHub must be an object ({ url, token, ... }).`, {
|
|
275
|
+
details: { app: expectedFolderName },
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
if (typeof bh.url !== 'string' || bh.url.length === 0) {
|
|
279
|
+
throw new MegaConfigError('config.bridgeHub_url_required', `app '${expectedFolderName}': bridgeHub.url (ws:// hub address) is required.`, {
|
|
280
|
+
details: { app: expectedFolderName },
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
if (globalConfig?.wsCluster?.bus) {
|
|
284
|
+
throw new MegaConfigError(
|
|
285
|
+
'config.cluster_transport_conflict',
|
|
286
|
+
`app '${expectedFolderName}': bridgeHub(WS Hub) 와 global wsCluster(NATS) 를 동시에 쓸 수 없다 — 클러스터 전송은 앱당 하나만 선택하세요(ADR-176).`,
|
|
287
|
+
{ details: { app: expectedFolderName, bridgeHubUrl: bh.url, wsClusterBus: globalConfig.wsCluster.bus } },
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
200
292
|
// 3) Shared-Reference 키 검증 — 참조하는 키가 globalConfig.services 에 존재해야 함
|
|
201
293
|
for (const refKey of SHARED_REFERENCE_KEYS) {
|
|
202
294
|
if (!appConfig[refKey]) continue
|
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 })
|