mega-framework 0.1.1 → 0.1.3

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 CHANGED
@@ -92,7 +92,7 @@ NATS_JOBS_URL=nats://localhost:4222
92
92
  # WS Hub (mega ws-hub CLI — src/cli/ws-hub.js, runWsHubCli)
93
93
  # 코드가 읽는 키는 MEGA_WSHUB_* (언더스코어 없는 단일 service 토큰). TOKENS 만 필수.
94
94
  # 비밀 토큰은 운영에서 교체. hub 는 `mega ws-hub` 실행 시에만 기동(자동 기동 없음).
95
- MEGA_WSHUB_TOKENS=change-me
95
+ MEGA_WSHUB_TOKENS=dev-bridge-token-change-me
96
96
  MEGA_WSHUB_PORT=3100
97
97
  MEGA_WSHUB_HOST=0.0.0.0
98
98
  MEGA_WSHUB_HEARTBEAT_MS=25000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mega-framework",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
5
5
  "type": "module",
6
6
  "engines": {
@@ -118,4 +118,4 @@
118
118
  "redlock": "5.0.0-beta.2",
119
119
  "ws": "^8.21.0"
120
120
  }
121
- }
121
+ }
package/sample/crud/.env CHANGED
@@ -1,4 +1,4 @@
1
- PORT=4102
1
+ PORT=3000
2
2
  DATABASE_URL=postgres://mega:dkTkqkfl12@localhost:5432/mega_test
3
3
  MEGA_CLUSTER_WORKERS=8
4
4
  SESSION_SECRET=Zz4VoSzf0sYMEoqASu8G_wx5l3uKi2MlHsxDK3MSkoE
@@ -9,6 +9,13 @@ REDIS_LOCK_URL=redis://:dkTkqkfl12@localhost:6379/3
9
9
  MONGO_URL=mongodb://mega:dkTkqkfl12@localhost:27017/mega_test?authSource=admin
10
10
  NATS_JOBS_URL=nats://localhost:4222
11
11
  ASP_MASTER_SECRET=demo-asp-master-7Qe2mWzR1tYbN8sLpKvX0cAfH4dG6jU
12
+
13
+ MEGA_WSHUB_TOKENS=dev-bridge-token-change-me
14
+ MEGA_WSHUB_TOKEN=dev-bridge-token-change-me
15
+ MEGA_WSHUB_PORT=3100
16
+ MEGA_WSHUB_HOST=0.0.0.0
17
+ MEGA_WSHUB_URL=ws://localhost:3100
18
+
12
19
  MEGA_OTEL_ENABLED=true
13
20
  MEGA_OTEL_SERVICE_NAME=sample-crud
14
21
  MEGA_OTEL_ENDPOINT=http://localhost:4318/v1/traces
@@ -16,3 +23,5 @@ MEGA_OTEL_EXPORTER=otlp
16
23
  MEGA_OTEL_SAMPLING_RATIO=1.0
17
24
  MEGA_OTEL_ZIPKIN_API=http://localhost:9411/api/v2
18
25
  DEMO_UPLOAD_DIR=var/uploads
26
+
27
+ BRIDGE_ID=crud-1
@@ -72,6 +72,22 @@ export default {
72
72
  },
73
73
  },
74
74
 
75
+ // WS Hub 브릿지(ADR-065/176) — 별도 `mega ws-hub` 서버(localhost:3100, .env MEGA_WSHUB_*)에 연결.
76
+ // boot 가 이 블록을 보고 **app.connectHub 를 자동 호출**한다(ADR-176 자동배선 — connectHub 코드 불요).
77
+ // 채팅(/ws/chat)은 app.broadcast → hub fan-out 으로 클러스터 전파된다. retry 로 허브 재시작·drain(4503)·
78
+ // 네트워크 단절 시 지수 백오프 재연결(ADR-098). ⚠️ global wsCluster(NATS)와 **동시 사용 불가**(부팅 fail-fast).
79
+ bridgeHub: {
80
+ url: process.env.MEGA_WSHUB_URL ?? 'ws://localhost:3100',
81
+ token: process.env.MEGA_WSHUB_TOKEN,
82
+ bridgeId: process.env.BRIDGE_ID ?? 'main-1',
83
+ channels: ['chat'],
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 },
89
+ },
90
+
75
91
  // rate limit 상향(ADR-073) — /demo/worker 데모는 1초 간격 하트비트 ping(메인 스레드 non-block 시연)으로
