mega-framework 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env 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.0",
3
+ "version": "0.1.2",
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,18 @@ 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
+ },
86
+
75
87
  // rate limit 상향(ADR-073) — /demo/worker 데모는 1초 간격 하트비트 ping(메인 스레드 non-block 시연)으로
76
88
  // 폴링한다. 기본 한도(100/분)는 폴링 + 일반 탐색이 겹치면 쉽게 넘어 데모가 429 로 끊긴다. 데모 앱이라
77
89
  // 폴링을 허용하도록 한도를 넉넉히 둔다(여전히 ON — 무제한 아님). 운영 앱은 엔드포인트별로 더 낮게 잡는다.
@@ -1,28 +1,26 @@
1
1
  // @ts-check
2
2
  /**
3
- * ChatChannel — /ws/chat 실시간 채팅 WS 채널 (ADR-158).
3
+ * ChatChannel — /ws/chat 실시간 채팅 WS 채널 (ADR-158/176).
4
4
  *
5
- * MegaWebSocketController 상속해 라이프사이클 3훅 + `type` 자동 디스패치(ADR-015)를 쓴다. 채널
6
- * 인스턴스는 **연결마다 새로 생성**되므로 공유 상태는 인스턴스가 아니라:
7
- * - **전파(cluster-wide)**: redis pub/sub({@link module:channels/chat-bus}) `publish` 모든 워커에
8
- * fan-out. `app.broadcast`(로컬 ns 전용) 클러스터에서 다른 워커에 닿으므로 쓰지 않는다(hub 불요).
9
- * - **접속자 명단·기록**: redis(`demo` 캐시 `.native`) — roster HASH(field=sessionId→userName, cluster-wide
10
- * 인원수·명단) + 최근기록 캡 리스트(RPUSH/LTRIM).
11
- * 에 둔다.
5
+ * 클러스터 전파(broadcast)와 접속자 목록(roster) 동기화는 **프레임워크가 자동 처리**한다(ADR-176,
6
+ * `wsCluster` = NATS). 채널은 `ctx.presence`/`ctx.app.broadcast` **비즈니스 로직만** 작성하고, redis
7
+ * pub/sub·roster 동기화 코드를 직접 두지 않는다(개발자 배선 불요). 최근 메시지 기록(history)만 redis
8
+ * (`demo` 캐시) KV 둔다 이건 전파/roster 아니라 단순 저장이다.
12
9
  *
13
- * ASP: `/ws/chat` 는 asp.websocket.namespaces 들어 기본 암호화(E: 프레임). 코덱은 프레임워크
14
- * (ws-upgrade.js) 종단하므로 채널은 **평문 envelope** 다룬다 — 암복호는 transport 계층 책임.
10
+ * - 전파: `ctx.presence.broadcast({ message })` 같은 ns 의 전 클러스터 클라가 1회씩 수신(NATS fan-out).
11
+ * - 접속자: `ctx.presence.join({...})`(자동 roster 등록·동기화) + `ctx.presence.list()`(클러스터 전역 명단).
12
+ * - 기록: redis RPUSH/LTRIM(최근 N건 cap), 연결 시 replay.
15
13
  *
16
- * 인증: `before`(makeWsRequireAuth) 로그인 세션만 통과시키므로 `ctx.auth`(userId/sessionId/userName)가 보장된다.
14
+ * ASP: `/ws/chat` asp.websocket.namespaces E: 암호화. 코덱은 프레임워크가 종단(평문 envelope 만 다룸).
15
+ * 인증: before(makeWsRequireAuth) 가 로그인 세션만 통과 → ctx.auth(userId/sessionId/userName) 보장.
17
16
  *
18
17
  * @module channels/chat-channel
19
18
  */
20
19
  import { MegaWebSocketController } from 'mega-framework'
21
- import { ensureSubscriber, registerConn, unregisterConn, publish, ROSTER_KEY } from './chat-bus.js'
22
20
 
23
21
  /** broadcast 논리 채널명(payload 분류용). */
24
22
  const CHANNEL = 'chat'
