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/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
@@ -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 })