mega-framework 0.1.1 → 0.1.3
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 +16 -0
- package/sample/crud/apps/main/channels/chat-channel.js +15 -13
- package/sample/crud/apps/main/locales/server/en.json +1 -0
- package/sample/crud/apps/main/locales/server/ko.json +1 -0
- package/sample/crud/apps/main/public/js/ws-chat.js +30 -2
- package/sample/crud/apps/main/views/ws/index.ejs +7 -0
- package/sample/crud/mega.config.js +12 -8
- package/sample/crud/scripts/start-ws-hub.sh +40 -0
- package/sample/crud/yarn.lock +1 -1
- package/sample/simple/package.json +1 -1
- package/src/core/boot.js +52 -13
- package/src/core/config-validator.js +63 -0
- package/src/core/mega-app.js +100 -3
- package/src/core/ws-roster.js +163 -0
- package/src/core/ws-upgrade.js +10 -3
- package/src/lib/logger/telegram-core.js +54 -27
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.3",
|
|
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,22 @@ 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
|
+
// 접속자 목록(roster)은 broadcast(허브)와 별개로 **redis** 로 관리한다(ADR-177). 멀티 허브에서도 명단이
|
|
86
|
+
// 정합하고, 신규/재연결 브릿지가 즉시 전체 명단을 받는다(허브 presence 스냅샷 불요). cache='demo'(글로벌
|
|
87
|
+
// redis 캐시, db1). 룸은 앱 비즈니스 로직 — 프레임워크는 **채널별 전체 접속자**만 관리한다(키 ws:roster:<channel>).
|
|
88
|
+
roster: { driver: 'redis', cache: 'demo', ttlMs: 30_000 },
|
|
89
|
+
},
|
|
90
|
+
|
|
75
91
|
// rate limit 상향(ADR-073) — /demo/worker 데모는 1초 간격 하트비트 ping(메인 스레드 non-block 시연)으로
|
|
76
92
|
// 폴링한다. 기본 한도(100/분)는 폴링 + 일반 탐색이 겹치면 쉽게 넘어 데모가 429 로 끊긴다. 데모 앱이라
|
|
77
93
|
// 폴링을 허용하도록 한도를 넉넉히 둔다(여전히 ON — 무제한 아님). 운영 앱은 엔드포인트별로 더 낮게 잡는다.
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* ChatChannel — /ws/chat 실시간 채팅 WS 채널 (ADR-158/176).
|
|
4
4
|
*
|
|
5
|
-
* 클러스터 전파(broadcast)와 접속자 목록(roster)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (`demo` 캐시)에 KV 로 둔다 — 이건 전파/roster 가 아니라 단순 저장이다.
|
|
5
|
+
* 클러스터 전파(broadcast)와 접속자 목록(roster)은 **프레임워크가 자동 처리**한다 — 전파는 **WS Hub**(broadcast
|
|
6
|
+
* fan-out, ADR-176), 접속자 목록은 **redis**(채널별 roster, ADR-177)로 **분리**된다(멀티 허브 정합). 채널은
|
|
7
|
+
* `ctx.presence`/`ctx.app.broadcast` 로 **비즈니스 로직만** 작성하고 전파·roster 동기화 코드를 직접 두지 않는다.
|
|
8
|
+
* 최근 메시지 기록(history)만 redis(`demo` 캐시)에 KV 로 둔다 — 이건 전파/roster 가 아니라 단순 저장이다.
|
|
9
9
|
*
|
|
10
|
-
* - 전파: `ctx.presence.broadcast({ message })` → 같은
|
|
11
|
-
* - 접속자: `ctx.presence.join({...})`(자동 roster
|
|
10
|
+
* - 전파: `ctx.presence.broadcast({ message })` → 같은 채널의 전 클러스터 클라가 1회씩 수신(허브 fan-out).
|
|
11
|
+
* - 접속자: `ctx.presence.join({...})`(자동 roster 등록) + `await ctx.presence.list()`(채널 전역 명단, redis).
|
|
12
12
|
* - 기록: redis RPUSH/LTRIM(최근 N건 cap), 연결 시 replay.
|
|
13
|
+
* - 룸(채널 내 세분화)은 앱 몫 — 프레임워크는 **채널별 전체 접속자**만 관리한다.
|
|
13
14
|
*
|
|
14
15
|
* ASP: `/ws/chat` 는 asp.websocket.namespaces 라 E: 암호화. 코덱은 프레임워크가 종단(평문 envelope 만 다룸).
|
|
15
16
|
* 인증: before(makeWsRequireAuth) 가 로그인 세션만 통과 → ctx.auth(userId/sessionId/userName) 보장.
|
|
@@ -39,7 +40,7 @@ export class ChatChannel extends MegaWebSocketController {
|
|
|
39
40
|
|
|
40
41
|
// 최근 기록 replay (redis KV — 전파/roster 와 무관한 단순 저장).
|
|
41
42
|
const items = await this._loadHistory(ctx, sock)
|
|
42
|
-
const members = this._members(ctx) // 클러스터 전역 명단(
|
|
43
|
+
const members = await this._members(ctx) // 클러스터 전역 명단(redis roster, ADR-177).
|
|
43
44
|
|
|
44
45
|
// 입장 본인: 신원 + 최근기록 + 현재 명단/인원 + 워커 PID(클러스터 확인용).
|
|
45
46
|
sock.send({
|
|
@@ -86,7 +87,7 @@ export class ChatChannel extends MegaWebSocketController {
|
|
|
86
87
|
if (!ctx.auth) return
|
|
87
88
|
const { sessionId, userName } = ctx.auth
|
|
88
89
|
// 본인을 제외한 명단(자동 roster 제거 타이밍과 무관하게 sessionId 로 명시 제외).
|
|
89
|
-
const members = this._members(ctx, sessionId)
|
|
90
|
+
const members = await this._members(ctx, sessionId)
|
|
90
91
|
ctx.presence.broadcast({
|
|
91
92
|
channel: CHANNEL,
|
|
92
93
|
message: { type: 'chat.presence', payload: { event: 'leave', userName, online: members.length, members } },
|
|
@@ -95,13 +96,14 @@ export class ChatChannel extends MegaWebSocketController {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
|
-
* 클러스터 전역 접속자 이름 목록(
|
|
99
|
-
*
|
|
99
|
+
* 클러스터 전역 접속자 이름 목록(프레임워크가 redis 로 동기화한 채널 roster, ADR-177). `ctx.presence.list()`
|
|
100
|
+
* 는 async(redis 조회)다. exceptSessionId 가 주어지면 그 세션은 뺀다.
|
|
101
|
+
* @param {any} ctx @param {string} [exceptSessionId] @returns {Promise<string[]>}
|
|
100
102
|
* @private
|
|
101
103
|
*/
|
|
102
|
-
_members(ctx, exceptSessionId) {
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
async _members(ctx, exceptSessionId) {
|
|
105
|
+
const list = await ctx.presence.list()
|
|
106
|
+
return list
|
|
105
107
|
.filter((/** @type {any} */ m) => exceptSessionId == null || m.sessionId !== exceptSessionId)
|
|
106
108
|
.map((/** @type {any} */ m) => m.metadata?.userName)
|
|
107
109
|
.filter((/** @type {any} */ n) => typeof n === 'string' && n.length > 0)
|
|
@@ -212,6 +212,7 @@
|
|
|
212
212
|
"ws_status_closed": "Disconnected",
|
|
213
213
|
"ws_status_error": "Error",
|
|
214
214
|
"ws_online": "{n} online",
|
|
215
|
+
"ws_members_label": "Online",
|
|
215
216
|
"ws_presence_join": "{user} joined.",
|
|
216
217
|
"ws_presence_leave": "{user} left.",
|
|
217
218
|
"ws_empty": "No messages yet. Be the first to say hi.",
|
|
@@ -212,6 +212,7 @@
|
|
|
212
212
|
"ws_status_closed": "연결 끊김",
|
|
213
213
|
"ws_status_error": "오류",
|
|
214
214
|
"ws_online": "접속자 {n}명",
|
|
215
|
+
"ws_members_label": "접속자",
|
|
215
216
|
"ws_presence_join": "{user} 님이 입장했습니다.",
|
|
216
217
|
"ws_presence_leave": "{user} 님이 퇴장했습니다.",
|
|
217
218
|
"ws_empty": "아직 메시지가 없습니다. 첫 메시지를 보내보세요.",
|
|
@@ -15,6 +15,7 @@ const messagesEl = document.getElementById('chat-messages')
|
|
|
15
15
|
const statusEl = document.getElementById('chat-status')
|
|
16
16
|
const statusDot = document.getElementById('chat-status-dot')
|
|
17
17
|
const onlineEl = document.getElementById('chat-online')
|
|
18
|
+
const membersListEl = document.getElementById('chat-members-list')
|
|
18
19
|
const workerEl = document.getElementById('chat-worker')
|
|
19
20
|
const form = /** @type {HTMLFormElement} */ (document.getElementById('chat-form'))
|
|
20
21
|
const input = /** @type {HTMLInputElement} */ (document.getElementById('chat-input'))
|
|
@@ -49,12 +50,39 @@ function setStatus(state) {
|
|
|
49
50
|
if (canSend) input.focus()
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* 접속자 수 뱃지 + **가시적 명단(칩)** 갱신. roster 는 cluster-wide — 프레임워크가 wsCluster(NATS) 또는
|
|
55
|
+
* WS Hub(bridgeHub) 로 워커 전역 접속자를 동기화한다(ADR-176). 서버가 매 chat.history/chat.presence 에
|
|
56
|
+
* 갱신된 members 를 실어 보내므로, 받을 때마다 명단을 통째로 다시 그린다(서버 authoritative).
|
|
57
|
+
* @param {number} [online] @param {string[]} [members]
|
|
58
|
+
*/
|
|
53
59
|
function setRoster(online, members) {
|
|
54
60
|
if (onlineEl && typeof online === 'number') {
|
|
55
61
|
onlineEl.textContent = fill(d.tOnline, { n: online })
|
|
56
62
|
if (Array.isArray(members)) onlineEl.title = members.join(', ')
|
|
57
63
|
}
|
|
64
|
+
renderMembers(members)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 접속자 명단 칩을 다시 그린다(textContent 만 — XSS 방지). @param {string[]} [members] */
|
|
68
|
+
function renderMembers(members) {
|
|
69
|
+
if (!membersListEl) return
|
|
70
|
+
const list = Array.isArray(members) ? members : null
|
|
71
|
+
if (!list) return
|
|
72
|
+
membersListEl.replaceChildren() // 기존 칩 제거(innerHTML 미사용).
|
|
73
|
+
if (list.length === 0) {
|
|
74
|
+
const empty = document.createElement('span')
|
|
75
|
+
empty.className = 'text-body-secondary'
|
|
76
|
+
empty.textContent = '—'
|
|
77
|
+
membersListEl.append(empty)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
for (const name of list) {
|
|
81
|
+
const chip = document.createElement('span')
|
|
82
|
+
chip.className = 'badge rounded-pill text-bg-light border'
|
|
83
|
+
chip.textContent = String(name ?? '') // ← XSS 방지: 닉네임은 textContent 로만.
|
|
84
|
+
membersListEl.append(chip)
|
|
85
|
+
}
|
|
58
86
|
}
|
|
59
87
|
|
|
60
88
|
/** 이 연결이 붙은 클러스터 워커 PID 표시(cluster broadcast 실증용). */
|
|
@@ -137,7 +165,7 @@ async function start() {
|
|
|
137
165
|
setRoster(payload?.online, payload?.members)
|
|
138
166
|
setWorker(payload?.workerPid)
|
|
139
167
|
})
|
|
140
|
-
// 채팅 메시지(본인 echo 포함) — cluster-wide
|
|
168
|
+
// 채팅 메시지(본인 echo 포함) — cluster-wide(프레임워크가 WS Hub 또는 NATS 로 모든 워커에 fan-out, ADR-176).
|
|
141
169
|
sock.on('chat.msg', (payload) => appendMessage(payload ?? {}))
|
|
142
170
|
// 입퇴장 알림 + 갱신된 명단/수(cluster-wide).
|
|
143
171
|
sock.on('chat.presence', (payload) => {
|
|
@@ -37,6 +37,13 @@
|
|
|
37
37
|
<span id="chat-online" class="badge text-bg-light" data-bs-toggle="tooltip" title=""></span>
|
|
38
38
|
</div>
|
|
39
39
|
|
|
40
|
+
<%# 접속자 명단(cluster-wide) — JS(ws-chat.js)가 chat.history/chat.presence 의 members 로 칩을 채운다.
|
|
41
|
+
XSS 방지: 닉네임은 textContent 로만 삽입. %>
|
|
42
|
+
<div id="chat-members" class="px-3 py-2 d-flex flex-wrap align-items-center gap-1 border-bottom bg-body-tertiary small">
|
|
43
|
+
<span class="text-body-secondary me-1"><%= t('ws_members_label', { defaultValue: '접속자' }) %>:</span>
|
|
44
|
+
<span id="chat-members-list" class="d-flex flex-wrap gap-1"></span>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
40
47
|
<%# 메시지 목록 — JS 가 textContent 로만 채운다(XSS 방지: 사용자 입력을 innerHTML 로 넣지 않음). %>
|
|
41
48
|
<div id="chat-messages" class="list-group list-group-flush overflow-auto" style="height: 22rem" aria-live="polite">
|
|
42
49
|
<div data-empty class="list-group-item text-body-secondary small text-center py-5"><%= t('ws_empty', { defaultValue: '아직 메시지가 없습니다. 첫 메시지를 보내보세요.' }) %></div>
|
|
@@ -79,14 +79,18 @@ export default {
|
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
81
|
|
|
82
|
-
//
|
|
83
|
-
// app.
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
wsCluster
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
+
// },
|
|
90
94
|
|
|
91
95
|
// 정기 작업(ADR-028/118) — `mega scheduler` 프로세스가 등록·실행한다(config.schedules, ADR-123).
|
|
92
96
|
schedules: [CronCounterSchedule],
|
|
@@ -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" "$@"
|
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.2"
|
|
1238
1238
|
dependencies:
|
|
1239
1239
|
"@fastify/cookie" "^11.0.2"
|
|
1240
1240
|
"@fastify/cors" "^11.2.0"
|
package/src/core/boot.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* @module core/boot
|
|
35
35
|
*/
|
|
36
36
|
import { join } from 'node:path'
|
|
37
|
+
import nodeCluster from 'node:cluster' // 워커 식별자(cluster.worker.id)로 bridgeId 유일화(ADR-176, L-3).
|
|
37
38
|
// 빌트인 어댑터(postgres/mongodb/mariadb/sqlite/redis/file/nats/redlock)는 import 시 레지스트리에
|
|
38
39
|
// 자기등록한다(ADR-044). CLI 런타임 부팅(`mega start`/`worker`/`scheduler`/`migrate`)은 사용자 코드가
|
|
39
40
|
// 'mega-framework' 를 import 하기 전에 `buildFromGlobalConfig` 로 driver 를 resolve 하므로, 이 배럴을
|
|
@@ -50,6 +51,7 @@ import { buildLogger } from '../lib/mega-logger.js'
|
|
|
50
51
|
import { buildFromGlobalConfig, connectAll, get as getAdapter, entries as adapterEntries } from '../adapters/adapter-manager.js'
|
|
51
52
|
import { buildWorkers, startAll as startWorkers, contextProxy as workersContext } from './workers-manager.js'
|
|
52
53
|
import { MegaWsCluster } from './ws-cluster.js'
|
|
54
|
+
import { MegaWsRedisRoster } from './ws-roster.js'
|
|
53
55
|
import * as MegaMetrics from '../lib/mega-metrics.js'
|
|
54
56
|
import * as MegaTracing from '../lib/mega-tracing.js'
|
|
55
57
|
import { MegaWsHub } from '../cli/ws-hub.js'
|
|
@@ -294,17 +296,55 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
|
|
|
294
296
|
megaApps.push(app)
|
|
295
297
|
}
|
|
296
298
|
|
|
297
|
-
//
|
|
298
|
-
// app.
|
|
299
|
-
//
|
|
300
|
-
//
|
|
299
|
+
// 클러스터 전송 자동배선 (ADR-176) — 앱당 **하나**(상호배타, config-validator 가 충돌 fail-fast). config 로 선택:
|
|
300
|
+
// - app.config `bridgeHub` → **WS Hub**(`app.connectHub`, 평문 12-타입). `mega ws-hub` 서버에 자동 연결.
|
|
301
|
+
// - global `wsCluster.bus`(NATS) → **MegaWsCluster**(NATS pub/sub broadcast + roster 동기화).
|
|
302
|
+
// 둘 다 개발자가 connectHub 같은 코드를 쓰지 않고 config 만으로 동작한다(ADR-176 이 ADR-137 의 자동배선
|
|
303
|
+
// 거부를 **명시 config 가 있을 때만** 번복 — 설정이 곧 의도라 "추측 배선" 아님). megaApps[i] 와 apps[i] 동순서.
|
|
301
304
|
const wsClusterCfg = /** @type {any} */ (global).wsCluster
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
const wsClusterBus = wsClusterCfg?.bus ? getAdapter('bus', wsClusterCfg.bus) : null
|
|
306
|
+
for (let i = 0; i < megaApps.length; i++) {
|
|
307
|
+
const app = megaApps[i]
|
|
308
|
+
const a = /** @type {any} */ (app) // _deliverBroadcast/_deliverDirect 는 framework-internal(@private).
|
|
309
|
+
const bridgeHub = /** @type {any} */ (apps[i].config).bridgeHub
|
|
310
|
+
if (bridgeHub?.url) {
|
|
311
|
+
// ⚠️ 클러스터 워커마다 별개 브릿지라 bridgeId 가 **워커별로 유일**해야 한다(허브 sessionId global-unique
|
|
312
|
+
// 계약). 모든 워커가 같은 bridgeId 면 bridge-subscriber sessionId(`bridge:<id>#<ch>`, mega-app
|
|
313
|
+
// _resyncPresence)가 충돌해 허브가 계속 재할당(thrashing)한다(L-3). 설정 bridgeId 를 베이스로 워커
|
|
314
|
+
// 식별자(cluster.worker.id, 단일 프로세스면 pid)를 붙여 유일화한다. instanceId 도 동일하게 유일화
|
|
315
|
+
// (hub-link 는 instanceId 미지정 시 bridgeId 로 폴백하므로).
|
|
316
|
+
const baseId = bridgeHub.bridgeId ?? app.name
|
|
317
|
+
const workerTag = nodeCluster.worker?.id ?? process.pid
|
|
318
|
+
const uniqueBridgeId = `${baseId}-w${workerTag}`
|
|
319
|
+
// WS Hub 자동연결(ADR-065/176). connectHub 가 _hubLink 를 세워 app.broadcast/joinSession 의 hub 경로를
|
|
320
|
+
// 활성화한다(shutdown hook 도 connectHub 가 등록). 허브 다운이어도 boot 를 막지 않는다 — 초기 연결
|
|
321
|
+
// 실패는 warn 하고(retry 설정 시 백그라운드 재연결, ADR-098) 앱은 계속 뜬다(로컬 전달은 동작).
|
|
322
|
+
try {
|
|
323
|
+
await app.connectHub({ ...bridgeHub, bridgeId: uniqueBridgeId, instanceId: bridgeHub.instanceId ?? uniqueBridgeId })
|
|
324
|
+
logger?.debug?.({ app: app.name, url: bridgeHub.url, bridgeId: uniqueBridgeId }, 'boot.bridgeHub connected (ADR-176)')
|
|
325
|
+
} catch (err) {
|
|
326
|
+
logger?.warn?.({ err, app: app.name, url: bridgeHub.url }, 'boot.bridgeHub initial connect failed (retry if configured)')
|
|
327
|
+
}
|
|
328
|
+
// redis roster 자동배선(ADR-177) — `bridgeHub.roster.driver==='redis'` 면 **채널별 접속자 목록**을 redis HASH 로
|
|
329
|
+
// 관리한다(broadcast 와 별개 — 멀티 허브에서도 정합, 신규/재연결 브릿지가 즉시 전체 명단). 캐시 어댑터의 raw
|
|
330
|
+
// ioredis(`.native`)를 쓰고, heartbeat 로 crash 워커 stale 정리. hub 연결 성패와 무관(roster 는 redis 독립).
|
|
331
|
+
const rosterCfg = /** @type {any} */ (bridgeHub).roster
|
|
332
|
+
if (rosterCfg?.driver === 'redis') {
|
|
333
|
+
const cacheAdapter = /** @type {any} */ (getAdapter('cache', rosterCfg.cache))
|
|
334
|
+
const redis = cacheAdapter?.native
|
|
335
|
+
if (!redis || typeof redis.hset !== 'function') {
|
|
336
|
+
logger?.warn?.({ app: app.name, cache: rosterCfg.cache }, 'boot.wsRoster: cache adapter has no native redis — roster disabled')
|
|
337
|
+
} else {
|
|
338
|
+
const roster = new MegaWsRedisRoster({ redis, getLocalMembers: () => a.localRosterMembers(), ttlMs: rosterCfg.ttlMs, keyPrefix: rosterCfg.keyPrefix, logger })
|
|
339
|
+
roster.startHeartbeat()
|
|
340
|
+
a.setWsRoster(roster)
|
|
341
|
+
MegaShutdown.register(`mega-wsroster:${app.name}`, () => roster.stop())
|
|
342
|
+
logger?.debug?.({ app: app.name, cache: rosterCfg.cache }, 'boot.wsRoster connected (ADR-177, redis)')
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
} else if (wsClusterBus) {
|
|
306
346
|
const cluster = new MegaWsCluster({
|
|
307
|
-
bus: /** @type {any} */ (
|
|
347
|
+
bus: /** @type {any} */ (wsClusterBus),
|
|
308
348
|
appName: app.name,
|
|
309
349
|
deliverBroadcast: (/** @type {any} */ p) => a._deliverBroadcast(p),
|
|
310
350
|
deliverDirect: (/** @type {any} */ p) => a._deliverDirect(p),
|
|
@@ -317,10 +357,9 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
|
|
|
317
357
|
// MegaShutdown LIFO — 어댑터 disconnect 보다 먼저 정리(graceful LEAVE 가 NATS 끊기기 전에 나가게).
|
|
318
358
|
MegaShutdown.register(`mega-ws-cluster:${app.name}`, async () => cluster.stop())
|
|
319
359
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
)
|
|
360
|
+
}
|
|
361
|
+
if (wsClusterBus) {
|
|
362
|
+
logger?.debug?.({ bus: wsClusterCfg.bus, roster: wsClusterCfg.roster?.driver ?? 'none' }, 'boot.wsCluster wired (ADR-176)')
|
|
324
363
|
}
|
|
325
364
|
|
|
326
365
|
// 9단계: HTTP listen(분기 — CLI/테스트가 listen=false 로 mount 까지만 받을 수 있음).
|
|
@@ -265,6 +265,69 @@ export function validateAppConfig(appConfig, expectedFolderName, globalConfig) {
|
|
|
265
265
|
)
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
// 2b) bridgeHub(WS Hub 브릿지, ADR-065/176) 검증 + wsCluster 상호배타.
|
|
269
|
+
// 클러스터 전송은 앱당 **하나**다 — bridgeHub(WS Hub) 와 global wsCluster(NATS) 를 동시에 쓰면
|
|
270
|
+
// app.broadcast 가 양쪽으로 나가 이중 전파된다. 부팅 시 fail-fast 로 막는다(boot 가 둘 중 하나만 배선).
|
|
271
|
+
if (appConfig.bridgeHub !== undefined) {
|
|
272
|
+
const bh = appConfig.bridgeHub
|
|
273
|
+
if (typeof bh !== 'object' || bh === null || Array.isArray(bh)) {
|
|
274
|
+
throw new MegaConfigError('config.bridgeHub_invalid', `app '${expectedFolderName}': bridgeHub must be an object ({ url, token, ... }).`, {
|
|
275
|
+
details: { app: expectedFolderName },
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
if (typeof bh.url !== 'string' || bh.url.length === 0) {
|
|
279
|
+
throw new MegaConfigError('config.bridgeHub_url_required', `app '${expectedFolderName}': bridgeHub.url (ws:// hub address) is required.`, {
|
|
280
|
+
details: { app: expectedFolderName },
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
if (globalConfig?.wsCluster?.bus) {
|
|
284
|
+
throw new MegaConfigError(
|
|
285
|
+
'config.cluster_transport_conflict',
|
|
286
|
+
`app '${expectedFolderName}': bridgeHub(WS Hub) 와 global wsCluster(NATS) 를 동시에 쓸 수 없다 — 클러스터 전송은 앱당 하나만 선택하세요(ADR-176).`,
|
|
287
|
+
{ details: { app: expectedFolderName, bridgeHubUrl: bh.url, wsClusterBus: globalConfig.wsCluster.bus } },
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
// bridgeHub.roster(채널별 접속자 목록 백엔드, ADR-177) — broadcast 와 별개. driver='redis' 면 cache 는
|
|
291
|
+
// 글로벌 services.caches 의 **redis** 어댑터 키여야 한다(멀티 허브 정합용 공유 스토어).
|
|
292
|
+
if (bh.roster !== undefined) {
|
|
293
|
+
const r = bh.roster
|
|
294
|
+
if (typeof r !== 'object' || r === null || Array.isArray(r)) {
|
|
295
|
+
throw new MegaConfigError('config.bridgeHub_roster_invalid', `app '${expectedFolderName}': bridgeHub.roster must be an object ({ driver, cache?, ttlMs? }).`, {
|
|
296
|
+
details: { app: expectedFolderName },
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
const VALID_ROSTER_DRIVERS = ['redis', 'none']
|
|
300
|
+
if (r.driver !== undefined && !VALID_ROSTER_DRIVERS.includes(r.driver)) {
|
|
301
|
+
throw new MegaConfigError('config.bridgeHub_roster_driver_invalid', `app '${expectedFolderName}': bridgeHub.roster.driver must be one of ${VALID_ROSTER_DRIVERS.join('|')}.`, {
|
|
302
|
+
details: { app: expectedFolderName, driver: r.driver },
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
if (r.driver === 'redis') {
|
|
306
|
+
const cacheSvc = typeof r.cache === 'string' ? globalConfig?.services?.caches?.[r.cache] : undefined
|
|
307
|
+
if (typeof r.cache !== 'string' || r.cache.length === 0) {
|
|
308
|
+
throw new MegaConfigError('config.bridgeHub_roster_cache_required', `app '${expectedFolderName}': bridgeHub.roster.cache (global redis cache key) is required when driver='redis'.`, {
|
|
309
|
+
details: { app: expectedFolderName },
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
if (!cacheSvc) {
|
|
313
|
+
throw new MegaConfigError('config.bridgeHub_roster_cache_unknown', `app '${expectedFolderName}': bridgeHub.roster.cache '${r.cache}' is not a registered global cache (services.caches).`, {
|
|
314
|
+
details: { app: expectedFolderName, cache: r.cache },
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
if (cacheSvc.driver !== 'redis') {
|
|
318
|
+
throw new MegaConfigError('config.bridgeHub_roster_cache_not_redis', `app '${expectedFolderName}': bridgeHub.roster.cache '${r.cache}' must be a redis cache (driver='redis'), got '${cacheSvc.driver}'.`, {
|
|
319
|
+
details: { app: expectedFolderName, cache: r.cache, driver: cacheSvc.driver },
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (r.ttlMs !== undefined && (!Number.isInteger(r.ttlMs) || r.ttlMs <= 0)) {
|
|
324
|
+
throw new MegaConfigError('config.bridgeHub_roster_ttl_invalid', `app '${expectedFolderName}': bridgeHub.roster.ttlMs must be a positive integer (ms).`, {
|
|
325
|
+
details: { app: expectedFolderName, ttlMs: r.ttlMs },
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
268
331
|
// 3) Shared-Reference 키 검증 — 참조하는 키가 globalConfig.services 에 존재해야 함
|
|
269
332
|
for (const refKey of SHARED_REFERENCE_KEYS) {
|
|
270
333
|
if (!appConfig[refKey]) continue
|
package/src/core/mega-app.js
CHANGED
|
@@ -184,6 +184,9 @@ export class MegaApp {
|
|
|
184
184
|
this._hubLink = null
|
|
185
185
|
/** @type {import('./ws-cluster.js').MegaWsCluster|null} NATS 기반 클러스터 fan-out/roster (ADR-176, boot 자동배선). */
|
|
186
186
|
this._wsCluster = null
|
|
187
|
+
/** @type {import('./ws-roster.js').MegaWsRedisRoster|null} 채널별 redis roster(ADR-177, boot 자동배선).
|
|
188
|
+
* 접속자 목록(상태)을 redis HASH 로 cluster-wide 관리한다(broadcast 와 별개 — 멀티 허브 정합·즉시 스냅샷). */
|
|
189
|
+
this._wsRoster = null
|
|
187
190
|
/** ns(=ws path) → 활성 로컬 연결 집합 (hub broadcast 의 local fan-out 용). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
188
191
|
this._wsConns = new Map()
|
|
189
192
|
/** userId → 활성 로컬 연결 집합 (DIRECT 타겟팅, H-latent guard). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
@@ -636,7 +639,17 @@ export class MegaApp {
|
|
|
636
639
|
// hub → bridge 푸시를 로컬 소켓에 전달.
|
|
637
640
|
link.on(HUB_MESSAGE_TYPES.BROADCAST, (msg) => this._deliverBroadcast(/** @type {any} */ (msg).payload))
|
|
638
641
|
link.on(HUB_MESSAGE_TYPES.DIRECT, (msg) => this._deliverDirect(/** @type {any} */ (msg).payload))
|
|
639
|
-
//
|
|
642
|
+
// 허브가 fan-out 하는 presence(JOIN/LEAVE/METADATA)·heartbeat 는 broadcast 채널 멤버십·keepalive 용으로만
|
|
643
|
+
// 의미 있다. **roster(접속자 목록)는 redis(MegaWsRedisRoster, ADR-177)가 별도 관리**하므로 브릿지는 이들을
|
|
644
|
+
// 소비하지 않는다 — no-op 핸들러로 "no handler for type" 노이즈만 차단한다(JOIN 자체는 joinSession 이
|
|
645
|
+
// broadcast 채널 가입용으로 계속 송신). 멀티 허브에서도 redis 가 single source-of-truth 라 명단이 정합.
|
|
646
|
+
const noopHub = () => {}
|
|
647
|
+
link.on(HUB_MESSAGE_TYPES.JOIN, noopHub)
|
|
648
|
+
link.on(HUB_MESSAGE_TYPES.LEAVE, noopHub)
|
|
649
|
+
link.on(HUB_MESSAGE_TYPES.BULK_LEAVE, noopHub)
|
|
650
|
+
link.on(HUB_MESSAGE_TYPES.METADATA, noopHub)
|
|
651
|
+
link.on(HUB_MESSAGE_TYPES.HEARTBEAT, noopHub)
|
|
652
|
+
// 재연결 성공 시 presence 재동기화(ADR-098) — hub 는 절단 시점 presence 를 잃으므로 broadcast 채널을 다시 JOIN.
|
|
640
653
|
link.on(MegaHubLink.EVENTS.RECONNECTED, () => this._resyncPresence())
|
|
641
654
|
await link.connect()
|
|
642
655
|
|
|
@@ -765,6 +778,15 @@ export class MegaApp {
|
|
|
765
778
|
if (this._wsCluster && typeof conn.ns === 'string') {
|
|
766
779
|
this._wsCluster.rosterAdd({ ns: conn.ns, sessionId, userId, ...(metadata ? { metadata } : {}) })
|
|
767
780
|
}
|
|
781
|
+
// redis roster 동기화 (ADR-177) — **채널별** 접속자 목록을 redis HASH 에 add(멀티 허브 정합). best-effort.
|
|
782
|
+
if (this._wsRoster && chans.length > 0) {
|
|
783
|
+
const member = { userId, ...(metadata ? { metadata } : {}) }
|
|
784
|
+
for (const ch of chans) {
|
|
785
|
+
this._wsRoster.add(ch, sessionId, member).catch((err) =>
|
|
786
|
+
this.fastify.log?.warn?.({ err, channel: ch, app: this.name }, 'ws-roster add failed'),
|
|
787
|
+
)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
768
790
|
return this
|
|
769
791
|
}
|
|
770
792
|
|
|
@@ -898,8 +920,9 @@ export class MegaApp {
|
|
|
898
920
|
* @returns {Array<{ sessionId: string, userId: string, metadata?: Object }>}
|
|
899
921
|
*/
|
|
900
922
|
roster(ns) {
|
|
901
|
-
if (this._wsCluster) return this._wsCluster.roster(ns)
|
|
902
|
-
//
|
|
923
|
+
if (this._wsCluster) return this._wsCluster.roster(ns) // NATS: 이미 cluster-wide(roster 동기화 포함).
|
|
924
|
+
// ns 기준 로컬 명단(동기 API). **채널 기준 redis roster(ADR-177)** 는 `ctx.presence.list()`(async)가
|
|
925
|
+
// 다룬다 — 이 메서드는 NATS/로컬용 ns 기준 동기 API 로 유지(하위호환).
|
|
903
926
|
/** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
|
|
904
927
|
const out = []
|
|
905
928
|
for (const [sessionId, conn] of this._sessionConns) {
|
|
@@ -909,6 +932,65 @@ export class MegaApp {
|
|
|
909
932
|
return out
|
|
910
933
|
}
|
|
911
934
|
|
|
935
|
+
/**
|
|
936
|
+
* 채널별 redis roster(ADR-177)를 이 앱에 배선한다. boot 가 `bridgeHub.roster.driver==='redis'` 일 때
|
|
937
|
+
* 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
|
|
938
|
+
* @param {import('./ws-roster.js').MegaWsRedisRoster|null} roster
|
|
939
|
+
* @returns {this}
|
|
940
|
+
*/
|
|
941
|
+
setWsRoster(roster) {
|
|
942
|
+
this._wsRoster = roster
|
|
943
|
+
return this
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* 주어진 **채널들**의 cluster-wide 접속자 목록 — redis roster(원격 포함) + 로컬 세션을 병합한다(ADR-177).
|
|
948
|
+
* 로컬을 항상 포함해 join 직후 redis HSET 반영 전에도 본인이 빠지지 않게 한다. `ctx.presence.list()` 의
|
|
949
|
+
* redis 경로가 호출(async). roster 미배선이면 로컬 채널 멤버만.
|
|
950
|
+
* @param {string[]} channels
|
|
951
|
+
* @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
|
|
952
|
+
*/
|
|
953
|
+
async presenceList(channels) {
|
|
954
|
+
const want = new Set(Array.isArray(channels) ? channels : [])
|
|
955
|
+
/** @type {Map<string, { sessionId: string, userId: string, metadata?: Object }>} */
|
|
956
|
+
const out = new Map()
|
|
957
|
+
// 로컬 세션(이 워커) — 요청 채널에 가입한 것. 항상 fresh(본인 포함).
|
|
958
|
+
for (const [sessionId, conn] of this._sessionConns) {
|
|
959
|
+
if (!conn.isOpen || !conn.channels) continue
|
|
960
|
+
let inCh = false
|
|
961
|
+
for (const ch of conn.channels) {
|
|
962
|
+
if (want.has(ch)) {
|
|
963
|
+
inCh = true
|
|
964
|
+
break
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (inCh) out.set(sessionId, { sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
|
|
968
|
+
}
|
|
969
|
+
// redis(cluster-wide) — 다른 워커/허브의 세션까지.
|
|
970
|
+
if (this._wsRoster) {
|
|
971
|
+
for (const ch of want) {
|
|
972
|
+
for (const m of await this._wsRoster.list(ch)) if (!out.has(m.sessionId)) out.set(m.sessionId, m)
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return [...out.values()]
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* 이 워커의 로컬 멤버 목록 — redis roster heartbeat 갱신 대상(ADR-177). joinSession 으로 매핑된
|
|
980
|
+
* (채널 × 세션)마다 1개. MegaWsRedisRoster 가 주기적으로 이 목록의 expiresAt 을 갱신한다.
|
|
981
|
+
* @returns {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>}
|
|
982
|
+
*/
|
|
983
|
+
localRosterMembers() {
|
|
984
|
+
/** @type {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>} */
|
|
985
|
+
const out = []
|
|
986
|
+
for (const [sessionId, conn] of this._sessionConns) {
|
|
987
|
+
if (!conn.isOpen || !conn.channels) continue
|
|
988
|
+
const member = { userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) }
|
|
989
|
+
for (const ch of conn.channels) out.push({ channel: ch, sessionId, member })
|
|
990
|
+
}
|
|
991
|
+
return out
|
|
992
|
+
}
|
|
993
|
+
|
|
912
994
|
/**
|
|
913
995
|
* broadcast payload 를 로컬 ns 소켓에 전달한다. message 는 `{ type, payload }` 내부 envelope.
|
|
914
996
|
*
|
|
@@ -999,6 +1081,14 @@ export class MegaApp {
|
|
|
999
1081
|
}
|
|
1000
1082
|
// NATS roster 제거 (ADR-176) — disconnect 시 클러스터 접속자 목록에서 자동 제거(개발자 코드 불요).
|
|
1001
1083
|
this._wsCluster?.rosterRemove(conn.sessionId)
|
|
1084
|
+
// redis roster 제거 (ADR-177) — 이 세션이 가입한 모든 채널에서 제거. best-effort.
|
|
1085
|
+
if (this._wsRoster && conn.channels) {
|
|
1086
|
+
for (const ch of conn.channels) {
|
|
1087
|
+
this._wsRoster.remove(ch, /** @type {string} */ (conn.sessionId)).catch((err) =>
|
|
1088
|
+
this.fastify.log?.warn?.({ err, channel: ch, app: this.name }, 'ws-roster remove failed'),
|
|
1089
|
+
)
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1002
1092
|
}
|
|
1003
1093
|
}
|
|
1004
1094
|
|
|
@@ -1169,6 +1259,13 @@ export class MegaApp {
|
|
|
1169
1259
|
this._hubBridgeId = null
|
|
1170
1260
|
MegaShutdown.unregister(`mega-hublink:${this.name}`)
|
|
1171
1261
|
}
|
|
1262
|
+
// redis roster 정리(ADR-177) — heartbeat 중지 + 로컬 멤버 즉시 제거. _sessionConns 를 비우기 **전에**
|
|
1263
|
+
// 호출해야 stop() 이 localRosterMembers 로 자기 멤버를 redis 에서 뺄 수 있다.
|
|
1264
|
+
if (this._wsRoster) {
|
|
1265
|
+
await this._wsRoster.stop().catch((err) => this.fastify.log?.warn?.({ err, app: this.name }, 'ws-roster stop failed'))
|
|
1266
|
+
this._wsRoster = null
|
|
1267
|
+
MegaShutdown.unregister(`mega-wsroster:${this.name}`)
|
|
1268
|
+
}
|
|
1172
1269
|
this._wsConns.clear()
|
|
1173
1270
|
this._userConns.clear()
|
|
1174
1271
|
this._sessionConns.clear()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaWsRedisRoster — **채널별** 클러스터 접속자 목록(roster)을 redis 로 관리 (ADR-177).
|
|
4
|
+
*
|
|
5
|
+
* roster 는 "전송(broadcast)"이 아니라 **공유 상태(누가 어느 채널에 접속 중인지)** 라, 허브 개수와 무관한
|
|
6
|
+
* **공유 스토어(redis)** 가 단일 source-of-truth 다. 이로써 (1) **멀티 허브**에서도 명단이 정합하고,
|
|
7
|
+
* (2) 신규/재연결 브릿지가 `HGETALL` 한 번으로 **전체 명단을 즉시** 획득하며(허브 presence 스냅샷 불요),
|
|
8
|
+
* (3) TTL+heartbeat 로 crash 워커의 stale 엔트리를 자동 정리한다. broadcast(메시지)는 그대로 허브(또는
|
|
9
|
+
* NATS)가 fan-out 하고, 본 모듈은 **roster(상태)만** 다룬다(관심사 분리).
|
|
10
|
+
*
|
|
11
|
+
* # 키 스키마 — 채널 기준
|
|
12
|
+
* `<prefix>:<channel>` = HASH( sessionId → JSON({ userId, metadata?, expiresAt }) )
|
|
13
|
+
* 예) `ws:roster:chat`. 룸(채널 내 더 잘게 나눔)은 **앱 비즈니스 로직** — 프레임워크는 채널 단위만.
|
|
14
|
+
*
|
|
15
|
+
* # crash 정리(TTL) — HEXPIRE 없이 모든 redis 버전 호환
|
|
16
|
+
* per-field TTL(HEXPIRE)은 redis 7.4+ 라, 대신 **값에 `expiresAt` 을 싣고 읽을 때 lazy 만료**한다.
|
|
17
|
+
* 각 워커가 heartbeat 로 자기 로컬 세션의 `expiresAt` 을 주기 갱신 → 살아있는 세션은 유지, crash 워커의
|
|
18
|
+
* 세션은 갱신이 끊겨 만료된다. `list` 는 만료 엔트리를 결과에서 빼고 best-effort 로 `HDEL` 한다.
|
|
19
|
+
*
|
|
20
|
+
* @module core/ws-roster
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 기본 키 접두. */
|
|
24
|
+
const DEFAULT_KEY_PREFIX = 'ws:roster'
|
|
25
|
+
/** 기본 멤버 TTL(ms) — heartbeat 미갱신 시 stale 로 간주. */
|
|
26
|
+
const DEFAULT_TTL_MS = 30_000
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 채널 roster 멤버(redis 에 저장되는 형태 — sessionId 는 HASH field 라 값엔 안 넣음).
|
|
30
|
+
* @typedef {Object} RosterMember
|
|
31
|
+
* @property {string} userId
|
|
32
|
+
* @property {Object} [metadata]
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* redis 기반 채널별 roster.
|
|
37
|
+
*
|
|
38
|
+
* 앱(MegaApp) 1개당 1 인스턴스. boot 가 `bridgeHub.roster.driver==='redis'` 일 때 생성·start·shutdown 한다.
|
|
39
|
+
* redis 핸들은 캐시 어댑터의 `.native`(ioredis) 를 받는다 — pub/sub 가 아니라 HASH 명령(HSET/HGETALL/HDEL)만 쓴다.
|
|
40
|
+
*/
|
|
41
|
+
export class MegaWsRedisRoster {
|
|
42
|
+
/**
|
|
43
|
+
* @param {Object} opts
|
|
44
|
+
* @param {import('ioredis').Redis} opts.redis - 캐시 어댑터의 raw ioredis 핸들.
|
|
45
|
+
* @param {() => Array<{ channel: string, sessionId: string, member: RosterMember }>} opts.getLocalMembers -
|
|
46
|
+
* 이 워커의 현재 로컬 멤버 목록(heartbeat 갱신 대상). 보통 mega-app 의 세션 매핑에서 도출.
|
|
47
|
+
* @param {string} [opts.keyPrefix] - 키 접두(기본 'ws:roster').
|
|
48
|
+
* @param {number} [opts.ttlMs] - 멤버 TTL(ms, 기본 30000).
|
|
49
|
+
* @param {{ debug?: Function, warn?: Function, error?: Function }} [opts.logger]
|
|
50
|
+
*/
|
|
51
|
+
constructor({ redis, getLocalMembers, keyPrefix, ttlMs, logger } = /** @type {any} */ ({})) {
|
|
52
|
+
if (!redis || typeof redis.hset !== 'function' || typeof redis.hgetall !== 'function') {
|
|
53
|
+
throw new Error('MegaWsRedisRoster: a redis(ioredis) handle (cache adapter .native) is required.')
|
|
54
|
+
}
|
|
55
|
+
this._redis = redis
|
|
56
|
+
this._getLocalMembers = typeof getLocalMembers === 'function' ? getLocalMembers : () => /** @type {Array<{ channel: string, sessionId: string, member: RosterMember }>} */ ([])
|
|
57
|
+
this._prefix = typeof keyPrefix === 'string' && keyPrefix.length > 0 ? keyPrefix : DEFAULT_KEY_PREFIX
|
|
58
|
+
this._ttlMs = Number.isInteger(ttlMs) && /** @type {number} */ (ttlMs) > 0 ? /** @type {number} */ (ttlMs) : DEFAULT_TTL_MS
|
|
59
|
+
this._log = logger
|
|
60
|
+
/** @type {ReturnType<typeof setInterval>|null} */
|
|
61
|
+
this._hbTimer = null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 채널 → redis 키. @param {string} channel @returns {string} @private */
|
|
65
|
+
_key(channel) {
|
|
66
|
+
return `${this._prefix}:${channel}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 채널 roster 에 멤버 추가/갱신(joinSession). 키 자체에도 EXPIRE 를 걸어 버려진 채널이 새지 않게 한다.
|
|
71
|
+
* @param {string} channel @param {string} sessionId @param {RosterMember} member @returns {Promise<void>}
|
|
72
|
+
*/
|
|
73
|
+
async add(channel, sessionId, member) {
|
|
74
|
+
if (typeof channel !== 'string' || typeof sessionId !== 'string') return
|
|
75
|
+
const key = this._key(channel)
|
|
76
|
+
const value = JSON.stringify({ userId: member?.userId, ...(member?.metadata ? { metadata: member.metadata } : {}), expiresAt: Date.now() + this._ttlMs })
|
|
77
|
+
await this._redis.hset(key, sessionId, value)
|
|
78
|
+
// 채널 HASH 키 전체 TTL — 모든 멤버가 stale 가 돼도 키가 영구히 남지 않게(멤버 TTL 의 2배 여유).
|
|
79
|
+
await this._redis.pexpire(key, this._ttlMs * 2)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 채널 roster 에서 멤버 제거(disconnect).
|
|
84
|
+
* @param {string} channel @param {string} sessionId @returns {Promise<void>}
|
|
85
|
+
*/
|
|
86
|
+
async remove(channel, sessionId) {
|
|
87
|
+
if (typeof channel !== 'string' || typeof sessionId !== 'string') return
|
|
88
|
+
await this._redis.hdel(this._key(channel), sessionId)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 채널의 **현재 접속자 목록**(만료 제외). 만료 엔트리는 best-effort 로 정리한다.
|
|
93
|
+
* @param {string} channel @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
|
|
94
|
+
*/
|
|
95
|
+
async list(channel) {
|
|
96
|
+
if (typeof channel !== 'string') return []
|
|
97
|
+
/** @type {Record<string, string>} */
|
|
98
|
+
const raw = await this._redis.hgetall(this._key(channel))
|
|
99
|
+
const now = Date.now()
|
|
100
|
+
/** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
|
|
101
|
+
const out = []
|
|
102
|
+
/** @type {string[]} 만료돼 정리할 field. */
|
|
103
|
+
const expired = []
|
|
104
|
+
for (const [sessionId, json] of Object.entries(raw ?? {})) {
|
|
105
|
+
let m
|
|
106
|
+
try {
|
|
107
|
+
m = JSON.parse(json)
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// 손상 엔트리는 결과에서 제외 + 정리(silent 금지 — 사유 로그).
|
|
110
|
+
this._log?.warn?.({ err, channel, sessionId }, 'ws-roster corrupt entry (dropping)')
|
|
111
|
+
expired.push(sessionId)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (typeof m.expiresAt === 'number' && m.expiresAt < now) {
|
|
115
|
+
expired.push(sessionId) // heartbeat 끊긴 crash 세션 — 만료.
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
out.push({ sessionId, userId: m.userId, ...(m.metadata ? { metadata: m.metadata } : {}) })
|
|
119
|
+
}
|
|
120
|
+
if (expired.length > 0) {
|
|
121
|
+
// best-effort 정리(실패해도 다음 list 에서 다시 시도) — 결과엔 이미 제외됨.
|
|
122
|
+
this._redis.hdel(this._key(channel), ...expired).catch((err) => this._log?.warn?.({ err, channel }, 'ws-roster expired cleanup failed'))
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* heartbeat 시작 — 주기적으로 로컬 멤버의 `expiresAt` 을 갱신해 살아있음을 알린다(crash 워커는 갱신이
|
|
129
|
+
* 끊겨 만료). 프로세스 종료를 막지 않게 unref. 멱등(중복 호출 무시).
|
|
130
|
+
* @returns {void}
|
|
131
|
+
*/
|
|
132
|
+
startHeartbeat() {
|
|
133
|
+
if (this._hbTimer) return
|
|
134
|
+
const intervalMs = Math.max(1000, Math.floor(this._ttlMs / 2))
|
|
135
|
+
this._hbTimer = setInterval(() => {
|
|
136
|
+
this._refreshLocal().catch((err) => this._log?.warn?.({ err }, 'ws-roster heartbeat refresh failed'))
|
|
137
|
+
}, intervalMs)
|
|
138
|
+
this._hbTimer.unref?.()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 로컬 멤버 전체를 다시 add(=expiresAt 갱신). @returns {Promise<void>} @private
|
|
143
|
+
*/
|
|
144
|
+
async _refreshLocal() {
|
|
145
|
+
const members = this._getLocalMembers()
|
|
146
|
+
for (const { channel, sessionId, member } of members) {
|
|
147
|
+
await this.add(channel, sessionId, member)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 정리 — heartbeat 중지 + 로컬 멤버를 redis 에서 제거(graceful 종료 시 다른 워커가 즉시 정합).
|
|
153
|
+
* @returns {Promise<void>}
|
|
154
|
+
*/
|
|
155
|
+
async stop() {
|
|
156
|
+
if (this._hbTimer) clearInterval(this._hbTimer)
|
|
157
|
+
this._hbTimer = null
|
|
158
|
+
// graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리).
|
|
159
|
+
for (const { channel, sessionId } of this._getLocalMembers()) {
|
|
160
|
+
await this.remove(channel, sessionId).catch(() => {})
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/core/ws-upgrade.js
CHANGED
|
@@ -175,14 +175,21 @@ export class MegaWsConnection {
|
|
|
175
175
|
* @param {import('./mega-app.js').MegaApp|null|undefined} app
|
|
176
176
|
* @param {MegaWsConnection} conn
|
|
177
177
|
* @param {string|undefined} ns
|
|
178
|
-
* @returns {{ list: () => Array<object
|
|
178
|
+
* @returns {{ list: () => Promise<Array<object>>, join: (entry: object) => void, directToUser: (userId: string, message: object) => void, broadcast: (args: object) => void } | null}
|
|
179
179
|
*/
|
|
180
180
|
function buildWsPresence(app, conn, ns) {
|
|
181
181
|
if (!app || typeof (/** @type {any} */ (app).joinSession) !== 'function' || typeof ns !== 'string') return null
|
|
182
182
|
const a = /** @type {any} */ (app)
|
|
183
183
|
return {
|
|
184
|
-
/**
|
|
185
|
-
|
|
184
|
+
/**
|
|
185
|
+
* 이 연결이 가입한 채널의 **클러스터 전역** 접속자 목록(async). redis roster(ADR-177) 배선 시 채널 기준
|
|
186
|
+
* (redis HASH, 멀티 허브 정합); 미배선이면 NATS/로컬 ns 기준. 룸별 필터는 앱이 직접(프레임워크는 채널 단위).
|
|
187
|
+
* @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
|
|
188
|
+
*/
|
|
189
|
+
list: async () => {
|
|
190
|
+
if (a._wsRoster && conn.channels && conn.channels.size > 0) return a.presenceList([...conn.channels])
|
|
191
|
+
return a.roster(ns)
|
|
192
|
+
},
|
|
186
193
|
/** 연결을 신원에 매핑 — `{ userId, sessionId, channels?, metadata? }`. roster 등록이 자동으로 따라온다. */
|
|
187
194
|
join: (entry) => {
|
|
188
195
|
a.joinSession(conn, entry)
|
|
@@ -68,7 +68,14 @@ const LEVEL_NAMES = /** @type {Record<number, string>} */ ({
|
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* disk-backed retry queue — 전송 실패한 텍스트를 파일(JSONL)에 쌓고, 다음 기회에 재시도한다(전송 실패해도
|
|
71
|
-
* 메시지를 잃지 않음, ADR-023).
|
|
71
|
+
* 메시지를 잃지 않음, ADR-023).
|
|
72
|
+
*
|
|
73
|
+
* # in-process 직렬화 락 (ADR-023)
|
|
74
|
+
* 단일 스레드라도 `append`(open→write→close)와 `drain`(rename→read→rm)은 각 await 사이에서 **이벤트루프
|
|
75
|
+
* 인터리브**된다. 인터리브되면 append 의 `open` 이 drain 의 `rename` 보다 먼저 일어날 때, write 가 이미
|
|
76
|
+
* rename·rm 된 inode 로 가서 항목이 **유실**된다(rename-claim 의 "유실 0" 이 깨지는 좁은 창). 그래서 큐
|
|
77
|
+
* 연산을 promise 체인 mutex 로 **직렬화**해 인터리브를 원천 차단한다. 큐 연산은 드물어(전송 실패 시
|
|
78
|
+
* append + 주기 drain) 직렬화 비용은 무시 가능. rename-claim 은 cross-process 안전용으로 그대로 둔다.
|
|
72
79
|
*/
|
|
73
80
|
export class RetryQueue {
|
|
74
81
|
/**
|
|
@@ -77,16 +84,32 @@ export class RetryQueue {
|
|
|
77
84
|
constructor(filePath) {
|
|
78
85
|
/** @type {string} */
|
|
79
86
|
this._file = filePath
|
|
87
|
+
/** @type {Promise<unknown>} 직렬화 락의 꼬리 — append/drain/clear 를 이 체인에 줄세운다. */
|
|
88
|
+
this._tail = Promise.resolve()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 큐 연산을 직렬화 — 앞 연산이 끝난 뒤(성패 무관) `fn` 을 실행한다. 한 연산의 실패가 다음 연산을 막지
|
|
93
|
+
* 않도록 락 체인은 에러를 삼키되, 호출자에겐 결과/에러를 그대로 전파한다.
|
|
94
|
+
* @template T @param {() => Promise<T>} fn @returns {Promise<T>} @private
|
|
95
|
+
*/
|
|
96
|
+
_serialize(fn) {
|
|
97
|
+
const result = this._tail.then(() => fn())
|
|
98
|
+
// 락 꼬리: 앞 연산의 성패와 무관하게 다음 연산을 진행시킨다(에러는 result 로 호출자에게만 전파).
|
|
99
|
+
this._tail = result.catch(() => {})
|
|
100
|
+
return result
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
/**
|
|
83
|
-
* 실패 메시지를 큐에 append.
|
|
104
|
+
* 실패 메시지를 큐에 append (직렬화됨).
|
|
84
105
|
* @param {string} text
|
|
85
106
|
* @returns {Promise<void>}
|
|
86
107
|
*/
|
|
87
108
|
async append(text) {
|
|
88
|
-
|
|
89
|
-
|
|
109
|
+
return this._serialize(async () => {
|
|
110
|
+
await mkdir(dirname(this._file), { recursive: true })
|
|
111
|
+
await appendFile(this._file, JSON.stringify({ text, ts: Date.now() }) + '\n', 'utf8')
|
|
112
|
+
})
|
|
90
113
|
}
|
|
91
114
|
|
|
92
115
|
/**
|
|
@@ -100,34 +123,38 @@ export class RetryQueue {
|
|
|
100
123
|
* @returns {Promise<string[]>}
|
|
101
124
|
*/
|
|
102
125
|
async drain() {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
await rename(this._file, claim)
|
|
106
|
-
} catch (e) {
|
|
107
|
-
// 큐 파일 없음(아직 실패 없음) 또는 다른 drain 이 이미 가로챔 → 처리할 것 없음.
|
|
108
|
-
if (/** @type {NodeJS.ErrnoException} */ (e)?.code === 'ENOENT') return []
|
|
109
|
-
throw e
|
|
110
|
-
}
|
|
111
|
-
const raw = await readFile(claim, 'utf8')
|
|
112
|
-
await rm(claim, { force: true })
|
|
113
|
-
/** @type {string[]} */
|
|
114
|
-
const texts = []
|
|
115
|
-
for (const line of raw.split('\n')) {
|
|
116
|
-
if (!line.trim()) continue
|
|
126
|
+
return this._serialize(async () => {
|
|
127
|
+
const claim = `${this._file}.draining`
|
|
117
128
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
129
|
+
await rename(this._file, claim)
|
|
130
|
+
} catch (e) {
|
|
131
|
+
// 큐 파일 없음(아직 실패 없음) 또는 다른 drain 이 이미 가로챔 → 처리할 것 없음.
|
|
132
|
+
if (/** @type {NodeJS.ErrnoException} */ (e)?.code === 'ENOENT') return []
|
|
133
|
+
throw e
|
|
134
|
+
}
|
|
135
|
+
const raw = await readFile(claim, 'utf8')
|
|
136
|
+
await rm(claim, { force: true })
|
|
137
|
+
/** @type {string[]} */
|
|
138
|
+
const texts = []
|
|
139
|
+
for (const line of raw.split('\n')) {
|
|
140
|
+
if (!line.trim()) continue
|
|
141
|
+
try {
|
|
142
|
+
const obj = JSON.parse(line)
|
|
143
|
+
if (typeof obj.text === 'string') texts.push(obj.text)
|
|
144
|
+
} catch {
|
|
145
|
+
// 손상된 라인은 건너뛴다 — 큐 전체를 막지 않기 위함(비치명적, 다음 항목 계속).
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
123
148
|
}
|
|
124
|
-
|
|
125
|
-
|
|
149
|
+
return texts
|
|
150
|
+
})
|
|
126
151
|
}
|
|
127
152
|
|
|
128
|
-
/** 큐 파일 제거(
|
|
153
|
+
/** 큐 파일 제거(테스트·정리용, 직렬화됨). @returns {Promise<void>} */
|
|
129
154
|
async clear() {
|
|
130
|
-
|
|
155
|
+
return this._serialize(async () => {
|
|
156
|
+
await rm(this._file, { force: true })
|
|
157
|
+
})
|
|
131
158
|
}
|
|
132
159
|
}
|
|
133
160
|
|