25
- /** redis 키 — 최근 메시지 캡 리스트(RPUSH + LTRIM). */
23
+ /** redis 키 — 최근 메시지 캡 리스트(RPUSH + LTRIM). 전파가 아니라 기록 KV 용도. */
26
24
  const HISTORY_KEY = 'ws:chat:history'
27
25
  /** 보관·재생할 최근 메시지 개수. */
28
26
  const HISTORY_LIMIT = 30
@@ -31,48 +29,27 @@ const HISTORY_TTL_SEC = 86_400
31
29
 
32
30
  export class ChatChannel extends MegaWebSocketController {
33
31
  /**
34
- * 연결 수립 — 워커 구독자 보장 + 로컬 등록 + roster 갱신(cluster-wide) + 최근기록 재생 + 입장 전파.
32
+ * 연결 수립 — 신원 매핑(자동 roster 등록) + 최근기록 재생 + 입장 전파.
35
33
  * @param {any} sock @param {any} ctx @returns {Promise<void>}
36
34
  */
37
35
  async onConnect(sock, ctx) {
38
36
  const { userId, sessionId, userName } = ctx.auth
39
- const redis = this._redis(ctx)
40
- if (!redis) {
41
- // redis 가 전파 transport — 없으면 채팅 불가. 명시적으로 닫는다(silent 진행 금지).
42
- ctx.log?.error?.({ connId: sock.id }, 'ws.chat: redis(demo) unavailable — closing')
43
- if (sock.isOpen) sock.close(1011, 'chat backend unavailable')
44
- return
45
- }
46
- ensureSubscriber(ctx.app, redis) // 이 워커의 cluster-wide 구독 1회 보장.
47
- registerConn(sock, { sessionId, userName })
37
+ // 신원 매핑 + 클러스터 roster 자동 등록(프레임워크가 NATS 로 동기화). roster/전파 배선 코드 불요(ADR-176).
38
+ ctx.presence.join({ userId, sessionId, channels: [CHANNEL], metadata: { userName } })
48
39
 
49
- let roster = [userName]
50
- let items = []
51
- try {
52
- await redis.hset(ROSTER_KEY, sessionId, userName)
53
- roster = await redis.hvals(ROSTER_KEY)
54
- const raw = await redis.lrange(HISTORY_KEY, -HISTORY_LIMIT, -1)
55
- for (const s of raw) {
56
- try {
57
- items.push(JSON.parse(s))
58
- } catch (err) {
59
- // 손상된 기록 1건은 재생에서 제외(전체 재생을 막지 않음) — 사유 명시 + debug 로그(silent 금지).
60
- ctx.log?.debug?.({ err, connId: sock.id }, 'ws.chat skip corrupt history entry')
61
- }
62
- }
63
- } catch (err) {
64
- // roster/기록 조회 실패는 비치명적 — 빈 명단·기록으로라도 연결은 유지(다음 동작에서 복구).
65
- ctx.log?.warn?.({ err, connId: sock.id }, 'ws.chat onConnect redis read failed (non-fatal)')
66
- }
40
+ // 최근 기록 replay (redis KV — 전파/roster 무관한 단순 저장).
41
+ const items = await this._loadHistory(ctx, sock)
42
+ const members = this._members(ctx) // 클러스터 전역 명단(자동 동기화된 roster).
67
43
 
68
- // 입장 본인에게: 신원 + 최근기록 + 현재 명단/인원 + 워커 PID(클러스터 확인용).
44
+ // 입장 본인: 신원 + 최근기록 + 현재 명단/인원 + 워커 PID(클러스터 확인용).
69
45
  sock.send({
70
46
  type: 'chat.history',
71
- payload: { me: { userId, userName }, items, online: roster.length, members: roster, workerPid: process.pid },
47
+ payload: { me: { userId, userName }, items, online: members.length, members, workerPid: process.pid },
72
48
  })
73
- // 다른 접속자(전 클러스터)에게: 입장 알림 + 갱신된 명단(본인은 제외).
74
- await this._publish(ctx, redis, {
75
- message: { type: 'chat.presence', payload: { event: 'join', userName, online: roster.length, members: roster } },
49
+ // 다른 접속자(전 클러스터): 입장 알림 + 갱신 명단(본인 제외). NATS fan-out 은 프레임워크가 처리.
50
+ ctx.presence.broadcast({
51
+ channel: CHANNEL,
52
+ message: { type: 'chat.presence', payload: { event: 'join', userName, online: members.length, members } },
76
53
  exceptSessionIds: [sessionId],
77
54
  })
78
55
  }
@@ -84,59 +61,79 @@ export class ChatChannel extends MegaWebSocketController {
84
61
  async ['chat.send'](sock, msg, ctx) {
85
62
  const text = String(msg.payload?.text ?? '').trim()
86
63
  if (text.length === 0) return // 스키마가 minLength 1 강제하지만 trim 후 공백뿐이면 무시.
87
- const redis = this._redis(ctx)
88
- if (!redis) return
89
64
  const entry = { userId: ctx.auth.userId, userName: ctx.auth.userName, text, ts: Date.now() }
90
- try {
91
- await redis.rpush(HISTORY_KEY, JSON.stringify(entry))
92
- await redis.ltrim(HISTORY_KEY, -HISTORY_LIMIT, -1)
93
- await redis.expire(HISTORY_KEY, HISTORY_TTL_SEC)
94
- } catch (err) {
95
- // 기록 적재 실패는 비치명적 — 전파는 그대로 진행(다른 사용자는 메시지를 받는다).
96
- ctx.log?.warn?.({ err, connId: sock.id }, 'ws.chat history append failed (non-fatal)')
65
+
66
+ const redis = this._redis(ctx)
67
+ if (redis) {
68
+ try {
69
+ await redis.rpush(HISTORY_KEY, JSON.stringify(entry))
70
+ await redis.ltrim(HISTORY_KEY, -HISTORY_LIMIT, -1)
71
+ await redis.expire(HISTORY_KEY, HISTORY_TTL_SEC)
72
+ } catch (err) {
73
+ // 기록 적재 실패는 비치명적 — 전파는 그대로 진행(다른 사용자는 메시지를 받는다).
74
+ ctx.log?.warn?.({ err, connId: sock.id }, 'ws.chat history append failed (non-fatal)')
75
+ }
97
76
  }
98
- // exceptSessionIds 없음 → 본인도 구독자 경로로 echo 받아 렌더(optimistic 렌더 안 함 → 중복 없음).
99
- await this._publish(ctx, redis, { message: { type: 'chat.msg', payload: entry } })
77
+ // exceptSessionIds 없음 → 본인도 클러스터 경로로 echo 받아 렌더(optimistic 렌더 안 함 → 중복 없음).
78
+ ctx.presence.broadcast({ channel: CHANNEL, message: { type: 'chat.msg', payload: entry } })
100
79
  }
101
80
 
102
81
  /**
103
- * 연결 종료 — 로컬 해제 + roster 제거(cluster-wide) + 퇴장 전파.
82
+ * 연결 종료 — 퇴장 전파(roster 제거는 프레임워크가 disconnect 훅에서 자동 처리).
104
83
  * @param {any} sock @param {any} ctx @returns {Promise<void>}
105
84
  */
106
85
  async onDisconnect(sock, ctx) {
107
- unregisterConn(sock)
108
86
  if (!ctx.auth) return
109
87
  const { sessionId, userName } = ctx.auth
110
- const redis = this._redis(ctx)
111
- if (!redis) return
112
- let roster = []
113
- try {
114
- await redis.hdel(ROSTER_KEY, sessionId)
115
- roster = await redis.hvals(ROSTER_KEY)
116
- } catch (err) {
117
- ctx.log?.warn?.({ err, connId: sock.id }, 'ws.chat onDisconnect redis failed (non-fatal)')
118
- }
119
- await this._publish(ctx, redis, {
120
- message: { type: 'chat.presence', payload: { event: 'leave', userName, online: roster.length, members: roster } },
88
+ // 본인을 제외한 명단(자동 roster 제거 타이밍과 무관하게 sessionId 로 명시 제외).
89
+ const members = this._members(ctx, sessionId)
90
+ ctx.presence.broadcast({
91
+ channel: CHANNEL,
92
+ message: { type: 'chat.presence', payload: { event: 'leave', userName, online: members.length, members } },
121
93
  exceptSessionIds: [sessionId],
122
94
  })
123
95
  }
124
96
 
125
97
  /**
126
- * 전파 1건 실패해도 연결을 막지 않는다(메시지 1건 유실은 비치명적, 로그). silent 금지.
127
- * @param {any} ctx @param {import('ioredis').Redis} redis @param {object} env @returns {Promise<void>}
98
+ * 클러스터 전역 접속자 이름 목록(자동 동기화된 roster). exceptSessionId 주어지면 세션은 뺀다.
99
+ * @param {any} ctx @param {string} [exceptSessionId] @returns {string[]}
100
+ * @private
101
+ */
102
+ _members(ctx, exceptSessionId) {
103
+ return ctx.presence
104
+ .list()
105
+ .filter((/** @type {any} */ m) => exceptSessionId == null || m.sessionId !== exceptSessionId)
106
+ .map((/** @type {any} */ m) => m.metadata?.userName)
107
+ .filter((/** @type {any} */ n) => typeof n === 'string' && n.length > 0)
108
+ }
109
+
110
+ /**
111
+ * 최근 메시지 기록 replay(redis KV). 미배선이면 빈 배열. 손상 1건은 제외(전체 replay 막지 않음).
112
+ * @param {any} ctx @param {any} sock @returns {Promise<object[]>}
128
113
  * @private
129
114
  */
130
- async _publish(ctx, redis, env) {
115
+ async _loadHistory(ctx, sock) {
116
+ const redis = this._redis(ctx)
117
+ if (!redis) return []
118
+ /** @type {object[]} */
119
+ const items = []
131
120
  try {
132
- await publish(redis, env)
121
+ const raw = await redis.lrange(HISTORY_KEY, -HISTORY_LIMIT, -1)
122
+ for (const s of raw) {
123
+ try {
124
+ items.push(JSON.parse(s))
125
+ } catch (err) {
126
+ ctx.log?.debug?.({ err, connId: sock.id }, 'ws.chat skip corrupt history entry')
127
+ }
128
+ }
133
129
  } catch (err) {
134
- ctx.log?.warn?.({ err }, 'ws.chat publish failed (message dropped)')
130
+ ctx.log?.warn?.({ err, connId: sock.id }, 'ws.chat history read failed (non-fatal)')
135
131
  }
132
+ return items
136
133
  }
137
134
 
138
135
  /**
139
- * 'demo' redis 캐시의 raw ioredis 핸들(원자적 연산·pub/sub용). 미배선(단위 테스트 mock app)이면 null.
136
+ * 'demo' redis 캐시의 raw ioredis 핸들(기록 RPUSH/LTRIM 용). 미배선(단위 테스트 mock)이면 null.
140
137
  * @param {any} ctx @returns {any|null} @private
141
138
  */
142
139
  _redis(ctx) {
@@ -79,6 +79,19 @@ export default {
79
79
  },
80
80
  },
81
81
 
82
+ // ── 클러스터 전송 선택(ADR-176, 앱당 하나·상호배타) ───────────────────────────────────────────
83
+ // 이 샘플은 현재 **WS Hub**(app.config 의 bridgeHub → `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
+ // },
94
+
82
95
  // 정기 작업(ADR-028/118) — `mega scheduler` 프로세스가 등록·실행한다(config.schedules, ADR-123).
83
96
  schedules: [CronCounterSchedule],
84
97
 
@@ -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" "$@"
@@ -1,31 +1,16 @@
1
1
  // @ts-check
2
2
  /**
3
- * ChatChannel 단위 테스트(ADR-158) — 라이프사이클 훅이 chat-bus(redis pub/sub) + roster HASH 를
4
- * 올바르게 호출하는지 mock 으로 검증. 인프라 불필요(redis native·chat-bus 모듈은 spy).
5
- *
6
- * 채널은 cluster-wide 전파를 chat-bus.publish 에, 로컬 등록을 register/unregisterConn 에, 접속자 명단을
7
- * redis HASH(hset/hvals/hdel)에 위임한다 — 그 위임 계약을 단언한다(app.broadcast/joinSession 미사용).
3
+ * ChatChannel 단위 테스트(ADR-158/176) — 라이프사이클 훅이 **프레임워크 presence/broadcast API**
4
+ * (ctx.presence.join/list/broadcast)와 history redis(KV)를 올바르게 호출하는지 mock 으로 검증.
5
+ * 인프라 불필요(presence·redis native 는 spy). redis pub/sub·roster HASH 직접 호출은 더 이상 없다 —
6
+ * 클러스터 전파·접속자목록 동기화는 프레임워크(wsCluster, NATS)가 처리한다.
8
7
  */
9
8
  import { describe, test, expect, vi, beforeEach } from 'vitest'
10
-
11
- // chat-bus(redis pub/sub) 모듈은 mock — 채널이 올바른 env 로 호출하는지만 본다.
12
- vi.mock('../../../apps/main/channels/chat-bus.js', () => ({
13
- ensureSubscriber: vi.fn(),
14
- registerConn: vi.fn(),
15
- unregisterConn: vi.fn(),
16
- publish: vi.fn(async () => {}),
17
- ROSTER_KEY: 'ws:chat:roster',
18
- }))
19
-
20
9
  import { ChatChannel } from '../../../apps/main/channels/chat-channel.js'
21
- import * as chatBus from '../../../apps/main/channels/chat-bus.js'
22
10
 
23
- /** native redis spy — roster HASH + 기록 리스트. @param {object} [o] */
24
- function fakeNative({ roster = ['kim'], history = [] } = {}) {
11
+ /** native redis spy — history 리스트(전파/roster 아님, 단순 기록 KV). @param {object} [o] */
12
+ function fakeNative({ history = [] } = {}) {
25
13
  return {
26
- hset: vi.fn(async () => 1),
27
- hvals: vi.fn(async () => roster),
28
- hdel: vi.fn(async () => 1),
29
14
  rpush: vi.fn(async () => 1),
30
15
  ltrim: vi.fn(async () => 'OK'),
31
16
  expire: vi.fn(async () => 1),
@@ -33,17 +18,27 @@ function fakeNative({ roster = ['kim'], history = [] } = {}) {
33
18
  }
34
19
  }
35
20
 
36
- /** 채널 ctx mock — auth/app/cache/log. @param {object} [o] */
37
- function makeCtx({ roster = ['kim'], history = [], userName = 'kim' } = {}) {
38
- const native = fakeNative({ roster, history })
21
+ /**
22
+ * 채널 ctx mock auth/presence/cache/log. presence.list 클러스터 roster 흉내낸다.
23
+ * @param {object} [o]
24
+ */
25
+ function makeCtx({ members = [{ sessionId: 's1', userId: 'u1', metadata: { userName: 'kim' } }], history = [], userName = 'kim' } = {}) {
26
+ const native = fakeNative({ history })
27
+ const presence = {
28
+ join: vi.fn(),
29
+ list: vi.fn(() => members),
30
+ broadcast: vi.fn(),
31
+ directToUser: vi.fn(),
32
+ }
39
33
  return {
40
34
  ctx: {
41
35
  auth: { userId: 'u1', sessionId: 's1', userName },
42
- app: { fastify: { log: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() } } },
36
+ presence,
43
37
  cache: vi.fn(() => ({ native })),
44
38
  log: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
45
39
  },
46
40
  native,
41
+ presence,
47
42
  }
48
43
  }
49
44
 
@@ -55,40 +50,46 @@ function makeSock() {
55
50
  beforeEach(() => vi.clearAllMocks())
56
51
 
57
52
  describe('ChatChannel.onConnect', () => {
58
- test('구독자 보장 + 로컬등록 + roster HSET + 기록재생 + 입장 publish(본인 제외) + 워커PID', async () => {
59
- const { ctx, native } = makeCtx({
60
- roster: ['kim', 'old'],
53
+ test('presence.join(자동 roster) + 기록재생 + 입장 broadcast(본인 제외) + chat.history(워커PID)', async () => {
54
+ const { ctx, presence } = makeCtx({
55
+ members: [
56
+ { sessionId: 's0', userId: 'u0', metadata: { userName: 'old' } },
57
+ { sessionId: 's1', userId: 'u1', metadata: { userName: 'kim' } },
58
+ ],
61
59
  history: [JSON.stringify({ userId: 'u0', userName: 'old', text: 'hi', ts: 1 })],
62
60
  })
63
61
  const sock = makeSock()
64
62
  await new ChatChannel().onConnect(sock, ctx)
65
63
 
66
- expect(chatBus.ensureSubscriber).toHaveBeenCalledWith(ctx.app, native)
67
- expect(chatBus.registerConn).toHaveBeenCalledWith(sock, { sessionId: 's1', userName: 'kim' })
68
- expect(native.hset).toHaveBeenCalledWith('ws:chat:roster', 's1', 'kim')
64
+ // 신원 매핑 + 자동 roster 등록(프레임워크에 위임).
65
+ expect(presence.join).toHaveBeenCalledWith({ userId: 'u1', sessionId: 's1', channels: ['chat'], metadata: { userName: 'kim' } })
69
66
 
70
- // 본인에게: chat.history(me + items + 명단 + 워커PID).
67
+ // 본인에게: chat.history(me + items + 클러스터 명단 + 워커PID).
71
68
  const hist = sock.send.mock.calls.find(([m]) => m.type === 'chat.history')[0]
72
69
  expect(hist.payload.me).toEqual({ userId: 'u1', userName: 'kim' })
73
70
  expect(hist.payload.items).toHaveLength(1)
74
71
  expect(hist.payload.online).toBe(2)
75
- expect(hist.payload.members).toEqual(['kim', 'old'])
72
+ expect(hist.payload.members).toEqual(['old', 'kim'])
76
73
  expect(hist.payload.workerPid).toBe(process.pid)
77
74
 
78
- // 전 클러스터에: 입장 presence(본인 sessionId 제외).
79
- const pub = chatBus.publish.mock.calls.at(-1)[1]
80
- expect(pub).toMatchObject({
75
+ // 전 클러스터에: 입장 presence broadcast(본인 sessionId 제외). NATS fan-out 은 프레임워크 처리.
76
+ const env = presence.broadcast.mock.calls.at(-1)[0]
77
+ expect(env).toMatchObject({
78
+ channel: 'chat',
81
79
  message: { type: 'chat.presence', payload: { event: 'join', userName: 'kim', online: 2 } },
82
80
  exceptSessionIds: ['s1'],
83
81
  })
84
82
  })
85
83
 
86
- test('redis 없으면 1011 닫는다(전파 transport 부재)', async () => {
84
+ test('redis(history) 없으면 기록으로 연결 유지(close )', async () => {
85
+ const presence = { join: vi.fn(), list: vi.fn(() => [{ sessionId: 's1', userId: 'u1', metadata: { userName: 'kim' } }]), broadcast: vi.fn() }
86
+ const ctx = { auth: { userId: 'u1', sessionId: 's1', userName: 'kim' }, presence, cache: vi.fn(() => ({ native: null })), log: { warn: vi.fn(), debug: vi.fn() } }
87
87
  const sock = makeSock()
88
- const ctx = { auth: { userId: 'u1', sessionId: 's1', userName: 'kim' }, cache: vi.fn(() => ({ native: null })), log: { error: vi.fn() } }
89
88
  await new ChatChannel().onConnect(sock, ctx)
90
- expect(sock.close).toHaveBeenCalledWith(1011, expect.any(String))
91
- expect(chatBus.registerConn).not.toHaveBeenCalled()
89
+ expect(sock.close).not.toHaveBeenCalled()
90
+ expect(presence.join).toHaveBeenCalled()
91
+ const hist = sock.send.mock.calls.find(([m]) => m.type === 'chat.history')[0]
92
+ expect(hist.payload.items).toEqual([])
92
93
  })
93
94
 
94
95
  test('손상된 기록 1건은 건너뛰고 나머지는 재생(debug 로그)', async () => {
@@ -102,43 +103,47 @@ describe('ChatChannel.onConnect', () => {
102
103
  })
103
104
 
104
105
  describe('ChatChannel.chat.send', () => {
105
- test('검증된 text 를 기록 적재 + 전 클러스터 publish(본인 포함)', async () => {
106
- const { ctx, native } = makeCtx()
106
+ test('검증된 text 를 기록 적재 + 전 클러스터 broadcast(본인 포함)', async () => {
107
+ const { ctx, native, presence } = makeCtx()
107
108
  await new ChatChannel()['chat.send'](makeSock(), { type: 'chat.send', payload: { text: ' hello ' } }, ctx)
108
109
  expect(native.rpush).toHaveBeenCalled()
109
110
  expect(native.ltrim).toHaveBeenCalledWith('ws:chat:history', -30, -1)
110
- const pub = chatBus.publish.mock.calls.at(-1)[1]
111
- expect(pub.message.type).toBe('chat.msg')
112
- expect(pub.message.payload).toMatchObject({ userId: 'u1', userName: 'kim', text: 'hello' })
113
- expect(pub.exceptSessionIds).toBeUndefined() // 본인도 echo.
111
+ const env = presence.broadcast.mock.calls.at(-1)[0]
112
+ expect(env.message.type).toBe('chat.msg')
113
+ expect(env.message.payload).toMatchObject({ userId: 'u1', userName: 'kim', text: 'hello' })
114
+ expect(env.exceptSessionIds).toBeUndefined() // 본인도 echo.
114
115
  })
115
116
 
116
117
  test('공백뿐인 메시지는 무시(전파·적재 없음)', async () => {
117
- const { ctx, native } = makeCtx()
118
+ const { ctx, native, presence } = makeCtx()
118
119
  await new ChatChannel()['chat.send'](makeSock(), { type: 'chat.send', payload: { text: ' ' } }, ctx)
119
- expect(chatBus.publish).not.toHaveBeenCalled()
120
+ expect(presence.broadcast).not.toHaveBeenCalled()
120
121
  expect(native.rpush).not.toHaveBeenCalled()
121
122
  })
122
123
  })
123
124
 
124
125
  describe('ChatChannel.onDisconnect', () => {
125
- test('로컬 해제 + roster HDEL + 퇴장 publish(본인 제외)', async () => {
126
- const { ctx, native } = makeCtx({ roster: [] })
126
+ test('퇴장 broadcast(본인 명단 제외) roster 제거는 프레임워크 자동', async () => {
127
+ // disconnect 시점에 roster 아직 본인을 포함할 수도 있으므로, 채널은 sessionId 로 명시 제외해야 한다.
128
+ const { ctx, presence } = makeCtx({
129
+ members: [
130
+ { sessionId: 's1', userId: 'u1', metadata: { userName: 'kim' } },
131
+ { sessionId: 's2', userId: 'u2', metadata: { userName: 'lee' } },
132
+ ],
133
+ })
127
134
  const sock = makeSock()
128
135
  await new ChatChannel().onDisconnect(sock, ctx)
129
- expect(chatBus.unregisterConn).toHaveBeenCalledWith(sock)
130
- expect(native.hdel).toHaveBeenCalledWith('ws:chat:roster', 's1')
131
- const pub = chatBus.publish.mock.calls.at(-1)[1]
132
- expect(pub.message).toMatchObject({ type: 'chat.presence', payload: { event: 'leave', userName: 'kim' } })
133
- expect(pub.exceptSessionIds).toEqual(['s1'])
136
+ const env = presence.broadcast.mock.calls.at(-1)[0]
137
+ expect(env.message).toMatchObject({ type: 'chat.presence', payload: { event: 'leave', userName: 'kim' } })
138
+ expect(env.message.payload.members).toEqual(['lee']) // 본인(s1) 제외.
139
+ expect(env.message.payload.online).toBe(1)
140
+ expect(env.exceptSessionIds).toEqual(['s1'])
134
141
  })
135
142
 
136
- test('auth 없으면 로컬 해제만(전파 없음)', async () => {
137
- const { ctx } = makeCtx()
143
+ test('auth 없으면 전파 없음', async () => {
144
+ const { ctx, presence } = makeCtx()
138
145
  ctx.auth = null
139
- const sock = makeSock()
140
- await new ChatChannel().onDisconnect(sock, ctx)
141
- expect(chatBus.unregisterConn).toHaveBeenCalledWith(sock)
142
- expect(chatBus.publish).not.toHaveBeenCalled()
146
+ await new ChatChannel().onDisconnect(makeSock(), ctx)
147
+ expect(presence.broadcast).not.toHaveBeenCalled()
143
148
  })
144
149
  })
@@ -18,7 +18,6 @@ import { MegaAspCrypto } from 'mega-framework/lib'
18
18
  import { fileURLToPath } from 'node:url'
19
19
  import { dirname, resolve } from 'node:path'
20
20
  import { User } from '../../../apps/main/models/user.js'
21
- import { closeChatBus, ROSTER_KEY } from '../../../apps/main/channels/chat-bus.js'
22
21
 
23
22
  const { wsEncrypt, wsDecrypt } = MegaAspCrypto
24
23
  const PROJECT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
@@ -34,6 +33,7 @@ const hasInfra = Boolean(
34
33
  process.env.REDIS_RATE_URL &&
35
34
  process.env.REDIS_DEMO_URL &&
36
35
  process.env.MONGO_URL &&
36
+ process.env.NATS_JOBS_URL && // wsCluster(broadcast/roster) 가 NATS 'jobs' 버스를 쓴다(ADR-176).
37
37
  process.env.SESSION_SECRET &&
38
38
  SECRET,
39
39
  )
@@ -155,21 +155,22 @@ d('WS 채팅 + ASP E2E — sample/crud 실 인프라 (ADR-158)', () => {
155
155
  )
156
156
  await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT')
157
157
  await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ')
158
- await boot.ctx.cache('demo').del(ROSTER_KEY) // 결정적 접속자 명단.
158
+ // roster 는 이제 프레임워크(wsCluster, NATS) 인메모리로 관리한다 새 부팅이라 빈 명단에서 시작(별도 정리 불필요).
159
159
  jarA = await registerUser(fastify, emailA)
160
160
  jarB = await registerUser(fastify, emailB)
161
161
  })
162
162
 
163
163
  afterAll(async () => {
164
164
  if (!boot) return
165
- await closeChatBus() // redis pub/sub 구독 연결 정리(이벤트루프 누수 방지).
165
+ const app = boot.megaApps.find((a) => a.name === 'main')
166
+ // wsCluster(NATS 구독·타이머) 정리 — 이벤트루프 누수 방지(framework-internal stop).
167
+ await /** @type {any} */ (app)?._wsCluster?.stop().catch(() => {})
166
168
  await User.query('DELETE FROM users WHERE email = ANY($1)', [[emailA, emailB]]).catch(() => {})
167
- await boot.ctx.cache('demo').del(ROSTER_KEY).catch(() => {})
168
169
  await boot.server.close().catch(() => {})
169
- const app = boot.megaApps.find((a) => a.name === 'main')
170
170
  await app?.sessionStore?.disconnect().catch(() => {})
171
171
  await boot.ctx.cache('rate').disconnect().catch(() => {})
172
172
  await boot.ctx.cache('demo').disconnect().catch(() => {})
173
+ await boot.ctx.bus('jobs').disconnect().catch(() => {}) // wsCluster 가 쓰던 NATS 버스.
173
174
  await boot.ctx.db('mongo').disconnect().catch(() => {})
174
175
  await boot.ctx.db('primary').disconnect().catch(() => {})
175
176
  MegaShutdown._reset()
@@ -200,7 +201,7 @@ d('WS 채팅 + ASP E2E — sample/crud 실 인프라 (ADR-158)', () => {
200
201
  expect(history.payload.me.userId).toBeTruthy()
201
202
  expect(Array.isArray(history.payload.items)).toBe(true)
202
203
  expect(history.payload.online).toBeGreaterThanOrEqual(1)
203
- // roster(cluster-wide redis HASH) 에 내 이름이 있고, 워커 PID 가 실린다.
204
+ // roster(cluster-wide, 프레임워크 NATS 동기화) 에 내 이름이 있고, 워커 PID 가 실린다.
204
205
  expect(history.payload.members).toContain(history.payload.me.userName)
205
206
  expect(typeof history.payload.workerPid).toBe('number')
206
207
  } finally {
@@ -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.1"
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",