76
92
  // 폴링한다. 기본 한도(100/분)는 폴링 + 일반 탐색이 겹치면 쉽게 넘어 데모가 429 로 끊긴다. 데모 앱이라
77
93
  // 폴링을 허용하도록 한도를 넉넉히 둔다(여전히 ON — 무제한 아님). 운영 앱은 엔드포인트별로 더 낮게 잡는다.
@@ -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>
@@ -79,14 +79,18 @@ export default {
79
79
  },
80
80
  },
81
81
 
82
- // WS 클러스터 broadcast + roster 자동배선(ADR-176) `bus`(NATS 'jobs' 재사용) 있으면 프레임워크가
83
- // app.broadcast/joinSession**클러스터 fan-out·접속자목록 동기화**를 NATS 자동 처리한다. 채널
84
- // (ChatChannel)은 connectHub·redis pub/sub·roster 코드 없이 ctx.presence 비즈니스 로직만 작성한다.
85
- // roster.driver:'nats' → 접속자 목록도 NATS 로 동기화(crash 정리는 ttlMs heartbeat). 'none' 이면 로컬만.
86
- wsCluster: {
87
- bus: 'jobs',
88
- roster: { driver: 'nats', ttlMs: 15_000 },
89
- },
82
+ // ── 클러스터 전송 선택(ADR-176, 앱당 하나·상호배타) ───────────────────────────────────────────
83
+ // 이 샘플은 현재 **WS Hub**(app.configbridgeHub → `mega ws-hub` 서버, localhost:3100)채팅을
84
+ // 클러스터 전파한다. boot bridgeHub 를 보고 app.connectHub 자동 호출한다(개발자 배선 불요).
85
+ //
86
+ // ⚠️ NATS 로 다시 전환하려면: app.config 의 `bridgeHub` 블록을 제거(또는 주석)하고 아래 wsCluster
87
+ // 되살린다. **둘을 동시에 두면 부팅 시 config.cluster_transport_conflict 로 fail-fast**(이중 전파 방지).
88
+ //
89
+ // // NATS wsCluster (대안 — bridgeHub 와 동시 사용 불가):
90
+ // wsCluster: {
91
+ // bus: 'jobs', // services.buses 의 NATS 키 재사용
92
+ // roster: { driver: 'nats', ttlMs: 15_000 }, // 접속자목록도 NATS 동기화(crash 정리 heartbeat). 'none'=로컬만
93
+ // },
90
94
 
91
95
  // 정기 작업(ADR-028/118) — `mega scheduler` 프로세스가 등록·실행한다(config.schedules, ADR-123).
