mega-framework 0.1.0 → 0.1.1

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.0",
3
+ "version": "0.1.1",
4
4
  "description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
5
5
  "type": "module",
6
6
  "engines": {
@@ -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,15 @@ 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
+ },
90
+
82
91
  // 정기 작업(ADR-028/118) — `mega scheduler` 프로세스가 등록·실행한다(config.schedules, ADR-123).
83
92
  schedules: [CronCounterSchedule],
84
93
 
@@ -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 {
package/src/core/boot.js CHANGED
@@ -49,6 +49,7 @@ import { MegaShutdown } from '../lib/mega-shutdown.js'
49
49
  import { buildLogger } from '../lib/mega-logger.js'
50
50
  import { buildFromGlobalConfig, connectAll, get as getAdapter, entries as adapterEntries } from '../adapters/adapter-manager.js'
51
51
  import { buildWorkers, startAll as startWorkers, contextProxy as workersContext } from './workers-manager.js'
52
+ import { MegaWsCluster } from './ws-cluster.js'
52
53
  import * as MegaMetrics from '../lib/mega-metrics.js'
53
54
  import * as MegaTracing from '../lib/mega-tracing.js'
54
55
  import { MegaWsHub } from '../cli/ws-hub.js'
@@ -293,6 +294,35 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
293
294
  megaApps.push(app)
294
295
  }
295
296
 
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)을 그대로 전달.
301
+ 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).
306
+ const cluster = new MegaWsCluster({
307
+ bus: /** @type {any} */ (busAdapter),
308
+ appName: app.name,
309
+ deliverBroadcast: (/** @type {any} */ p) => a._deliverBroadcast(p),
310
+ deliverDirect: (/** @type {any} */ p) => a._deliverDirect(p),
311
+ subjectPrefix: wsClusterCfg.subjectPrefix,
312
+ roster: wsClusterCfg.roster,
313
+ logger: /** @type {any} */ (app.fastify.log),
314
+ })
315
+ await cluster.start()
316
+ app.setWsCluster(cluster)
317
+ // MegaShutdown LIFO — 어댑터 disconnect 보다 먼저 정리(graceful LEAVE 가 NATS 끊기기 전에 나가게).
318
+ MegaShutdown.register(`mega-ws-cluster:${app.name}`, async () => cluster.stop())
319
+ }
320
+ logger?.debug?.(
321
+ { bus: wsClusterCfg.bus, apps: megaApps.length, roster: wsClusterCfg.roster?.driver ?? 'none' },
322
+ 'boot.wsCluster wired (ADR-176)',
323
+ )
324
+ }
325
+
296
326
  // 9단계: HTTP listen(분기 — CLI/테스트가 listen=false 로 mount 까지만 받을 수 있음).
297
327
  if (listen) {
298
328
  await server.listen({ port: resolvedPort, host: resolvedHost })
@@ -75,6 +75,12 @@ export function validateGlobalConfig(globalConfig) {
75
75
  })
76
76
  }
77
77
 
78
+ // 4b) wsCluster 검증 (ADR-176) — NATS 기반 WS 클러스터 broadcast/roster 자동배선.
79
+ // `bus` 가 있으면 boot 가 자동 배선하므로, 가리키는 글로벌 버스 키가 실제 NATS 인지 부팅 시 확정한다.
80
+ if (globalConfig.wsCluster !== undefined) {
81
+ validateWsClusterConfig(globalConfig.wsCluster, globalConfig.services?.buses)
82
+ }
83
+
78
84
  // 5) jobs / schedules / workers 명시 등록 배열 검증 (M-2 + ADR-124 / 04-data-models §1.1).
79
85
  // GLOBAL_ONLY_KEYS 에 키만 등록돼 있고 shape 검증이 없어, `jobs: SendEmailJob`(배열 잊음)이나
80
86
  // 원소가 클래스 아닌 값이면 흡수 로직이 silent drop 했다. 부팅 시 fail-fast 로 드러낸다.
@@ -119,6 +125,68 @@ function validateSessionSecret(secret) {
119
125
  }
120
126
  }
121
127
 
