mega-framework 0.1.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mega-framework",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
5
5
  "type": "module",
6
6
  "engines": {
@@ -82,6 +82,10 @@ export default {
82
82
  bridgeId: process.env.BRIDGE_ID ?? 'main-1',
83
83
  channels: ['chat'],
84
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 },
85
89
  },
86
90
 
87
91
  // rate limit 상향(ADR-073) — /demo/worker 데모는 1초 간격 하트비트 ping(메인 스레드 non-block 시연)으로
@@ -2,14 +2,15 @@
2
2
  /**
3
3
  * ChatChannel — /ws/chat 실시간 채팅 WS 채널 (ADR-158/176).
4
4
  *
5
- * 클러스터 전파(broadcast)와 접속자 목록(roster) 동기화는 **프레임워크가 자동 처리**한다(ADR-176,
6
- * `wsCluster` = NATS). 채널은 `ctx.presence`/`ctx.app.broadcast`**비즈니스 로직만** 작성하고, redis
7
- * pub/sub·roster 동기화 코드를 직접 두지 않는다(개발자 배선 불요). 최근 메시지 기록(history)만 redis
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 })` → 같은 ns 전 클러스터 클라가 1회씩 수신(NATS fan-out).
11
- * - 접속자: `ctx.presence.join({...})`(자동 roster 등록·동기화) + `ctx.presence.list()`(클러스터 전역 명단).
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) // 클러스터 전역 명단(자동 동기화된 roster).
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
- * 클러스터 전역 접속자 이름 목록(자동 동기화된 roster). exceptSessionId 주어지면 세션은 뺀다.
99
- * @param {any} ctx @param {string} [exceptSessionId] @returns {string[]}
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
- return ctx.presence
104
- .list()
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
- /** 접속자 수 뱃지 + 명단 툴팁 갱신(roster 는 cluster-wide redis HASH 공유). */
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 redis pub/sub 로 모든 워커에서 도착.
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>
@@ -1234,7 +1234,7 @@ marked@^18.0.5:
1234
1234
  integrity sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==
1235
1235
 
1236
1236
  "mega-framework@file:../..":
1237
- version "0.1.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'
@@ -306,15 +308,40 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
306
308
  const a = /** @type {any} */ (app) // _deliverBroadcast/_deliverDirect 는 framework-internal(@private).
307
309
  const bridgeHub = /** @type {any} */ (apps[i].config).bridgeHub
308
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}`
309
319
  // WS Hub 자동연결(ADR-065/176). connectHub 가 _hubLink 를 세워 app.broadcast/joinSession 의 hub 경로를
310
320
  // 활성화한다(shutdown hook 도 connectHub 가 등록). 허브 다운이어도 boot 를 막지 않는다 — 초기 연결
311
321
  // 실패는 warn 하고(retry 설정 시 백그라운드 재연결, ADR-098) 앱은 계속 뜬다(로컬 전달은 동작).
312
322
  try {
313
- await app.connectHub(bridgeHub)
314
- logger?.debug?.({ app: app.name, url: bridgeHub.url }, 'boot.bridgeHub connected (ADR-176)')
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)')
315
325
  } catch (err) {
316
326
  logger?.warn?.({ err, app: app.name, url: bridgeHub.url }, 'boot.bridgeHub initial connect failed (retry if configured)')
317
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
+ }
318
345
  } else if (wsClusterBus) {
319
346
  const cluster = new MegaWsCluster({
320
347
  bus: /** @type {any} */ (wsClusterBus),
@@ -287,6 +287,45 @@ export function validateAppConfig(appConfig, expectedFolderName, globalConfig) {
287
287
  { details: { app: expectedFolderName, bridgeHubUrl: bh.url, wsClusterBus: globalConfig.wsCluster.bus } },
288
288
  )
289
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
+ }
290
329
  }
291
330
 
292
331
  // 3) Shared-Reference 키 검증 — 참조하는 키가 globalConfig.services 에 존재해야 함
@@ -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
- // 재연결 성공 presence 재동기화(ADR-098) — hub 절단 시점 presence 를 잃으므로 다시 JOIN.
642
+ // 허브가 fan-out 하는 presence(JOIN/LEAVE/METADATA)·heartbeatbroadcast 채널 멤버십·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
- // wsCluster 미배선 joinSession 으로 매핑된 로컬 세션 해당 ns 만.
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
+ }
@@ -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>, join: (entry: object) => void, directToUser: (userId: string, message: object) => void, broadcast: (args: object) => void } | null}
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
- /** 이 채널 ns 의 **클러스터 전역** 접속자 목록(wsCluster roster). 미배선이면 로컬 멤버만. */
185
- list: () => a.roster(ns),
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). worker thread 내에서만 접근하므로 락 불필요(단일 소비자).
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
- await mkdir(dirname(this._file), { recursive: true })
89
- await appendFile(this._file, JSON.stringify({ text, ts: Date.now() }) + '\n', 'utf8')
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
- const claim = `${this._file}.draining`
104
- try {
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
- const obj = JSON.parse(line)
119
- if (typeof obj.text === 'string') texts.push(obj.text)
120
- } catch {
121
- // 손상된 라인은 건너뛴다 전체를 막지 않기 위함(비치명적, 다음 항목 계속).
122
- continue
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
- return texts
149
+ return texts
150
+ })
126
151
  }
127
152
 
128
- /** 큐 파일 제거(테스트·정리용). @returns {Promise<void>} */
153
+ /** 큐 파일 제거(테스트·정리용, 직렬화됨). @returns {Promise<void>} */
129
154
  async clear() {
130
- await rm(this._file, { force: true })
155
+ return this._serialize(async () => {
156
+ await rm(this._file, { force: true })
157
+ })
131
158
  }
132
159
  }
133
160