92
96
  schedules: [CronCounterSchedule],
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ # ─────────────────────────────────────────────────────────────────────────────
3
+ # sample/crud — WS Hub 서버 기동 스크립트 (ADR-032/176)
4
+ #
5
+ # 별도 hub 프로세스(`mega-ws-hub` 바이너리, mega-framework bin)를 localhost:3100 에 띄운다.
6
+ # 앱(`yarn dev` = `mega start`)이 app.config 의 `bridgeHub` 로 이 허브에 **자동 연결**한다(ADR-176).
7
+ #
8
+ # ⚠️ `mega-ws-hub` 는 `mega start` 와 달리 `.env` 를 자동 로드하지 않는다(직접 process.env 만 읽음,
9
+ # src/cli/ws-hub.js). 그래서 Node 20.6+ 의 `--env-file` 로 .env 를 주입한다.
10
+ # 읽는 env(src/cli/ws-hub.js runWsHubCli): MEGA_WSHUB_TOKENS(필수, 콤마구분) /
11
+ # MEGA_WSHUB_PORT(기본 3100) / MEGA_WSHUB_HOST(기본 0.0.0.0) / MEGA_WSHUB_HEARTBEAT_MS.
12
+ #
13
+ # 사용:
14
+ # sample/crud/scripts/start-ws-hub.sh # .env 값으로 기동(localhost:3100)
15
+ # MEGA_WSHUB_PORT=4100 scripts/start-ws-hub.sh # 일부 오버라이드(실 env 가 .env 보다 우선)
16
+ # scripts/start-ws-hub.sh --some-extra-flag # 추가 인자는 mega-ws-hub 로 전달
17
+ # ─────────────────────────────────────────────────────────────────────────────
18
+ set -euo pipefail
19
+
20
+ # sample/crud 루트로 이동(스크립트 위치 기준 — 어디서 호출해도 동작).
21
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
22
+ cd "$ROOT"
23
+
24
+ ENV_FILE=".env"
25
+ BIN="node_modules/mega-framework/bin/mega-ws-hub.js"
26
+
27
+ # 사전 점검 — 누락 시 silent 진행 금지, 이유를 명확히 알린다(P4/P7).
28
+ [ -f "$ENV_FILE" ] || {
29
+ echo "✗ $ROOT/$ENV_FILE 가 없습니다 — MEGA_WSHUB_TOKENS 등 허브 설정이 필요합니다." >&2
30
+ exit 1
31
+ }
32
+ [ -f "$BIN" ] || {
33
+ echo "✗ $BIN 없음 — 먼저 'yarn install --ignore-engines'(또는 npm install) 로 mega-framework 를 설치/동기화하세요." >&2
34
+ exit 1
35
+ }
36
+
37
+ echo "▶ WS Hub 기동 (mega-ws-hub) — host=${MEGA_WSHUB_HOST:-(.env)} port=${MEGA_WSHUB_PORT:-(.env, 기본 3100)}. Ctrl+C 로 종료."
38
+ # --env-file: .env 를 process.env 로 로드(이미 export 된 실 env 가 우선 — --env-file 표준 동작).
39
+ # exec 로 교체 실행 → 시그널(SIGINT/SIGTERM)이 mega-ws-hub 로 그대로 전달되어 graceful 종료(drain 4503).
40
+ exec node --env-file="$ENV_FILE" "$BIN" "$@"
@@ -1234,7 +1234,7 @@ marked@^18.0.5:
1234
1234
  integrity sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==
1235
1235
 
1236
1236
  "mega-framework@file:../..":
1237
- version "0.1.0"
1237
+ version "0.1.2"
1238
1238
  dependencies:
1239
1239
  "@fastify/cookie" "^11.0.2"
1240
1240
  "@fastify/cors" "^11.2.0"
@@ -16,7 +16,7 @@
16
16
  "test": "mega test"
17
17
  },
18
18
  "dependencies": {
19
- "mega-framework": "file:../.."
19
+ "mega-framework": "^0.1.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "concurrently": "^9.0.0",
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'
@@ -294,17 +296,55 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
294
296
  megaApps.push(app)
295
297
  }
296
298
 
297
- // wsCluster 자동배선 (ADR-176) — global `wsCluster.bus`(NATS)있으면 앱마다 MegaWsCluster 만들어
298
- // app.broadcast/directToUser/joinSession **클러스터 fan-out·roster 동기화**를 배선한다. 개발자는
299
- // connectHub 같은 코드를 쓰지 않고 비즈니스 로직만 작성한다(ADR-176 ADR-137 자동배선 거부를 wsCluster
300
- // 한해 번복). bus 글로벌 직접 lookup(이미 connect 됨). roster 설정(driver/ttlMs)을 그대로 전달.
299
+ // 클러스터 전송 자동배선 (ADR-176) — 앱당 **하나**(상호배타, config-validator 충돌 fail-fast). config 선택:
300
+ // - app.config `bridgeHub` **WS Hub**(`app.connectHub`, 평문 12-타입). `mega ws-hub` 서버에 자동 연결.
301
+ // - global `wsCluster.bus`(NATS) **MegaWsCluster**(NATS pub/sub broadcast + roster 동기화).
302
+ // 개발자가 connectHub 같은 코드를 쓰지 않고 config 만으로 동작한다(ADR-176 ADR-137 자동배선
303
+ // 거부를 **명시 config 가 있을 때만** 번복 — 설정이 곧 의도라 "추측 배선" 아님). megaApps[i] 와 apps[i] 동순서.
301
304
  const wsClusterCfg = /** @type {any} */ (global).wsCluster
