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 +1 -1
- package/package.json +2 -2
- package/sample/crud/.env +10 -1
- package/sample/crud/apps/main/app.config.js +12 -0
- package/sample/crud/apps/main/channels/chat-channel.js +73 -76
- package/sample/crud/mega.config.js +13 -0
- package/sample/crud/scripts/start-ws-hub.sh +40 -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/sample/crud/yarn.lock +1 -1
- package/sample/simple/package.json +1 -1
- package/src/core/boot.js +42 -0
- package/src/core/config-validator.js +92 -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/.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.
|
|
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=
|
|
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
|
-
*
|
|
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,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) — 라이프사이클 훅이
|
|
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/sample/crud/yarn.lock
CHANGED
|
@@ -1234,7 +1234,7 @@ marked@^18.0.5:
|
|
|
1234
1234
|
integrity sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==
|
|
1235
1235
|
|
|
1236
1236
|
"mega-framework@file:../..":
|
|
1237
|
-
version "0.1.
|
|
1237
|
+
version "0.1.1"
|
|
1238
1238
|
dependencies:
|
|
1239
1239
|
"@fastify/cookie" "^11.0.2"
|
|
1240
1240
|
"@fastify/cors" "^11.2.0"
|