128
+ /** wsCluster.roster.driver 허용값. */
129
+ const WS_CLUSTER_ROSTER_DRIVERS = Object.freeze(['nats', 'none'])
130
+
131
+ /**
132
+ * `wsCluster` config 검증 (ADR-176). 자동배선의 트리거라 잘못된 설정을 부팅 시 fail-fast 한다.
133
+ * (1) `bus`(string) 필수 — 가리키는 글로벌 버스 키가 `services.buses` 에 있고 driver:'nats' 여야 한다.
134
+ * (2) `roster.driver` 가 있으면 'nats'|'none' 중 하나.
135
+ * (3) `roster.ttlMs` 가 있으면 양의 정수.
136
+ *
137
+ * @param {any} wsCluster - globalConfig.wsCluster.
138
+ * @param {Record<string, any>|undefined} buses - globalConfig.services.buses.
139
+ * @throws {MegaConfigError} 위 위반 시.
140
+ */
141
+ function validateWsClusterConfig(wsCluster, buses) {
142
+ if (typeof wsCluster !== 'object' || wsCluster === null || Array.isArray(wsCluster)) {
143
+ throw new MegaConfigError('config.wsCluster_invalid', 'wsCluster must be an object ({ bus, roster? }).', {
144
+ details: { type: Array.isArray(wsCluster) ? 'array' : typeof wsCluster },
145
+ })
146
+ }
147
+ const bus = wsCluster.bus
148
+ if (typeof bus !== 'string' || bus.length === 0) {
149
+ throw new MegaConfigError('config.wsCluster_bus_required', 'wsCluster.bus (a global buses key) is required.', {
150
+ details: { bus },
151
+ })
152
+ }
153
+ const busDef = buses?.[bus]
154
+ if (!busDef) {
155
+ throw new MegaConfigError(
156
+ 'config.wsCluster_bus_not_found',
157
+ `wsCluster.bus '${bus}' is not defined in services.buses. Declared: [${Object.keys(buses ?? {}).join(', ') || '(none)'}].`,
158
+ { details: { bus, declared: Object.keys(buses ?? {}) } },
159
+ )
160
+ }
161
+ if (busDef.driver !== 'nats') {
162
+ throw new MegaConfigError(
163
+ 'config.wsCluster_bus_not_nats',
164
+ `wsCluster.bus '${bus}' must be a NATS bus (driver:'nats'), got driver:'${busDef.driver}'. Cluster fan-out uses NATS pub/sub (ADR-176).`,
165
+ { details: { bus, driver: busDef.driver } },
166
+ )
167
+ }
168
+ const roster = wsCluster.roster
169
+ if (roster !== undefined) {
170
+ if (typeof roster !== 'object' || roster === null || Array.isArray(roster)) {
171
+ throw new MegaConfigError('config.wsCluster_roster_invalid', 'wsCluster.roster must be an object ({ driver, ttlMs? }).', {
172
+ details: { type: Array.isArray(roster) ? 'array' : typeof roster },
173
+ })
174
+ }
175
+ if (roster.driver !== undefined && !WS_CLUSTER_ROSTER_DRIVERS.includes(roster.driver)) {
176
+ throw new MegaConfigError(
177
+ 'config.wsCluster_roster_driver_invalid',
178
+ `wsCluster.roster.driver must be one of [${WS_CLUSTER_ROSTER_DRIVERS.join(', ')}], got '${roster.driver}'.`,
179
+ { details: { driver: roster.driver } },
180
+ )
181
+ }
182
+ if (roster.ttlMs !== undefined && (!Number.isInteger(roster.ttlMs) || roster.ttlMs <= 0)) {
183
+ throw new MegaConfigError('config.wsCluster_roster_ttl_invalid', 'wsCluster.roster.ttlMs must be a positive integer (ms).', {
184
+ details: { ttlMs: roster.ttlMs },
185
+ })
186
+ }
187
+ }
188
+ }
189
+
122
190
  /**
123
191
  * `jobs`/`schedules`/`workers` 같은 "클래스(함수) 배열" config 키를 검증한다 (M-2/ADR-124). 미정의면
124
192
  * 통과(선택 키). 정의됐으면 (1) 배열이어야 하고 (2) 모든 원소가 함수(클래스)여야 한다 — 위반 시 부팅 fail-fast.