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
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* chat-bus.js — redis pub/sub 기반 cluster-wide 채팅 broadcast (ADR-158).
|
|
4
|
-
*
|
|
5
|
-
* `app.broadcast`(로컬 ns fan-out)는 단일 프로세스 안에서만 전달된다 — 클러스터(워커 N개)에선 다른
|
|
6
|
-
* 워커에 붙은 클라에게 닿지 않는다. hub 프로세스 없이 cluster-wide 전파를 하기 위해 redis pub/sub 를 쓴다:
|
|
7
|
-
* - 송신: 메시지를 redis 채널(`ws:chat:bcast`)에 **PUBLISH** 만 한다(로컬 직접 전달 안 함).
|
|
8
|
-
* - 수신: **워커마다** 전용 구독 연결(ioredis `duplicate()`)이 그 채널을 **SUBSCRIBE** 하고, 도착한
|
|
9
|
-
* 메시지를 자기 워커의 로컬 연결들에 전달한다.
|
|
10
|
-
* 결과: PUBLISH 1건 → 모든 워커의 구독자가 받아 각자 로컬 전달 → 전 클러스터 클라가 정확히 1회씩 수신
|
|
11
|
-
* (송신자 본인 워커도 구독자 경로로 전달받아 echo). 구독 연결은 명령 전용 연결과 분리해야 하므로
|
|
12
|
-
* (subscribe 모드 연결은 일반 명령 불가) `demo` 캐시의 raw ioredis 를 `duplicate()` 한다.
|
|
13
|
-
*
|
|
14
|
-
* 접속자 명단(roster)도 cluster-wide 다 — redis HASH(`ws:chat:roster`, field=sessionId→userName)에 두어
|
|
15
|
-
* 어느 워커에서든 같은 명단·인원수를 본다(INCR/DECR 카운터의 비정상종료 드리프트 회피, 명단까지 공유).
|
|
16
|
-
*
|
|
17
|
-
* @module channels/chat-bus
|
|
18
|
-
*/
|
|
19
|
-
import { MegaShutdown } from 'mega-framework'
|
|
20
|
-
|
|
21
|
-
/** cluster-wide 전파 채널(redis pub/sub 은 db 와 무관한 글로벌 네임스페이스). */
|
|
22
|
-
export const BCAST_CHANNEL = 'ws:chat:bcast'
|
|
23
|
-
/** 접속자 명단 HASH 키 — field=sessionId, value=userName. */
|
|
24
|
-
export const ROSTER_KEY = 'ws:chat:roster'
|
|
25
|
-
|
|
26
|
-
/** 이 워커의 로컬 연결 레지스트리. sock → { sessionId, userName }. */
|
|
27
|
-
const localConns = new Map()
|
|
28
|
-
/** 이 워커의 전용 구독 연결(1개, lazy). */
|
|
29
|
-
let subscriber = null
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* 이 워커의 구독자를 1회 보장한다(첫 연결 시). 채널 SUBSCRIBE + 메시지 → 로컬 전달 + graceful 종료 hook.
|
|
33
|
-
*
|
|
34
|
-
* @param {any} app - MegaApp(로깅용).
|
|
35
|
-
* @param {import('ioredis').Redis} redisNative - `demo` 캐시의 raw ioredis(여기서 duplicate).
|
|
36
|
-
* @returns {void}
|
|
37
|
-
*/
|
|
38
|
-
export function ensureSubscriber(app, redisNative) {
|
|
39
|
-
if (subscriber) return
|
|
40
|
-
const log = app?.fastify?.log
|
|
41
|
-
subscriber = redisNative.duplicate()
|
|
42
|
-
subscriber.on('message', (/** @type {string} */ _channel, /** @type {string} */ raw) => {
|
|
43
|
-
let env
|
|
44
|
-
try {
|
|
45
|
-
env = JSON.parse(raw)
|
|
46
|
-
} catch (err) {
|
|
47
|
-
// 손상 페이로드는 전달하지 않는다(사유 명시 + 로그, silent 금지).
|
|
48
|
-
log?.warn?.({ err }, 'chat-bus subscribe payload parse failed')
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
deliverLocal(env)
|
|
52
|
-
})
|
|
53
|
-
subscriber.on('error', (/** @type {Error} */ err) => {
|
|
54
|
-
// 구독 연결 오류는 비치명적(ioredis 가 재연결) — 로그만.
|
|
55
|
-
log?.warn?.({ err }, 'chat-bus subscriber connection error')
|
|
56
|
-
})
|
|
57
|
-
// subscribe 실패는 전파 자체가 죽는 심각한 신호 — 로그 + throw(fail-closed, silent 금지).
|
|
58
|
-
subscriber.subscribe(BCAST_CHANNEL).catch((err) => {
|
|
59
|
-
log?.error?.({ err, channel: BCAST_CHANNEL }, 'chat-bus subscribe failed')
|
|
60
|
-
throw err
|
|
61
|
-
})
|
|
62
|
-
// 워커 graceful 종료 시 구독 연결 정리(이벤트루프 누수 방지).
|
|
63
|
-
MegaShutdown.register('sample-chat-bus', async () => {
|
|
64
|
-
const s = subscriber
|
|
65
|
-
subscriber = null
|
|
66
|
-
localConns.clear()
|
|
67
|
-
if (s) await s.quit().catch(() => {})
|
|
68
|
-
})
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* 구독으로 받은 메시지를 이 워커의 로컬 연결에 전달한다. exceptSessionIds 에 든 세션은 건너뛴다.
|
|
73
|
-
* @param {{ message: { type: string, payload?: object }, exceptSessionIds?: string[] }} env
|
|
74
|
-
* @returns {void}
|
|
75
|
-
*/
|
|
76
|
-
function deliverLocal({ message, exceptSessionIds }) {
|
|
77
|
-
if (!message || typeof message.type !== 'string') return
|
|
78
|
-
const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
|
|
79
|
-
for (const [sock, meta] of localConns) {
|
|
80
|
-
if (!sock.isOpen) continue
|
|
81
|
-
if (except && except.has(meta.sessionId)) continue
|
|
82
|
-
sock.send({ type: message.type, payload: message.payload })
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* 로컬 연결 등록(onConnect). @param {any} sock @param {{ sessionId: string, userName: string }} meta
|
|
88
|
-
* @returns {void}
|
|
89
|
-
*/
|
|
90
|
-
export function registerConn(sock, meta) {
|
|
91
|
-
localConns.set(sock, meta)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** 로컬 연결 해제(onDisconnect). @param {any} sock @returns {void} */
|
|
95
|
-
export function unregisterConn(sock) {
|
|
96
|
-
localConns.delete(sock)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* cluster-wide 전파 — redis 채널에 PUBLISH(모든 워커 구독자가 받아 로컬 전달).
|
|
101
|
-
* @param {import('ioredis').Redis} redisNative
|
|
102
|
-
* @param {{ message: { type: string, payload?: object }, exceptSessionIds?: string[] }} env
|
|
103
|
-
* @returns {Promise<void>}
|
|
104
|
-
*/
|
|
105
|
-
export async function publish(redisNative, env) {
|
|
106
|
-
await redisNative.publish(BCAST_CHANNEL, JSON.stringify(env))
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** 테스트 정리용 — 구독 연결 종료 + 레지스트리 초기화. @returns {Promise<void>} */
|
|
110
|
-
export async function closeChatBus() {
|
|
111
|
-
const s = subscriber
|
|
112
|
-
subscriber = null
|
|
113
|
-
localConns.clear()
|
|
114
|
-
if (s) await s.quit().catch(() => {})
|
|
115
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* chat-bus 단위 테스트(ADR-158) — redis pub/sub cluster broadcast 의 송수신 계약.
|
|
4
|
-
*
|
|
5
|
-
* 인프라 불필요: ioredis 를 fake(duplicate→fake subscriber, publish spy)로 대체해
|
|
6
|
-
* PUBLISH 직렬화·SUBSCRIBE→로컬 전달·exceptSessionIds 제외·손상 페이로드 무시를 검증한다.
|
|
7
|
-
*/
|
|
8
|
-
import { describe, test, expect, vi, afterEach } from 'vitest'
|
|
9
|
-
import {
|
|
10
|
-
ensureSubscriber,
|
|
11
|
-
registerConn,
|
|
12
|
-
unregisterConn,
|
|
13
|
-
publish,
|
|
14
|
-
closeChatBus,
|
|
15
|
-
BCAST_CHANNEL,
|
|
16
|
-
} from '../../../apps/main/channels/chat-bus.js'
|
|
17
|
-
|
|
18
|
-
/** fake ioredis — duplicate 는 메시지 핸들러를 캡처하는 fake 구독자를 돌려준다. */
|
|
19
|
-
function fakeRedis() {
|
|
20
|
-
const handlers = {}
|
|
21
|
-
const sub = {
|
|
22
|
-
on: vi.fn((/** @type {string} */ ev, /** @type {Function} */ cb) => {
|
|
23
|
-
handlers[ev] = cb
|
|
24
|
-
}),
|
|
25
|
-
subscribe: vi.fn(async () => 1),
|
|
26
|
-
quit: vi.fn(async () => 'OK'),
|
|
27
|
-
}
|
|
28
|
-
return {
|
|
29
|
-
duplicate: vi.fn(() => sub),
|
|
30
|
-
publish: vi.fn(async () => 1),
|
|
31
|
-
_sub: sub,
|
|
32
|
-
/** 구독 채널로 메시지 1건 주입. */
|
|
33
|
-
emit: (raw) => handlers.message?.(BCAST_CHANNEL, raw),
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const app = { fastify: { log: { warn: vi.fn(), error: vi.fn() } } }
|
|
38
|
-
const mkSock = () => ({ isOpen: true, send: vi.fn() })
|
|
39
|
-
|
|
40
|
-
afterEach(async () => {
|
|
41
|
-
await closeChatBus()
|
|
42
|
-
vi.clearAllMocks()
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
test('publish 는 채널에 envelope 을 직렬화해 PUBLISH 한다', async () => {
|
|
46
|
-
const redis = fakeRedis()
|
|
47
|
-
const env = { message: { type: 'chat.msg', payload: { text: 'hi' } } }
|
|
48
|
-
await publish(/** @type {any} */ (redis), env)
|
|
49
|
-
expect(redis.publish).toHaveBeenCalledWith(BCAST_CHANNEL, JSON.stringify(env))
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
test('ensureSubscriber 는 워커당 1회만 구독자를 만든다', () => {
|
|
53
|
-
const redis = fakeRedis()
|
|
54
|
-
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
55
|
-
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
56
|
-
expect(redis.duplicate).toHaveBeenCalledTimes(1)
|
|
57
|
-
expect(redis._sub.subscribe).toHaveBeenCalledWith(BCAST_CHANNEL)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
test('구독 메시지를 로컬 연결에 전달하고 exceptSessionIds 는 건너뛴다', () => {
|
|
61
|
-
const redis = fakeRedis()
|
|
62
|
-
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
63
|
-
const a = mkSock()
|
|
64
|
-
const b = mkSock()
|
|
65
|
-
registerConn(a, { sessionId: 'sA', userName: 'a' })
|
|
66
|
-
registerConn(b, { sessionId: 'sB', userName: 'b' })
|
|
67
|
-
|
|
68
|
-
redis.emit(JSON.stringify({ message: { type: 'chat.msg', payload: { text: 'yo' } }, exceptSessionIds: ['sA'] }))
|
|
69
|
-
|
|
70
|
-
expect(a.send).not.toHaveBeenCalled() // 제외됨.
|
|
71
|
-
expect(b.send).toHaveBeenCalledWith({ type: 'chat.msg', payload: { text: 'yo' } })
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test('except 없으면 모든 로컬 연결에 전달(본인 echo 포함)', () => {
|
|
75
|
-
const redis = fakeRedis()
|
|
76
|
-
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
77
|
-
const a = mkSock()
|
|
78
|
-
registerConn(a, { sessionId: 'sA', userName: 'a' })
|
|
79
|
-
redis.emit(JSON.stringify({ message: { type: 'chat.msg', payload: { text: 'echo' } } }))
|
|
80
|
-
expect(a.send).toHaveBeenCalledWith({ type: 'chat.msg', payload: { text: 'echo' } })
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
test('unregister 된 연결에는 전달하지 않는다', () => {
|
|
84
|
-
const redis = fakeRedis()
|
|
85
|
-
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
86
|
-
const a = mkSock()
|
|
87
|
-
registerConn(a, { sessionId: 'sA', userName: 'a' })
|
|
88
|
-
unregisterConn(a)
|
|
89
|
-
redis.emit(JSON.stringify({ message: { type: 'chat.msg', payload: {} } }))
|
|
90
|
-
expect(a.send).not.toHaveBeenCalled()
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
test('손상 페이로드는 전달하지 않고 warn 로그', () => {
|
|
94
|
-
const redis = fakeRedis()
|
|
95
|
-
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
96
|
-
const a = mkSock()
|
|
97
|
-
registerConn(a, { sessionId: 'sA', userName: 'a' })
|
|
98
|
-
redis.emit('{not json')
|
|
99
|
-
expect(a.send).not.toHaveBeenCalled()
|
|
100
|
-
expect(app.fastify.log.warn).toHaveBeenCalled()
|
|
101
|
-
})
|