302
- if (wsClusterCfg?.bus) {
303
- const busAdapter = getAdapter('bus', wsClusterCfg.bus)
304
- for (const app of megaApps) {
305
- const a = /** @type {any} */ (app) // _deliverBroadcast/_deliverDirect 는 framework-internal(@private).
305
+ const wsClusterBus = wsClusterCfg?.bus ? getAdapter('bus', wsClusterCfg.bus) : null
306
+ for (let i = 0; i < megaApps.length; i++) {
307
+ const app = megaApps[i]
308
+ const a = /** @type {any} */ (app) // _deliverBroadcast/_deliverDirect 는 framework-internal(@private).
309
+ const bridgeHub = /** @type {any} */ (apps[i].config).bridgeHub
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}`
319
+ // WS Hub 자동연결(ADR-065/176). connectHub 가 _hubLink 를 세워 app.broadcast/joinSession 의 hub 경로를
320
+ // 활성화한다(shutdown hook 도 connectHub 가 등록). 허브 다운이어도 boot 를 막지 않는다 — 초기 연결
321
+ // 실패는 warn 하고(retry 설정 시 백그라운드 재연결, ADR-098) 앱은 계속 뜬다(로컬 전달은 동작).
322
+ try {
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)')
325
+ } catch (err) {
326
+ logger?.warn?.({ err, app: app.name, url: bridgeHub.url }, 'boot.bridgeHub initial connect failed (retry if configured)')
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
+ }
345
+ } else if (wsClusterBus) {
306
346
  const cluster = new MegaWsCluster({
307
- bus: /** @type {any} */ (busAdapter),
347
+ bus: /** @type {any} */ (wsClusterBus),
308
348
  appName: app.name,
309
349
  deliverBroadcast: (/** @type {any} */ p) => a._deliverBroadcast(p),
310
350
  deliverDirect: (/** @type {any} */ p) => a._deliverDirect(p),
@@ -317,10 +357,9 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
317
357
  // MegaShutdown LIFO — 어댑터 disconnect 보다 먼저 정리(graceful LEAVE 가 NATS 끊기기 전에 나가게).
318
358
  MegaShutdown.register(`mega-ws-cluster:${app.name}`, async () => cluster.stop())
319
359
  }
320
- logger?.debug?.(
321
- { bus: wsClusterCfg.bus, apps: megaApps.length, roster: wsClusterCfg.roster?.driver ?? 'none' },
322
- 'boot.wsCluster wired (ADR-176)',
323
- )
360
+ }
361
+ if (wsClusterBus) {
362
+ logger?.debug?.({ bus: wsClusterCfg.bus, roster: wsClusterCfg.roster?.driver ?? 'none' }, 'boot.wsCluster wired (ADR-176)')
324
363
  }
325
364
 
326
365
  // 9단계: HTTP listen(분기 — CLI/테스트가 listen=false 로 mount 까지만 받을 수 있음).
@@ -265,6 +265,69 @@ export function validateAppConfig(appConfig, expectedFolderName, globalConfig) {
265
265
  )
266
266
  }
267
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
+ // 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
+ }
329
+ }
330
+
268
331
  // 3) Shared-Reference 키 검증 — 참조하는 키가 globalConfig.services 에 존재해야 함
269
332
  for (const refKey of SHARED_REFERENCE_KEYS) {
270
333
  if (!appConfig[refKey]) continue
@@ -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
+ for (const ch of want) {
972
+ for (const m of await this._wsRoster.list(ch)) if (!out.has(m.sessionId)) out.set(m.sessionId, m)
973
+ }
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,13 @@ export class MegaApp {
1169
1259
  this._hubBridgeId = null
1170
1260
  MegaShutdown.unregister(`mega-hublink:${this.name}`)
1171
1261
  }
1262
+ // redis roster 정리(ADR-177) — heartbeat 중지 + 로컬 멤버 즉시 제거. _sessionConns 를 비우기 **전에**
1263
+ // 호출해야 stop() 이 localRosterMembers 로 자기 멤버를 redis 에서 뺄 수 있다.
1264
+ if (this._wsRoster) {
1265
+ await this._wsRoster.stop().catch((err) => this.fastify.log?.warn?.({ err, app: this.name }, 'ws-roster stop failed'))
1266
+ this._wsRoster = null
1267
+ MegaShutdown.unregister(`mega-wsroster:${this.name}`)
1268
+ }
1172
1269
  this._wsConns.clear()
1173
1270
  this._userConns.clear()
1174
1271
  this._sessionConns.clear()
@@ -0,0 +1,163 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaWsRedisRoster — **채널별** 클러스터 접속자 목록(roster)을 redis 로 관리 (ADR-177).
4
+ *
5
+ * roster 는 "전송(broadcast)"이 아니라 **공유 상태(누가 어느 채널에 접속 중인지)** 라, 허브 개수와 무관한
6
+ * **공유 스토어(redis)** 가 단일 source-of-truth 다. 이로써 (1) **멀티 허브**에서도 명단이 정합하고,
7
+ * (2) 신규/재연결 브릿지가 `HGETALL` 한 번으로 **전체 명단을 즉시** 획득하며(허브 presence 스냅샷 불요),
8
+ * (3) TTL+heartbeat 로 crash 워커의 stale 엔트리를 자동 정리한다. broadcast(메시지)는 그대로 허브(또는
9
+ * NATS)가 fan-out 하고, 본 모듈은 **roster(상태)만** 다룬다(관심사 분리).
10
+ *
11
+ * # 키 스키마 — 채널 기준
12
+ * `<prefix>:<channel>` = HASH( sessionId → JSON({ userId, metadata?, expiresAt }) )
13
+ * 예) `ws:roster:chat`. 룸(채널 내 더 잘게 나눔)은 **앱 비즈니스 로직** — 프레임워크는 채널 단위만.
14
+ *
15
+ * # crash 정리(TTL) — HEXPIRE 없이 모든 redis 버전 호환
16
+ * per-field TTL(HEXPIRE)은 redis 7.4+ 라, 대신 **값에 `expiresAt` 을 싣고 읽을 때 lazy 만료**한다.
17
+ * 각 워커가 heartbeat 로 자기 로컬 세션의 `expiresAt` 을 주기 갱신 → 살아있는 세션은 유지, crash 워커의
18
+ * 세션은 갱신이 끊겨 만료된다. `list` 는 만료 엔트리를 결과에서 빼고 best-effort 로 `HDEL` 한다.
19
+ *
20
+ * @module core/ws-roster
21
+ */
22
+
23
+ /** 기본 키 접두. */
24
+ const DEFAULT_KEY_PREFIX = 'ws:roster'
25
+ /** 기본 멤버 TTL(ms) — heartbeat 미갱신 시 stale 로 간주. */
26
+ const DEFAULT_TTL_MS = 30_000
27
+
28
+ /**
29
+ * 채널 roster 멤버(redis 에 저장되는 형태 — sessionId 는 HASH field 라 값엔 안 넣음).
30
+ * @typedef {Object} RosterMember
31
+ * @property {string} userId
32
+ * @property {Object} [metadata]
33
+ */
34
+
35
+ /**
36
+ * redis 기반 채널별 roster.
37
+ *
38
+ * 앱(MegaApp) 1개당 1 인스턴스. boot 가 `bridgeHub.roster.driver==='redis'` 일 때 생성·start·shutdown 한다.
39
+ * redis 핸들은 캐시 어댑터의 `.native`(ioredis) 를 받는다 — pub/sub 가 아니라 HASH 명령(HSET/HGETALL/HDEL)만 쓴다.
40
+ */
41
+ export class MegaWsRedisRoster {
42
+ /**
43
+ * @param {Object} opts
44
+ * @param {import('ioredis').Redis} opts.redis - 캐시 어댑터의 raw ioredis 핸들.
45
+ * @param {() => Array<{ channel: string, sessionId: string, member: RosterMember }>} opts.getLocalMembers -
46
+ * 이 워커의 현재 로컬 멤버 목록(heartbeat 갱신 대상). 보통 mega-app 의 세션 매핑에서 도출.
47
+ * @param {string} [opts.keyPrefix] - 키 접두(기본 'ws:roster').
48
+ * @param {number} [opts.ttlMs] - 멤버 TTL(ms, 기본 30000).
49
+ * @param {{ debug?: Function, warn?: Function, error?: Function }} [opts.logger]
50
+ */
51
+ constructor({ redis, getLocalMembers, keyPrefix, ttlMs, logger } = /** @type {any} */ ({})) {
52
+ if (!redis || typeof redis.hset !== 'function' || typeof redis.hgetall !== 'function') {
53
+ throw new Error('MegaWsRedisRoster: a redis(ioredis) handle (cache adapter .native) is required.')
54
+ }
55
+ this._redis = redis
56
+ this._getLocalMembers = typeof getLocalMembers === 'function' ? getLocalMembers : () => /** @type {Array<{ channel: string, sessionId: string, member: RosterMember }>} */ ([])
57
+ this._prefix = typeof keyPrefix === 'string' && keyPrefix.length > 0 ? keyPrefix : DEFAULT_KEY_PREFIX
58
+ this._ttlMs = Number.isInteger(ttlMs) && /** @type {number} */ (ttlMs) > 0 ? /** @type {number} */ (ttlMs) : DEFAULT_TTL_MS
59
+ this._log = logger
60
+ /** @type {ReturnType<typeof setInterval>|null} */
61
+ this._hbTimer = null
62
+ }
63
+
64
+ /** 채널 → redis 키. @param {string} channel @returns {string} @private */
65
+ _key(channel) {
66
+ return `${this._prefix}:${channel}`
67
+ }
68
+
69
+ /**
70
+ * 채널 roster 에 멤버 추가/갱신(joinSession). 키 자체에도 EXPIRE 를 걸어 버려진 채널이 새지 않게 한다.
71
+ * @param {string} channel @param {string} sessionId @param {RosterMember} member @returns {Promise<void>}
72
+ */
73
+ async add(channel, sessionId, member) {
74
+ if (typeof channel !== 'string' || typeof sessionId !== 'string') return
75
+ const key = this._key(channel)
76
+ const value = JSON.stringify({ userId: member?.userId, ...(member?.metadata ? { metadata: member.metadata } : {}), expiresAt: Date.now() + this._ttlMs })
77
+ await this._redis.hset(key, sessionId, value)
78
+ // 채널 HASH 키 전체 TTL — 모든 멤버가 stale 가 돼도 키가 영구히 남지 않게(멤버 TTL 의 2배 여유).
79
+ await this._redis.pexpire(key, this._ttlMs * 2)
80
+ }
81
+
82
+ /**
83
+ * 채널 roster 에서 멤버 제거(disconnect).
84
+ * @param {string} channel @param {string} sessionId @returns {Promise<void>}
85
+ */
86
+ async remove(channel, sessionId) {
87
+ if (typeof channel !== 'string' || typeof sessionId !== 'string') return
88
+ await this._redis.hdel(this._key(channel), sessionId)
89
+ }
90
+
91
+ /**
92
+ * 채널의 **현재 접속자 목록**(만료 제외). 만료 엔트리는 best-effort 로 정리한다.
93
+ * @param {string} channel @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
94
+ */
95
+ async list(channel) {
96
+ if (typeof channel !== 'string') return []
97
+ /** @type {Record<string, string>} */
98
+ const raw = await this._redis.hgetall(this._key(channel))
99
+ const now = Date.now()
100
+ /** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
101
+ const out = []
102
+ /** @type {string[]} 만료돼 정리할 field. */
103
+ const expired = []
104
+ for (const [sessionId, json] of Object.entries(raw ?? {})) {
105
+ let m
106
+ try {
107
+ m = JSON.parse(json)
108
+ } catch (err) {
109
+ // 손상 엔트리는 결과에서 제외 + 정리(silent 금지 — 사유 로그).
110
+ this._log?.warn?.({ err, channel, sessionId }, 'ws-roster corrupt entry (dropping)')
111
+ expired.push(sessionId)
112
+ continue
113
+ }
114
+ if (typeof m.expiresAt === 'number' && m.expiresAt < now) {
115
+ expired.push(sessionId) // heartbeat 끊긴 crash 세션 — 만료.
116
+ continue
117
+ }
118
+ out.push({ sessionId, userId: m.userId, ...(m.metadata ? { metadata: m.metadata } : {}) })
119
+ }
120
+ if (expired.length > 0) {
121
+ // best-effort 정리(실패해도 다음 list 에서 다시 시도) — 결과엔 이미 제외됨.
122
+ this._redis.hdel(this._key(channel), ...expired).catch((err) => this._log?.warn?.({ err, channel }, 'ws-roster expired cleanup failed'))
123
+ }
124
+ return out
125
+ }
126
+
127
+ /**
128
+ * heartbeat 시작 — 주기적으로 로컬 멤버의 `expiresAt` 을 갱신해 살아있음을 알린다(crash 워커는 갱신이
129
+ * 끊겨 만료). 프로세스 종료를 막지 않게 unref. 멱등(중복 호출 무시).
130
+ * @returns {void}
131
+ */
132
+ startHeartbeat() {
133
+ if (this._hbTimer) return
134
+ const intervalMs = Math.max(1000, Math.floor(this._ttlMs / 2))
135
+ this._hbTimer = setInterval(() => {
136
+ this._refreshLocal().catch((err) => this._log?.warn?.({ err }, 'ws-roster heartbeat refresh failed'))
137
+ }, intervalMs)
138
+ this._hbTimer.unref?.()
139
+ }
140
+
141
+ /**
142
+ * 로컬 멤버 전체를 다시 add(=expiresAt 갱신). @returns {Promise<void>} @private
143
+ */
144
+ async _refreshLocal() {
145
+ const members = this._getLocalMembers()
146
+ for (const { channel, sessionId, member } of members) {
147
+ await this.add(channel, sessionId, member)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * 정리 — heartbeat 중지 + 로컬 멤버를 redis 에서 제거(graceful 종료 시 다른 워커가 즉시 정합).
153
+ * @returns {Promise<void>}
154
+ */
155
+ async stop() {
156
+ if (this._hbTimer) clearInterval(this._hbTimer)
157
+ this._hbTimer = null
158
+ // graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리).
159
+ for (const { channel, sessionId } of this._getLocalMembers()) {
160
+ await this.remove(channel, sessionId).catch(() => {})
161
+ }
162
+ }
163
+ }
@@ -175,14 +175,21 @@ export class MegaWsConnection {
175
175
  * @param {import('./mega-app.js').MegaApp|null|undefined} app
176
176
  * @param {MegaWsConnection} conn
177
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}
178
+ * @returns {{ list: () => Promise<Array<object>>, join: (entry: object) => void, directToUser: (userId: string, message: object) => void, broadcast: (args: object) => void } | null}
179
179
  */
180
180
  function buildWsPresence(app, conn, ns) {
181
181
  if (!app || typeof (/** @type {any} */ (app).joinSession) !== 'function' || typeof ns !== 'string') return null
182
182
  const a = /** @type {any} */ (app)
183
183
  return {
184
- /** 이 채널 ns 의 **클러스터 전역** 접속자 목록(wsCluster roster). 미배선이면 로컬 멤버만. */
185
- list: () => a.roster(ns),
184
+ /**
185
+ * 이 연결이 가입한 채널의 **클러스터 전역** 접속자 목록(async). redis roster(ADR-177) 배선 시 채널 기준
186
+ * (redis HASH, 멀티 허브 정합); 미배선이면 NATS/로컬 ns 기준. 룸별 필터는 앱이 직접(프레임워크는 채널 단위).
187
+ * @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
188
+ */
189
+ list: async () => {
190
+ if (a._wsRoster && conn.channels && conn.channels.size > 0) return a.presenceList([...conn.channels])
191
+ return a.roster(ns)
192
+ },
186
193
  /** 연결을 신원에 매핑 — `{ userId, sessionId, channels?, metadata? }`. roster 등록이 자동으로 따라온다. */
187
194
  join: (entry) => {
188
195
  a.joinSession(conn, entry)
@@ -68,7 +68,14 @@ const LEVEL_NAMES = /** @type {Record<number, string>} */ ({
68
68
 
69
69
  /**
70
70
  * disk-backed retry queue — 전송 실패한 텍스트를 파일(JSONL)에 쌓고, 다음 기회에 재시도한다(전송 실패해도
71
- * 메시지를 잃지 않음, ADR-023). worker thread 내에서만 접근하므로 락 불필요(단일 소비자).
71
+ * 메시지를 잃지 않음, ADR-023).
72
+ *
73
+ * # in-process 직렬화 락 (ADR-023)
74
+ * 단일 스레드라도 `append`(open→write→close)와 `drain`(rename→read→rm)은 각 await 사이에서 **이벤트루프
75
+ * 인터리브**된다. 인터리브되면 append 의 `open` 이 drain 의 `rename` 보다 먼저 일어날 때, write 가 이미
76
+ * rename·rm 된 inode 로 가서 항목이 **유실**된다(rename-claim 의 "유실 0" 이 깨지는 좁은 창). 그래서 큐
77
+ * 연산을 promise 체인 mutex 로 **직렬화**해 인터리브를 원천 차단한다. 큐 연산은 드물어(전송 실패 시
78
+ * append + 주기 drain) 직렬화 비용은 무시 가능. rename-claim 은 cross-process 안전용으로 그대로 둔다.
72
79
  */
73
80
  export class RetryQueue {
74
81
  /**
@@ -77,16 +84,32 @@ export class RetryQueue {
77
84
  constructor(filePath) {
78
85
  /** @type {string} */
79
86
  this._file = filePath
87
+ /** @type {Promise<unknown>} 직렬화 락의 꼬리 — append/drain/clear 를 이 체인에 줄세운다. */
88
+ this._tail = Promise.resolve()
89
+ }
90
+
91
+ /**
92
+ * 큐 연산을 직렬화 — 앞 연산이 끝난 뒤(성패 무관) `fn` 을 실행한다. 한 연산의 실패가 다음 연산을 막지
93
+ * 않도록 락 체인은 에러를 삼키되, 호출자에겐 결과/에러를 그대로 전파한다.
94
+ * @template T @param {() => Promise<T>} fn @returns {Promise<T>} @private
95
+ */
96
+ _serialize(fn) {
97
+ const result = this._tail.then(() => fn())
98
+ // 락 꼬리: 앞 연산의 성패와 무관하게 다음 연산을 진행시킨다(에러는 result 로 호출자에게만 전파).
99
+ this._tail = result.catch(() => {})
100
+ return result
80
101
  }
81
102
 
82
103
  /**
83
- * 실패 메시지를 큐에 append.
104
+ * 실패 메시지를 큐에 append (직렬화됨).
84
105
  * @param {string} text
85
106
  * @returns {Promise<void>}
86
107
  */
87
108
  async append(text) {
88
- await mkdir(dirname(this._file), { recursive: true })
89
- await appendFile(this._file, JSON.stringify({ text, ts: Date.now() }) + '\n', 'utf8')
109
+ return this._serialize(async () => {
110
+ await mkdir(dirname(this._file), { recursive: true })
111
+ await appendFile(this._file, JSON.stringify({ text, ts: Date.now() }) + '\n', 'utf8')
112
+ })
90
113
  }
91
114
 
92
115
  /**
@@ -100,34 +123,38 @@ export class RetryQueue {
100
123
  * @returns {Promise<string[]>}
101
124
  */
102
125
  async drain() {
103
- const claim = `${this._file}.draining`
104
- try {
105
- await rename(this._file, claim)
106
- } catch (e) {
107
- // 큐 파일 없음(아직 실패 없음) 또는 다른 drain 이 이미 가로챔 → 처리할 것 없음.
108
- if (/** @type {NodeJS.ErrnoException} */ (e)?.code === 'ENOENT') return []
109
- throw e
110
- }
111
- const raw = await readFile(claim, 'utf8')
112
- await rm(claim, { force: true })
113
- /** @type {string[]} */
114
- const texts = []
115
- for (const line of raw.split('\n')) {
116
- if (!line.trim()) continue
126
+ return this._serialize(async () => {
127
+ const claim = `${this._file}.draining`
117
128
  try {
118
- const obj = JSON.parse(line)
119
- if (typeof obj.text === 'string') texts.push(obj.text)
120
- } catch {
121
- // 손상된 라인은 건너뛴다 전체를 막지 않기 위함(비치명적, 다음 항목 계속).
122
- continue
129
+ await rename(this._file, claim)
130
+ } catch (e) {
131
+ // 파일 없음(아직 실패 없음) 또는 다른 drain 이 이미 가로챔 → 처리할 것 없음.
132
+ if (/** @type {NodeJS.ErrnoException} */ (e)?.code === 'ENOENT') return []
133
+ throw e
134
+ }
135
+ const raw = await readFile(claim, 'utf8')
136
+ await rm(claim, { force: true })
137
+ /** @type {string[]} */
138
+ const texts = []
139
+ for (const line of raw.split('\n')) {
140
+ if (!line.trim()) continue
141
+ try {
142
+ const obj = JSON.parse(line)
143
+ if (typeof obj.text === 'string') texts.push(obj.text)
144
+ } catch {
145
+ // 손상된 라인은 건너뛴다 — 큐 전체를 막지 않기 위함(비치명적, 다음 항목 계속).
146
+ continue
147
+ }
123
148
  }
124
- }
125
- return texts
149
+ return texts
150
+ })
126
151
  }
127
152
 
128
- /** 큐 파일 제거(테스트·정리용). @returns {Promise<void>} */
153
+ /** 큐 파일 제거(테스트·정리용, 직렬화됨). @returns {Promise<void>} */
129
154
  async clear() {
130
- await rm(this._file, { force: true })
155
+ return this._serialize(async () => {
156
+ await rm(this._file, { force: true })
157
+ })
131
158
  }
132
159
  }
133
160