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 +1 -1
- package/sample/crud/apps/main/channels/chat-channel.js +73 -76
- package/sample/crud/mega.config.js +9 -0
- package/sample/crud/test/apps/main/chat-channel.test.js +66 -61
- package/sample/crud/test/apps/main/ws-chat.integration.test.js +7 -6
- package/src/core/boot.js +30 -0
- package/src/core/config-validator.js +68 -0
- package/src/core/mega-app.js +55 -0
- package/src/core/scope-registry.js +1 -0
- package/src/core/ws-cluster.js +352 -0
- package/src/core/ws-upgrade.js +31 -0
- package/sample/crud/apps/main/channels/chat-bus.js +0 -115
- package/sample/crud/test/apps/main/chat-bus.test.js +0 -101
package/package.json
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
14
|
-
* (
|
|
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
|
-
*
|
|
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
|
-
* 연결 수립 —
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
// 입장
|
|
44
|
+
// 입장 본인: 신원 + 최근기록 + 현재 명단/인원 + 워커 PID(클러스터 확인용).
|
|
69
45
|
sock.send({
|
|
70
46
|
type: 'chat.history',
|
|
71
|
-
payload: { me: { userId, userName }, items, online:
|
|
47
|
+
payload: { me: { userId, userName }, items, online: members.length, members, workerPid: process.pid },
|
|
72
48
|
})
|
|
73
|
-
// 다른 접속자(전 클러스터)
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 없음 → 본인도
|
|
99
|
-
|
|
77
|
+
// exceptSessionIds 없음 → 본인도 클러스터 경로로 echo 받아 렌더(optimistic 렌더 안 함 → 중복 없음).
|
|
78
|
+
ctx.presence.broadcast({ channel: CHANNEL, message: { type: 'chat.msg', payload: entry } })
|
|
100
79
|
}
|
|
101
80
|
|
|
102
81
|
/**
|
|
103
|
-
* 연결 종료 —
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
*
|
|
127
|
-
* @param {any} ctx @param {
|
|
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
|
|
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
|
|
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
|
|
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 핸들(
|
|
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) — 라이프사이클 훅이
|
|
4
|
-
* 올바르게 호출하는지 mock 으로 검증.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
24
|
-
function fakeNative({
|
|
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
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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('
|
|
59
|
-
const { ctx,
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
expect(
|
|
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(['
|
|
72
|
+
expect(hist.payload.members).toEqual(['old', 'kim'])
|
|
76
73
|
expect(hist.payload.workerPid).toBe(process.pid)
|
|
77
74
|
|
|
78
|
-
// 전 클러스터에: 입장 presence(본인 sessionId 제외).
|
|
79
|
-
const
|
|
80
|
-
expect(
|
|
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 없으면
|
|
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).
|
|
91
|
-
expect(
|
|
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 를 기록 적재 + 전 클러스터
|
|
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
|
|
111
|
-
expect(
|
|
112
|
-
expect(
|
|
113
|
-
expect(
|
|
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(
|
|
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('
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
expect(
|
|
131
|
-
|
|
132
|
-
expect(
|
|
133
|
-
expect(
|
|
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 없으면
|
|
137
|
-
const { ctx } = makeCtx()
|
|
143
|
+
test('auth 없으면 전파 없음', async () => {
|
|
144
|
+
const { ctx, presence } = makeCtx()
|
|
138
145
|
ctx.auth = null
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|