mega-framework 0.1.2 → 0.1.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mega-framework",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
5
5
  "type": "module",
6
6
  "engines": {
@@ -82,6 +82,10 @@ export default {
82
82
  bridgeId: process.env.BRIDGE_ID ?? 'main-1',
83
83
  channels: ['chat'],
84
84
  retry: { retries: 30, minTimeout: 1000, maxTimeout: 10_000 },
85
+ // 접속자 목록(roster)은 broadcast(허브)와 별개로 **redis** 로 관리한다(ADR-177). 멀티 허브에서도 명단이
86
+ // 정합하고, 신규/재연결 브릿지가 즉시 전체 명단을 받는다(허브 presence 스냅샷 불요). cache='demo'(글로벌
87
+ // redis 캐시, db1). 룸은 앱 비즈니스 로직 — 프레임워크는 **채널별 전체 접속자**만 관리한다(키 ws:roster:<channel>).
88
+ roster: { driver: 'redis', cache: 'demo', ttlMs: 30_000 },
85
89
  },
86
90
 
87
91
  // rate limit 상향(ADR-073) — /demo/worker 데모는 1초 간격 하트비트 ping(메인 스레드 non-block 시연)으로
@@ -2,14 +2,15 @@
2
2
  /**
3
3
  * ChatChannel — /ws/chat 실시간 채팅 WS 채널 (ADR-158/176).
4
4
  *
5
- * 클러스터 전파(broadcast)와 접속자 목록(roster) 동기화는 **프레임워크가 자동 처리**한다(ADR-176,
6
- * `wsCluster` = NATS). 채널은 `ctx.presence`/`ctx.app.broadcast`**비즈니스 로직만** 작성하고, redis
7
- * pub/sub·roster 동기화 코드를 직접 두지 않는다(개발자 배선 불요). 최근 메시지 기록(history)만 redis
8
- * (`demo` 캐시)에 KV 로 둔다 — 이건 전파/roster 가 아니라 단순 저장이다.
5
+ * 클러스터 전파(broadcast)와 접속자 목록(roster) **프레임워크가 자동 처리**한다 — 전파는 **WS Hub**(broadcast
6
+ * fan-out, ADR-176), 접속자 목록은 **redis**(채널별 roster, ADR-177)**분리**된다(멀티 허브 정합). 채널은
7
+ * `ctx.presence`/`ctx.app.broadcast` 로 **비즈니스 로직만** 작성하고 전파·roster 동기화 코드를 직접 두지 않는다.
8
+ * 최근 메시지 기록(history)만 redis(`demo` 캐시)에 KV 로 둔다 — 이건 전파/roster 가 아니라 단순 저장이다.
9
9
  *
10
- * - 전파: `ctx.presence.broadcast({ message })` → 같은 ns 전 클러스터 클라가 1회씩 수신(NATS fan-out).
11
- * - 접속자: `ctx.presence.join({...})`(자동 roster 등록·동기화) + `ctx.presence.list()`(클러스터 전역 명단).
10
+ * - 전파: `ctx.presence.broadcast({ message })` → 같은 채널의 전 클러스터 클라가 1회씩 수신(허브 fan-out).
11
+ * - 접속자: `ctx.presence.join({...})`(자동 roster 등록) + `await ctx.presence.list()`(채널 전역 명단, redis).
12
12
  * - 기록: redis RPUSH/LTRIM(최근 N건 cap), 연결 시 replay.
13
+ * - 룸(채널 내 세분화)은 앱 몫 — 프레임워크는 **채널별 전체 접속자**만 관리한다.
13
14
  *
14
15
  * ASP: `/ws/chat` 는 asp.websocket.namespaces 라 E: 암호화. 코덱은 프레임워크가 종단(평문 envelope 만 다룸).
15
16
  * 인증: before(makeWsRequireAuth) 가 로그인 세션만 통과 → ctx.auth(userId/sessionId/userName) 보장.
@@ -39,7 +40,7 @@ export class ChatChannel extends MegaWebSocketController {
39
40
 
40
41
  // 최근 기록 replay (redis KV — 전파/roster 와 무관한 단순 저장).
41
42
  const items = await this._loadHistory(ctx, sock)
42
- const members = this._members(ctx) // 클러스터 전역 명단(자동 동기화된 roster).
43
+ const members = await this._members(ctx) // 클러스터 전역 명단(redis roster, ADR-177).
43
44
 
44
45
  // 입장 본인: 신원 + 최근기록 + 현재 명단/인원 + 워커 PID(클러스터 확인용).
45
46
  sock.send({
@@ -86,7 +87,7 @@ export class ChatChannel extends MegaWebSocketController {
86
87
  if (!ctx.auth) return
87
88
  const { sessionId, userName } = ctx.auth
88
89
  // 본인을 제외한 명단(자동 roster 제거 타이밍과 무관하게 sessionId 로 명시 제외).
89
- const members = this._members(ctx, sessionId)
90
+ const members = await this._members(ctx, sessionId)
90
91
  ctx.presence.broadcast({
91
92
  channel: CHANNEL,
92
93
  message: { type: 'chat.presence', payload: { event: 'leave', userName, online: members.length, members } },
@@ -95,13 +96,14 @@ export class ChatChannel extends MegaWebSocketController {
95
96
  }
96
97
 
97
98
  /**
98
- * 클러스터 전역 접속자 이름 목록(자동 동기화된 roster). exceptSessionId 주어지면 세션은 뺀다.
99
- * @param {any} ctx @param {string} [exceptSessionId] @returns {string[]}
99
+ * 클러스터 전역 접속자 이름 목록(프레임워크가 redis 동기화한 채널 roster, ADR-177). `ctx.presence.list()`
100
+ * async(redis 조회)다. exceptSessionId 주어지면 세션은 뺀다.
101
+ * @param {any} ctx @param {string} [exceptSessionId] @returns {Promise<string[]>}
100
102
  * @private
101
103
  */
102
- _members(ctx, exceptSessionId) {
103
- return ctx.presence
104
- .list()
104
+ async _members(ctx, exceptSessionId) {
105
+ const list = await ctx.presence.list()
106
+ return list
105
107
  .filter((/** @type {any} */ m) => exceptSessionId == null || m.sessionId !== exceptSessionId)
106
108
  .map((/** @type {any} */ m) => m.metadata?.userName)
107
109
  .filter((/** @type {any} */ n) => typeof n === 'string' && n.length > 0)
@@ -212,6 +212,7 @@
212
212
  "ws_status_closed": "Disconnected",
213
213
  "ws_status_error": "Error",
214
214
  "ws_online": "{n} online",
215
+ "ws_members_label": "Online",
215
216
  "ws_presence_join": "{user} joined.",
216
217
  "ws_presence_leave": "{user} left.",
217
218
  "ws_empty": "No messages yet. Be the first to say hi.",
@@ -212,6 +212,7 @@
212
212
  "ws_status_closed": "연결 끊김",
213
213
  "ws_status_error": "오류",
214
214
  "ws_online": "접속자 {n}명",
215
+ "ws_members_label": "접속자",
215
216
  "ws_presence_join": "{user} 님이 입장했습니다.",
216
217
  "ws_presence_leave": "{user} 님이 퇴장했습니다.",
217
218
  "ws_empty": "아직 메시지가 없습니다. 첫 메시지를 보내보세요.",
@@ -15,6 +15,7 @@ const messagesEl = document.getElementById('chat-messages')
15
15
  const statusEl = document.getElementById('chat-status')
16
16
  const statusDot = document.getElementById('chat-status-dot')
17
17
  const onlineEl = document.getElementById('chat-online')
18
+ const membersListEl = document.getElementById('chat-members-list')
18
19
  const workerEl = document.getElementById('chat-worker')
19
20
  const form = /** @type {HTMLFormElement} */ (document.getElementById('chat-form'))
20
21
  const input = /** @type {HTMLInputElement} */ (document.getElementById('chat-input'))
@@ -49,12 +50,39 @@ function setStatus(state) {
49
50
  if (canSend) input.focus()
50
51
  }
51
52
 
52
- /** 접속자 수 뱃지 + 명단 툴팁 갱신(roster 는 cluster-wide redis HASH 공유). */
53
+ /**
54
+ * 접속자 수 뱃지 + **가시적 명단(칩)** 갱신. roster 는 cluster-wide — 프레임워크가 wsCluster(NATS) 또는
55
+ * WS Hub(bridgeHub) 로 워커 전역 접속자를 동기화한다(ADR-176). 서버가 매 chat.history/chat.presence 에
56
+ * 갱신된 members 를 실어 보내므로, 받을 때마다 명단을 통째로 다시 그린다(서버 authoritative).
57
+ * @param {number} [online] @param {string[]} [members]
58
+ */
53
59
  function setRoster(online, members) {
54
60
  if (onlineEl && typeof online === 'number') {
55
61
  onlineEl.textContent = fill(d.tOnline, { n: online })
56
62
  if (Array.isArray(members)) onlineEl.title = members.join(', ')
57
63
  }
64
+ renderMembers(members)
65
+ }
66
+
67
+ /** 접속자 명단 칩을 다시 그린다(textContent 만 — XSS 방지). @param {string[]} [members] */
68
+ function renderMembers(members) {
69
+ if (!membersListEl) return
70
+ const list = Array.isArray(members) ? members : null
71
+ if (!list) return
72
+ membersListEl.replaceChildren() // 기존 칩 제거(innerHTML 미사용).
73
+ if (list.length === 0) {
74
+ const empty = document.createElement('span')
75
+ empty.className = 'text-body-secondary'
76
+ empty.textContent = '—'
77
+ membersListEl.append(empty)
78
+ return
79
+ }
80
+ for (const name of list) {
81
+ const chip = document.createElement('span')
82
+ chip.className = 'badge rounded-pill text-bg-light border'
83
+ chip.textContent = String(name ?? '') // ← XSS 방지: 닉네임은 textContent 로만.
84
+ membersListEl.append(chip)
85
+ }
58
86
  }
59
87
 
60
88
  /** 이 연결이 붙은 클러스터 워커 PID 표시(cluster broadcast 실증용). */
@@ -137,7 +165,7 @@ async function start() {
137
165
  setRoster(payload?.online, payload?.members)
138
166
  setWorker(payload?.workerPid)
139
167
  })
140
- // 채팅 메시지(본인 echo 포함) — cluster-wide redis pub/sub 로 모든 워커에서 도착.
168
+ // 채팅 메시지(본인 echo 포함) — cluster-wide(프레임워크가 WS Hub 또는 NATS 로 모든 워커에 fan-out, ADR-176).
141
169
  sock.on('chat.msg', (payload) => appendMessage(payload ?? {}))
142
170
  // 입퇴장 알림 + 갱신된 명단/수(cluster-wide).
143
171
  sock.on('chat.presence', (payload) => {
@@ -37,6 +37,13 @@
37
37
  <span id="chat-online" class="badge text-bg-light" data-bs-toggle="tooltip" title=""></span>
38
38
  </div>
39
39
 
40
+ <%# 접속자 명단(cluster-wide) — JS(ws-chat.js)가 chat.history/chat.presence 의 members 로 칩을 채운다.
41
+ XSS 방지: 닉네임은 textContent 로만 삽입. %>
42
+ <div id="chat-members" class="px-3 py-2 d-flex flex-wrap align-items-center gap-1 border-bottom bg-body-tertiary small">
43
+ <span class="text-body-secondary me-1"><%= t('ws_members_label', { defaultValue: '접속자' }) %>:</span>
44
+ <span id="chat-members-list" class="d-flex flex-wrap gap-1"></span>
45
+ </div>
46
+
40
47
  <%# 메시지 목록 — JS 가 textContent 로만 채운다(XSS 방지: 사용자 입력을 innerHTML 로 넣지 않음). %>
41
48
  <div id="chat-messages" class="list-group list-group-flush overflow-auto" style="height: 22rem" aria-live="polite">
42
49
  <div data-empty class="list-group-item text-body-secondary small text-center py-5"><%= t('ws_empty', { defaultValue: '아직 메시지가 없습니다. 첫 메시지를 보내보세요.' }) %></div>
@@ -1234,7 +1234,7 @@ marked@^18.0.5:
1234
1234
  integrity sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==
1235
1235
 
1236
1236
  "mega-framework@file:../..":
1237
- version "0.1.1"
1237
+ version "0.1.3"
1238
1238
  dependencies:
1239
1239
  "@fastify/cookie" "^11.0.2"
1240
1240
  "@fastify/cors" "^11.2.0"
@@ -6,6 +6,11 @@
6
6
  * 같은 `get/set/del/has` API 를 로컬 파일로 만족시킨다 — driver 키 한 줄만 바꿔 환경 전환(ADR-082).
7
7
  * **신규 의존성 0** (`node:fs/promises`/`node:path`/`node:crypto` 표준만).
8
8
  *
9
+ * ⚠️ **만료 파일 능동 정리 없음(디스크 증가 주의)**: 만료 envelope 은 **재접근(get/has) 시에만 lazy 삭제**된다.
10
+ * set 후 다시 조회되지 않는 만료 키는 파일로 영구 잔존하므로, **짧은 TTL + 높은 키 카디널리티**(요청별 캐시 키
11
+ * 등) 워크로드에선 디스크/inode 가 누적될 수 있다. 그래서 file 캐시는 **dev / 단일·단명 인스턴스 권장**이며,
12
+ * 프로덕션 장기 구동에는 redis 캐시 또는 외부 정리(cron unlink)를 전제로 한다(능동 sweep 은 후속 과제).
13
+ *
9
14
  * # 표준 표면 (MegaCacheAdapter 상속)
10
15
  * - `_connect()` — `fs.mkdir(basePath, { recursive: true })` 로 디렉토리 보장.
11
16
  * - `_disconnect()`— no-op (파일시스템은 close 개념 없음).
@@ -3,8 +3,12 @@
3
3
  * MegaCacheAdapter — key-value 캐시 표준 인터페이스 (추상, 08-class-specs §3.4).
4
4
  *
5
5
  * `ctx.cache(key)` 가 본 베이스 인스턴스를 반환. 구체: `MegaRedisAdapter` /
6
- * `MegaFileAdapter` (ADR-082). 사용자 `key` 에 자동 prefix
7
- * `mega:cache:<appName>:<key>` 가 코어에서 부착됨 (ADR-064).
6
+ * `MegaFileAdapter` (ADR-082).
7
+ *
8
+ * ⚠️ **키 네임스페이스(ADR-064)**: ADR-064 가 `mega:cache:<appName>:<key>` 자동 prefix 를 *결정*했으나
9
+ * **현재 코어에 미구현**이다(get/set/del 이 raw `key` 를 그대로 사용). 멀티앱이 같은 redis 를 공유하면
10
+ * 키 충돌 위험이 있으니, **현재는 사용자가 키에 앱별 네임스페이스를 직접 붙여야 한다**. 자동 prefix 구현은
11
+ * 후속 과제(ADR-064 open). 단일앱(현 sample)에선 무관.
8
12
  *
9
13
  * @module adapters/mega-cache-adapter
10
14
  */
@@ -295,11 +295,16 @@ export class MegaNatsAdapter extends MegaBusAdapter {
295
295
  * 잡 처리 등록 — **queue group** 구독(같은 queue 구독자끼리 load-balance). 기본 queue 이름은 jobName
296
296
  * (같은 잡의 모든 워커가 한 그룹).
297
297
  *
298
+ * ⚠️ **전달 보장 = at-most-once(비영속, H5)**: 이건 **core NATS** queue group 이라 메시지가 디스크에
299
+ * 남지 않는다 — 구독자가 없거나 처리 중 죽으면 그 메시지는 **유실**된다(재시도·DLQ 없음). 유실이
300
+ * 치명적이거나 영속 큐/재시도/DLQ 가 필요하면 **`MegaJobQueue`(JetStream, at-least-once, ADR-028/112)**
301
+ * 를 쓴다. `ctx.bus(x).process` 와 `mega worker`(JetStream) 는 **전달 보장이 다르므로 혼용 금지**.
302
+ *
298
303
  * **subscription 핸들을 반환하지 않는 건 의도적**(L-2): worker 는 앱 수명 동안 상주하는 것이
299
304
  * 정상이고(개별 잡 처리 등록을 런타임에 떼었다 붙였다 하는 패턴은 비표준), 정리는 disconnect 시
300
305
  * `_disconnect()` 의 `nc.drain()` 이 **모든 구독을 일괄 비우며** 처리한다. 개별 unsubscribe 가
301
306
  * 필요한 일시 구독은 `subscribe()`(핸들 반환)를 쓴다 — `process` 는 fire-and-forget `enqueue` 와
302
- * 대칭인 상주 worker 용이다. 영속 큐/재시도/DLQ 가 필요하면 `MegaJob`(jetstream, ADR-028).
307
+ * 대칭인 상주 worker 용이다.
303
308
  *
304
309
  * @param {string} jobName
305
310
  * @param {(payload: any) => any} handler
@@ -30,7 +30,7 @@ function defaultReplFactory() {
30
30
  * @param {(msg: string) => void} [deps.out]
31
31
  * @param {() => (Promise<void> | void)} [deps.shutdown] - 주입용(테스트). REPL 종료 시 호출하는 graceful
32
32
  * shutdown 트리거. 기본 {@link MegaShutdown.now}(등록 hook 실행 후 process.exit).
33
- * @param {(opts: { signals?: string[] }) => void} [deps.setupSignals] - 주입용(테스트). 시그널 핸들러 등록.
33
+ * @param {(opts: { signals?: string[], globalErrorHandlers?: boolean }) => void} [deps.setupSignals] - 주입용(테스트). 시그널 핸들러 등록.
34
34
  * 기본 {@link MegaShutdown.setupSignals}.
35
35
  * @returns {Promise<{ ctx: Record<string, any>, config: object, server: { context: Record<string, any> } }>}
36
36
  */
@@ -44,7 +44,9 @@ export async function startConsole(
44
44
  // 두면 REPL 종료 후에도 열린 핸들(DB 풀·redis·wsHub listen)로 프로세스가 행하고 어댑터가 미정리된다.
45
45
  // 따라서 SIGTERM 은 graceful shutdown 으로 받는다. SIGINT 은 REPL 이 소유(빈 줄 클리어 / 이중 입력 시
46
46
  // 'exit')하므로 가로채지 않는다 — 가로채면 한 번의 Ctrl-C 가 콘솔을 죽인다(ADR-167).
47
- setupSignals({ signals: ['SIGTERM'] })
47
+ // REPL 은 전역 에러 핸들러를 끈다(globalErrorHandlers:false) 대화형에서 사용자 코드 예외가
48
+ // 프로세스를 graceful shutdown 시키면 안 되고, REPL 자체 에러 복구를 방해하지 않기 위함(ADR-178).
49
+ setupSignals({ signals: ['SIGTERM'], globalErrorHandlers: false })
48
50
  const server = replFactory()
49
51
  Object.assign(server.context, { ctx, config: global, mega: { config: global, host } })
50
52
  // REPL 종료(.exit / Ctrl-D / 이중 Ctrl-C)는 'exit' 이벤트로 온다 — 이때 graceful shutdown 으로 어댑터 등을
package/src/core/boot.js CHANGED
@@ -34,6 +34,7 @@
34
34
  * @module core/boot
35
35
  */
36
36
  import { join } from 'node:path'
37
+ import nodeCluster from 'node:cluster' // 워커 식별자(cluster.worker.id)로 bridgeId 유일화(ADR-176, L-3).
37
38
  // 빌트인 어댑터(postgres/mongodb/mariadb/sqlite/redis/file/nats/redlock)는 import 시 레지스트리에
38
39
  // 자기등록한다(ADR-044). CLI 런타임 부팅(`mega start`/`worker`/`scheduler`/`migrate`)은 사용자 코드가
39
40
  // 'mega-framework' 를 import 하기 전에 `buildFromGlobalConfig` 로 driver 를 resolve 하므로, 이 배럴을
@@ -50,6 +51,7 @@ import { buildLogger } from '../lib/mega-logger.js'
50
51
  import { buildFromGlobalConfig, connectAll, get as getAdapter, entries as adapterEntries } from '../adapters/adapter-manager.js'
51
52
  import { buildWorkers, startAll as startWorkers, contextProxy as workersContext } from './workers-manager.js'
52
53
  import { MegaWsCluster } from './ws-cluster.js'
54
+ import { MegaWsRedisRoster } from './ws-roster.js'
53
55
  import * as MegaMetrics from '../lib/mega-metrics.js'
54
56
  import * as MegaTracing from '../lib/mega-tracing.js'
55
57
  import { MegaWsHub } from '../cli/ws-hub.js'
@@ -306,15 +308,40 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
306
308
  const a = /** @type {any} */ (app) // _deliverBroadcast/_deliverDirect 는 framework-internal(@private).
307
309
  const bridgeHub = /** @type {any} */ (apps[i].config).bridgeHub
308
310
  if (bridgeHub?.url) {
311
+ // ⚠️ 클러스터 워커마다 별개 브릿지라 bridgeId 가 **워커별로 유일**해야 한다(허브 sessionId global-unique
312
+ // 계약). 모든 워커가 같은 bridgeId 면 bridge-subscriber sessionId(`bridge:<id>#<ch>`, mega-app
313
+ // _resyncPresence)가 충돌해 허브가 계속 재할당(thrashing)한다(L-3). 설정 bridgeId 를 베이스로 워커
314
+ // 식별자(cluster.worker.id, 단일 프로세스면 pid)를 붙여 유일화한다. instanceId 도 동일하게 유일화
315
+ // (hub-link 는 instanceId 미지정 시 bridgeId 로 폴백하므로).
316
+ const baseId = bridgeHub.bridgeId ?? app.name
317
+ const workerTag = nodeCluster.worker?.id ?? process.pid
318
+ const uniqueBridgeId = `${baseId}-w${workerTag}`
309
319
  // WS Hub 자동연결(ADR-065/176). connectHub 가 _hubLink 를 세워 app.broadcast/joinSession 의 hub 경로를
310
320
  // 활성화한다(shutdown hook 도 connectHub 가 등록). 허브 다운이어도 boot 를 막지 않는다 — 초기 연결
311
321
  // 실패는 warn 하고(retry 설정 시 백그라운드 재연결, ADR-098) 앱은 계속 뜬다(로컬 전달은 동작).
312
322
  try {
313
- await app.connectHub(bridgeHub)
314
- logger?.debug?.({ app: app.name, url: bridgeHub.url }, 'boot.bridgeHub connected (ADR-176)')
323
+ await app.connectHub({ ...bridgeHub, bridgeId: uniqueBridgeId, instanceId: bridgeHub.instanceId ?? uniqueBridgeId })
324
+ logger?.debug?.({ app: app.name, url: bridgeHub.url, bridgeId: uniqueBridgeId }, 'boot.bridgeHub connected (ADR-176)')
315
325
  } catch (err) {
316
326
  logger?.warn?.({ err, app: app.name, url: bridgeHub.url }, 'boot.bridgeHub initial connect failed (retry if configured)')
317
327
  }
328
+ // redis roster 자동배선(ADR-177) — `bridgeHub.roster.driver==='redis'` 면 **채널별 접속자 목록**을 redis HASH 로
329
+ // 관리한다(broadcast 와 별개 — 멀티 허브에서도 정합, 신규/재연결 브릿지가 즉시 전체 명단). 캐시 어댑터의 raw
330
+ // ioredis(`.native`)를 쓰고, heartbeat 로 crash 워커 stale 정리. hub 연결 성패와 무관(roster 는 redis 독립).
331
+ const rosterCfg = /** @type {any} */ (bridgeHub).roster
332
+ if (rosterCfg?.driver === 'redis') {
333
+ const cacheAdapter = /** @type {any} */ (getAdapter('cache', rosterCfg.cache))
334
+ const redis = cacheAdapter?.native
335
+ if (!redis || typeof redis.hset !== 'function') {
336
+ logger?.warn?.({ app: app.name, cache: rosterCfg.cache }, 'boot.wsRoster: cache adapter has no native redis — roster disabled')
337
+ } else {
338
+ const roster = new MegaWsRedisRoster({ redis, getLocalMembers: () => a.localRosterMembers(), ttlMs: rosterCfg.ttlMs, keyPrefix: rosterCfg.keyPrefix, logger })
339
+ roster.startHeartbeat()
340
+ a.setWsRoster(roster)
341
+ MegaShutdown.register(`mega-wsroster:${app.name}`, () => roster.stop())
342
+ logger?.debug?.({ app: app.name, cache: rosterCfg.cache }, 'boot.wsRoster connected (ADR-177, redis)')
343
+ }
344
+ }
318
345
  } else if (wsClusterBus) {
319
346
  const cluster = new MegaWsCluster({
320
347
  bus: /** @type {any} */ (wsClusterBus),
@@ -287,6 +287,45 @@ export function validateAppConfig(appConfig, expectedFolderName, globalConfig) {
287
287
  { details: { app: expectedFolderName, bridgeHubUrl: bh.url, wsClusterBus: globalConfig.wsCluster.bus } },
288
288
  )
289
289
  }
290
+ // bridgeHub.roster(채널별 접속자 목록 백엔드, ADR-177) — broadcast 와 별개. driver='redis' 면 cache 는
291
+ // 글로벌 services.caches 의 **redis** 어댑터 키여야 한다(멀티 허브 정합용 공유 스토어).
292
+ if (bh.roster !== undefined) {
293
+ const r = bh.roster
294
+ if (typeof r !== 'object' || r === null || Array.isArray(r)) {
295
+ throw new MegaConfigError('config.bridgeHub_roster_invalid', `app '${expectedFolderName}': bridgeHub.roster must be an object ({ driver, cache?, ttlMs? }).`, {
296
+ details: { app: expectedFolderName },
297
+ })
298
+ }
299
+ const VALID_ROSTER_DRIVERS = ['redis', 'none']
300
+ if (r.driver !== undefined && !VALID_ROSTER_DRIVERS.includes(r.driver)) {
301
+ throw new MegaConfigError('config.bridgeHub_roster_driver_invalid', `app '${expectedFolderName}': bridgeHub.roster.driver must be one of ${VALID_ROSTER_DRIVERS.join('|')}.`, {
302
+ details: { app: expectedFolderName, driver: r.driver },
303
+ })
304
+ }
305
+ if (r.driver === 'redis') {
306
+ const cacheSvc = typeof r.cache === 'string' ? globalConfig?.services?.caches?.[r.cache] : undefined
307
+ if (typeof r.cache !== 'string' || r.cache.length === 0) {
308
+ throw new MegaConfigError('config.bridgeHub_roster_cache_required', `app '${expectedFolderName}': bridgeHub.roster.cache (global redis cache key) is required when driver='redis'.`, {
309
+ details: { app: expectedFolderName },
310
+ })
311
+ }
312
+ if (!cacheSvc) {
313
+ throw new MegaConfigError('config.bridgeHub_roster_cache_unknown', `app '${expectedFolderName}': bridgeHub.roster.cache '${r.cache}' is not a registered global cache (services.caches).`, {
314
+ details: { app: expectedFolderName, cache: r.cache },
315
+ })
316
+ }
317
+ if (cacheSvc.driver !== 'redis') {
318
+ throw new MegaConfigError('config.bridgeHub_roster_cache_not_redis', `app '${expectedFolderName}': bridgeHub.roster.cache '${r.cache}' must be a redis cache (driver='redis'), got '${cacheSvc.driver}'.`, {
319
+ details: { app: expectedFolderName, cache: r.cache, driver: cacheSvc.driver },
320
+ })
321
+ }
322
+ }
323
+ if (r.ttlMs !== undefined && (!Number.isInteger(r.ttlMs) || r.ttlMs <= 0)) {
324
+ throw new MegaConfigError('config.bridgeHub_roster_ttl_invalid', `app '${expectedFolderName}': bridgeHub.roster.ttlMs must be a positive integer (ms).`, {
325
+ details: { app: expectedFolderName, ttlMs: r.ttlMs },
326
+ })
327
+ }
328
+ }
290
329
  }
291
330
 
292
331
  // 3) Shared-Reference 키 검증 — 참조하는 키가 globalConfig.services 에 존재해야 함
@@ -184,6 +184,9 @@ export class MegaApp {
184
184
  this._hubLink = null
185
185
  /** @type {import('./ws-cluster.js').MegaWsCluster|null} NATS 기반 클러스터 fan-out/roster (ADR-176, boot 자동배선). */
186
186
  this._wsCluster = null
187
+ /** @type {import('./ws-roster.js').MegaWsRedisRoster|null} 채널별 redis roster(ADR-177, boot 자동배선).
188
+ * 접속자 목록(상태)을 redis HASH 로 cluster-wide 관리한다(broadcast 와 별개 — 멀티 허브 정합·즉시 스냅샷). */
189
+ this._wsRoster = null
187
190
  /** ns(=ws path) → 활성 로컬 연결 집합 (hub broadcast 의 local fan-out 용). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
188
191
  this._wsConns = new Map()
189
192
  /** userId → 활성 로컬 연결 집합 (DIRECT 타겟팅, H-latent guard). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
@@ -636,7 +639,17 @@ export class MegaApp {
636
639
  // hub → bridge 푸시를 로컬 소켓에 전달.
637
640
  link.on(HUB_MESSAGE_TYPES.BROADCAST, (msg) => this._deliverBroadcast(/** @type {any} */ (msg).payload))
638
641
  link.on(HUB_MESSAGE_TYPES.DIRECT, (msg) => this._deliverDirect(/** @type {any} */ (msg).payload))
639
- // 재연결 성공 presence 재동기화(ADR-098) — hub 절단 시점 presence 를 잃으므로 다시 JOIN.
642
+ // 허브가 fan-out 하는 presence(JOIN/LEAVE/METADATA)·heartbeatbroadcast 채널 멤버십·keepalive 용으로만
643
+ // 의미 있다. **roster(접속자 목록)는 redis(MegaWsRedisRoster, ADR-177)가 별도 관리**하므로 브릿지는 이들을
644
+ // 소비하지 않는다 — no-op 핸들러로 "no handler for type" 노이즈만 차단한다(JOIN 자체는 joinSession 이
645
+ // broadcast 채널 가입용으로 계속 송신). 멀티 허브에서도 redis 가 single source-of-truth 라 명단이 정합.
646
+ const noopHub = () => {}
647
+ link.on(HUB_MESSAGE_TYPES.JOIN, noopHub)
648
+ link.on(HUB_MESSAGE_TYPES.LEAVE, noopHub)
649
+ link.on(HUB_MESSAGE_TYPES.BULK_LEAVE, noopHub)
650
+ link.on(HUB_MESSAGE_TYPES.METADATA, noopHub)
651
+ link.on(HUB_MESSAGE_TYPES.HEARTBEAT, noopHub)
652
+ // 재연결 성공 시 presence 재동기화(ADR-098) — hub 는 절단 시점 presence 를 잃으므로 broadcast 채널을 다시 JOIN.
640
653
  link.on(MegaHubLink.EVENTS.RECONNECTED, () => this._resyncPresence())
641
654
  await link.connect()
642
655
 
@@ -765,6 +778,15 @@ export class MegaApp {
765
778
  if (this._wsCluster && typeof conn.ns === 'string') {
766
779
  this._wsCluster.rosterAdd({ ns: conn.ns, sessionId, userId, ...(metadata ? { metadata } : {}) })
767
780
  }
781
+ // redis roster 동기화 (ADR-177) — **채널별** 접속자 목록을 redis HASH 에 add(멀티 허브 정합). best-effort.
782
+ if (this._wsRoster && chans.length > 0) {
783
+ const member = { userId, ...(metadata ? { metadata } : {}) }
784
+ for (const ch of chans) {
785
+ this._wsRoster.add(ch, sessionId, member).catch((err) =>
786
+ this.fastify.log?.warn?.({ err, channel: ch, app: this.name }, 'ws-roster add failed'),
787
+ )
788
+ }
789
+ }
768
790
  return this
769
791
  }
770
792
 
@@ -898,8 +920,9 @@ export class MegaApp {
898
920
  * @returns {Array<{ sessionId: string, userId: string, metadata?: Object }>}
899
921
  */
900
922
  roster(ns) {
901
- if (this._wsCluster) return this._wsCluster.roster(ns)
902
- // wsCluster 미배선 joinSession 으로 매핑된 로컬 세션 해당 ns 만.
923
+ if (this._wsCluster) return this._wsCluster.roster(ns) // NATS: 이미 cluster-wide(roster 동기화 포함).
924
+ // ns 기준 로컬 명단(동기 API). **채널 기준 redis roster(ADR-177)** `ctx.presence.list()`(async)가
925
+ // 다룬다 — 이 메서드는 NATS/로컬용 ns 기준 동기 API 로 유지(하위호환).
903
926
  /** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
904
927
  const out = []
905
928
  for (const [sessionId, conn] of this._sessionConns) {
@@ -909,6 +932,65 @@ export class MegaApp {
909
932
  return out
910
933
  }
911
934
 
935
+ /**
936
+ * 채널별 redis roster(ADR-177)를 이 앱에 배선한다. boot 가 `bridgeHub.roster.driver==='redis'` 일 때
937
+ * 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
938
+ * @param {import('./ws-roster.js').MegaWsRedisRoster|null} roster
939
+ * @returns {this}
940
+ */
941
+ setWsRoster(roster) {
942
+ this._wsRoster = roster
943
+ return this
944
+ }
945
+
946
+ /**
947
+ * 주어진 **채널들**의 cluster-wide 접속자 목록 — redis roster(원격 포함) + 로컬 세션을 병합한다(ADR-177).
948
+ * 로컬을 항상 포함해 join 직후 redis HSET 반영 전에도 본인이 빠지지 않게 한다. `ctx.presence.list()` 의
949
+ * redis 경로가 호출(async). roster 미배선이면 로컬 채널 멤버만.
950
+ * @param {string[]} channels
951
+ * @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
952
+ */
953
+ async presenceList(channels) {
954
+ const want = new Set(Array.isArray(channels) ? channels : [])
955
+ /** @type {Map<string, { sessionId: string, userId: string, metadata?: Object }>} */
956
+ const out = new Map()
957
+ // 로컬 세션(이 워커) — 요청 채널에 가입한 것. 항상 fresh(본인 포함).
958
+ for (const [sessionId, conn] of this._sessionConns) {
959
+ if (!conn.isOpen || !conn.channels) continue
960
+ let inCh = false
961
+ for (const ch of conn.channels) {
962
+ if (want.has(ch)) {
963
+ inCh = true
964
+ break
965
+ }
966
+ }
967
+ if (inCh) out.set(sessionId, { sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
968
+ }
969
+ // redis(cluster-wide) — 다른 워커/허브의 세션까지.
970
+ if (this._wsRoster) {
971
+ // 여러 채널 redis 조회를 **병렬화**(Promise.all) — onConnect 핫패스의 직렬 라운드트립 누적 제거(Med).
972
+ const lists = await Promise.all([...want].map((ch) => this._wsRoster.list(ch)))
973
+ for (const list of lists) for (const m of list) if (!out.has(m.sessionId)) out.set(m.sessionId, m)
974
+ }
975
+ return [...out.values()]
976
+ }
977
+
978
+ /**
979
+ * 이 워커의 로컬 멤버 목록 — redis roster heartbeat 갱신 대상(ADR-177). joinSession 으로 매핑된
980
+ * (채널 × 세션)마다 1개. MegaWsRedisRoster 가 주기적으로 이 목록의 expiresAt 을 갱신한다.
981
+ * @returns {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>}
982
+ */
983
+ localRosterMembers() {
984
+ /** @type {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>} */
985
+ const out = []
986
+ for (const [sessionId, conn] of this._sessionConns) {
987
+ if (!conn.isOpen || !conn.channels) continue
988
+ const member = { userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) }
989
+ for (const ch of conn.channels) out.push({ channel: ch, sessionId, member })
990
+ }
991
+ return out
992
+ }
993
+
912
994
  /**
913
995
  * broadcast payload 를 로컬 ns 소켓에 전달한다. message 는 `{ type, payload }` 내부 envelope.
914
996
  *
@@ -999,6 +1081,14 @@ export class MegaApp {
999
1081
  }
1000
1082
  // NATS roster 제거 (ADR-176) — disconnect 시 클러스터 접속자 목록에서 자동 제거(개발자 코드 불요).
1001
1083
  this._wsCluster?.rosterRemove(conn.sessionId)
1084
+ // redis roster 제거 (ADR-177) — 이 세션이 가입한 모든 채널에서 제거. best-effort.
1085
+ if (this._wsRoster && conn.channels) {
1086
+ for (const ch of conn.channels) {
1087
+ this._wsRoster.remove(ch, /** @type {string} */ (conn.sessionId)).catch((err) =>
1088
+ this.fastify.log?.warn?.({ err, channel: ch, app: this.name }, 'ws-roster remove failed'),
1089
+ )
1090
+ }
1091
+ }
1002
1092
  }
1003
1093
  }
1004
1094
 
@@ -1169,6 +1259,21 @@ export class MegaApp {
1169
1259
  this._hubBridgeId = null
1170
1260
  MegaShutdown.unregister(`mega-hublink:${this.name}`)
1171
1261
  }
1262
+ // NATS 클러스터 정리(ADR-176) — 구독·heartbeat/sweep 타이머·roster 멤버 해제. boot 의 shutdown hook 과
1263
+ // **양쪽**에서 정리해야 시그널을 안 거치는 종료(server.close()/프로그램적 재시작/멀티앱 부분 종료)에서도
1264
+ // 누수가 없다(_hubLink/_wsRoster 와 동일 대칭, H1). stop() 은 _isStarted 가드로 멱등이라 중복 안전.
1265
+ if (this._wsCluster) {
1266
+ await this._wsCluster.stop().catch((err) => this.fastify.log?.warn?.({ err, app: this.name }, 'ws-cluster stop failed'))
1267
+ this._wsCluster = null
1268
+ MegaShutdown.unregister(`mega-ws-cluster:${this.name}`)
1269
+ }
1270
+ // redis roster 정리(ADR-177) — heartbeat 중지 + 로컬 멤버 즉시 제거. _sessionConns 를 비우기 **전에**
1271
+ // 호출해야 stop() 이 localRosterMembers 로 자기 멤버를 redis 에서 뺄 수 있다.
1272
+ if (this._wsRoster) {
1273
+ await this._wsRoster.stop().catch((err) => this.fastify.log?.warn?.({ err, app: this.name }, 'ws-roster stop failed'))
1274
+ this._wsRoster = null
1275
+ MegaShutdown.unregister(`mega-wsroster:${this.name}`)
1276
+ }
1172
1277
  this._wsConns.clear()
1173
1278
  this._userConns.clear()
1174
1279
  this._sessionConns.clear()
@@ -50,10 +50,16 @@ export class MegaCluster {
50
50
  /** @type {(() => Promise<void>) | null} */
51
51
  this._workerFn = null
52
52
 
53
- // M1 — respawn backoff (crash-loop 보호)
54
- this._respawnTooFast = 0 // 빠른 연속 crash 카운터
55
- this._maxRapidRespawn = 5 // 5번 연속 너무 빨리 crash 하면 중단
56
- this._minRespawnIntervalMs = 1000 // 1초 이내 crash 는 "너무 빠름"
53
+ // M1 — respawn backoff (crash-loop 보호). 멀티워커 정확성: 전역 카운터 + 정상종료-리셋은 한 워커의
54
+ // 정상 종료가 다른 워커들의 누적 빠른-crash 카운트를 통째로 지워 crash-loop 를 마스킹한다. 그래서
55
+ // **빠른 crash 타임스탬프의 슬라이딩 윈도우**로 판정한다(윈도우 N회 → 포기). 정상 종료는 카운트에
56
+ // 기여하지 않고, 오래된 빠른-crash 는 윈도우에서 자연 소거된다(안정화되면 자동 리셋).
57
+ this._maxRapidRespawn = 5 // 윈도우 내 5번 빠른 crash 면 중단
58
+ this._minRespawnIntervalMs = 1000 // 이 lifetime 미만 crash 는 "너무 빠름"
59
+ /** @type {number[]} 윈도우 내 빠른-crash 타임스탬프(ms). */
60
+ this._rapidCrashTimes = []
61
+ /** @type {ReturnType<typeof setTimeout>|null} SIGTERM grace 강제-kill 타이머(전원 정상종료 시 취소). */
62
+ this._graceTimer = null
57
63
  }
58
64
 
59
65
  /**
@@ -103,13 +109,15 @@ export class MegaCluster {
103
109
  this._shuttingDown = true
104
110
  console.log(`[mega-cluster] received ${sig}, broadcasting shutdown to ${this._workers.size} workers (grace ${this._gracePeriodMs}ms)`)
105
111
  this._broadcastShutdown()
106
- // grace 초과 시 강제 kill
107
- setTimeout(() => {
112
+ // grace 초과 시 강제 kill. 핸들을 보관해 전원 정상종료(exit 핸들러) 시 취소 — 정상 종료가 이 타이머의
113
+ // exit(1) 경합해 exit code 가 흔들리지 않게 한다(k8s/systemd 종료 판정 노이즈 제거).
114
+ this._graceTimer = setTimeout(() => {
108
115
  for (const w of this._workers) {
109
116
  try { w.kill('SIGKILL') } catch (err) { console.warn('[mega-cluster] SIGKILL failed:', err.message) }
110
117
  }
111
118
  this._proc.exit(1)
112
- }, this._gracePeriodMs).unref()
119
+ }, this._gracePeriodMs)
120
+ this._graceTimer.unref()
113
121
  }
114
122
  this._proc.on('SIGTERM', () => onSignal('SIGTERM'))
115
123
  this._proc.on('SIGINT', () => onSignal('SIGINT'))
@@ -124,33 +132,35 @@ export class MegaCluster {
124
132
  this._workers.delete(worker)
125
133
  if (this._shuttingDown) {
126
134
  if (this._workers.size === 0) {
135
+ if (this._graceTimer) clearTimeout(this._graceTimer) // 전원 정상종료 — 강제-kill 타이머 취소(exit code 경합 제거).
136
+ this._graceTimer = null
127
137
  console.log('[mega-cluster] all workers exited, primary exiting 0')
128
138
  this._proc.exit(0)
129
139
  }
130
140
  return
131
141
  }
132
142
  if (this._respawn) {
133
- // M1 — respawn backoff: 너무 빨리 연속 crash 하면 crash-loop 으로 보고 respawn 중단.
143
+ // M1 — respawn backoff: 빠른 crash 슬라이딩 윈도우로 crash-loop 판정(멀티워커 정확).
134
144
  const now = Date.now()
135
145
  const lastBirth = worker._megaBirthAt ?? now
136
146
  const lifetimeMs = now - lastBirth
147
+ // 윈도우 = maxRapidRespawn 회의 빠른 재시작이 걸릴 시간 + 여유. 이보다 오래된 빠른-crash 는 소거.
148
+ const windowMs = this._minRespawnIntervalMs * this._maxRapidRespawn * 2
149
+ this._rapidCrashTimes = this._rapidCrashTimes.filter((t) => now - t < windowMs)
137
150
  if (lifetimeMs < this._minRespawnIntervalMs) {
138
- this._respawnTooFast += 1
139
- if (this._respawnTooFast >= this._maxRapidRespawn) {
140
- // H-1 — crash-loop 포기 시 그냥 return 하면 이벤트루프가 비어 exit 0(성공)으로
141
- // 보인다 → systemd/k8s 가 "정상 종료" 로 판단해 재시작 함.
142
- // 명시적 exit 1 로 "비정상 종료" 를 외부 supervisor 에 알려 재시작을 유도한다.
151
+ this._rapidCrashTimes.push(now) // 빠른 crash 만 윈도우에 기록(정상 lifetime 은 기여 X).
152
+ if (this._rapidCrashTimes.length >= this._maxRapidRespawn) {
153
+ // H-1 — crash-loop 포기 시 그냥 return 하면 이벤트루프가 비어 exit 0(성공)으로 보인다 →
154
+ // systemd/k8s 가 "정상 종료" 로 오판. 명시적 exit 1 로 비정상 종료를 알려 재시작을 유도한다.
143
155
  console.error(
144
- `[mega-cluster] rapid crash-loop detected (${this._respawnTooFast} restarts within ${this._minRespawnIntervalMs}ms), giving up — exiting 1. Last exit: code=${code}, signal=${signal}.`,
156
+ `[mega-cluster] rapid crash-loop detected (${this._rapidCrashTimes.length} rapid crashes within ${windowMs}ms), giving up — exiting 1. Last exit: code=${code}, signal=${signal}.`,
145
157
  )
146
158
  this._proc.exit(1)
147
159
  return // 실 process.exit 은 즉시 종료하나, 주입된 fake 는 계속 실행되므로 명시적 중단
148
160
  }
149
- } else {
150
- this._respawnTooFast = 0 // 정상 lifetime 이면 카운터 reset
151
161
  }
152
162
  console.warn(
153
- `[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}, lifetime=${lifetimeMs}ms), respawning (rapid-crash-count=${this._respawnTooFast}/${this._maxRapidRespawn})`,
163
+ `[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}, lifetime=${lifetimeMs}ms), respawning (rapid-crashes=${this._rapidCrashTimes.length}/${this._maxRapidRespawn} in ${windowMs}ms)`,
154
164
  )
155
165
  this._forkWorker()
156
166
  } else {