mega-framework 0.1.5 → 0.1.7
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/bin/mega-ws-hub.js +2 -2
- package/package.json +32 -8
- package/sample/crud/.env +156 -8
- package/sample/crud/.env.example +153 -28
- package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
- package/sample/crud/.mega/journal/snapshot.json +261 -0
- package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
- package/sample/crud/apps/main/controllers/web-controller.js +7 -5
- package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
- package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
- package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
- package/sample/crud/apps/main/models/log-partition-model.js +105 -0
- package/sample/crud/apps/main/models/note-model.js +79 -0
- package/sample/crud/apps/main/models/user-level-model.js +24 -0
- package/sample/crud/apps/main/models/user-model.js +146 -0
- package/sample/crud/apps/main/models/user-type-model.js +21 -0
- package/sample/crud/apps/main/models/wallet-model.js +24 -0
- package/sample/crud/apps/main/routes/users.js +55 -10
- package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
- package/sample/crud/apps/main/services/auth-service.js +39 -24
- package/sample/crud/apps/main/services/log-partition-service.js +101 -0
- package/sample/crud/apps/main/services/note-service.js +6 -6
- package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
- package/sample/crud/apps/main/services/user-service.js +62 -21
- package/sample/crud/apps/main/views/auth/login.ejs +6 -6
- package/sample/crud/apps/main/views/auth/register.ejs +46 -5
- package/sample/crud/apps/main/views/users/edit.ejs +42 -5
- package/sample/crud/apps/main/views/users/list.ejs +6 -2
- package/sample/crud/apps/main/views/users/new.ejs +56 -4
- package/sample/crud/docs/log_partition_design.mm.md +23 -0
- package/sample/crud/mega.config.js +63 -3
- package/sample/crud/package.json +3 -3
- package/sample/crud/scripts/start-ws-hub.sh +2 -2
- package/sample/simple/package.json +2 -2
- package/src/adapters/adapter-manager.js +2 -1
- package/src/adapters/adapter-options.js +30 -0
- package/src/adapters/maria-adapter.js +26 -3
- package/src/adapters/mega-db-adapter.js +7 -1
- package/src/adapters/mongo-adapter.js +19 -1
- package/src/adapters/postgres-adapter.js +25 -2
- package/src/adapters/sqlite-adapter.js +20 -1
- package/src/cli/commands/new.js +13 -3
- package/src/cli/commands/scaffold.js +137 -33
- package/src/cli/generators/index.js +82 -2
- package/src/cli/index.js +478 -104
- package/src/core/ajv-mapper.js +27 -2
- package/src/core/boot.js +485 -237
- package/src/core/cluster-metrics.js +13 -4
- package/src/core/config-validator.js +25 -0
- package/src/core/ctx-builder.js +6 -2
- package/src/core/envelope.js +112 -12
- package/src/core/hub-link.js +65 -4
- package/src/core/i18n.js +11 -1
- package/src/core/index.js +6 -2
- package/src/core/mega-app.js +223 -481
- package/src/core/mega-cluster.js +54 -13
- package/src/core/mega-server.js +40 -9
- package/src/core/migration/dialect-registry.js +107 -0
- package/src/core/migration/dialects/README.md +62 -0
- package/src/core/migration/dialects/maria.js +496 -0
- package/src/core/migration/dialects/mongo.js +824 -0
- package/src/core/migration/dialects/postgres.js +563 -0
- package/src/core/migration/dialects/sqlite.js +476 -0
- package/src/core/migration/differ.js +456 -0
- package/src/core/migration/generate.js +508 -0
- package/src/core/migration/journal.js +167 -0
- package/src/core/migration/model-scan.js +84 -0
- package/src/core/migration/mongo-migration-db.js +97 -0
- package/src/core/migration/schema-builder.js +400 -0
- package/src/core/migration/schema-validator.js +315 -0
- package/src/core/migration-lock.js +205 -0
- package/src/core/migration-runner.js +166 -38
- package/src/core/multipart.js +28 -5
- package/src/core/pipeline.js +129 -0
- package/src/core/router.js +70 -65
- package/src/core/scope-registry.js +0 -1
- package/src/core/security.js +67 -9
- package/src/core/workers-manager.js +12 -1
- package/src/core/ws-cluster.js +10 -3
- package/src/core/ws-message.js +48 -4
- package/src/core/ws-presence.js +624 -0
- package/src/core/ws-roster.js +4 -1
- package/src/core/ws-upgrade.js +118 -12
- package/src/index.js +1 -1
- package/src/lib/hub-protocol.js +29 -0
- package/src/lib/mega-health.js +25 -4
- package/src/lib/mega-job-queue.js +98 -21
- package/src/lib/mega-job.js +29 -0
- package/src/lib/mega-logger.js +1 -1
- package/src/lib/mega-metrics.js +3 -12
- package/src/lib/mega-plugin.js +34 -3
- package/src/lib/mega-schedule.js +40 -22
- package/src/lib/mega-shutdown.js +162 -49
- package/src/lib/mega-tracing.js +66 -19
- package/src/lib/mega-worker.js +5 -1
- package/src/lib/otel-resource.js +36 -0
- package/src/{cli → lib}/ws-hub.js +51 -8
- package/src/models/crud-sql-builder.js +133 -0
- package/src/models/mega-model.js +82 -2
- package/src/models/model-crud.js +483 -0
- package/src/models/mongo-crud.js +285 -0
- package/templates/model/code-mongo.tpl +35 -0
- package/templates/model/code.tpl +15 -1
- package/templates/model/test-mongo.tpl +38 -0
- package/templates/model/test.tpl +4 -0
- package/types/adapters/adapter-manager.d.ts +95 -0
- package/types/adapters/adapter-options.d.ts +91 -0
- package/types/adapters/file-adapter.d.ts +94 -0
- package/types/adapters/file-session-adapter.d.ts +101 -0
- package/types/adapters/index.d.ts +20 -0
- package/types/adapters/maria-adapter.d.ts +115 -0
- package/types/adapters/mega-adapter.d.ts +215 -0
- package/types/adapters/mega-bus-adapter.d.ts +45 -0
- package/types/adapters/mega-cache-adapter.d.ts +47 -0
- package/types/adapters/mega-db-adapter.d.ts +47 -0
- package/types/adapters/mega-lock-adapter.d.ts +62 -0
- package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
- package/types/adapters/mega-session-adapter.d.ts +32 -0
- package/types/adapters/mongo-adapter.d.ts +139 -0
- package/types/adapters/nats-adapter.d.ts +108 -0
- package/types/adapters/postgres-adapter.d.ts +139 -0
- package/types/adapters/redis-adapter.d.ts +70 -0
- package/types/adapters/redis-session-adapter.d.ts +82 -0
- package/types/adapters/redlock-adapter.d.ts +149 -0
- package/types/adapters/registry.d.ts +46 -0
- package/types/adapters/sqlite-adapter.d.ts +106 -0
- package/types/auth/index.d.ts +24 -0
- package/types/cli/commands/console-cmd.d.ts +37 -0
- package/types/cli/commands/new.d.ts +16 -0
- package/types/cli/commands/routes.d.ts +36 -0
- package/types/cli/commands/scaffold.d.ts +78 -0
- package/types/cli/commands/test-cmd.d.ts +14 -0
- package/types/cli/generators/index.d.ts +112 -0
- package/types/cli/index.d.ts +249 -0
- package/types/cli/template-engine.d.ts +40 -0
- package/types/core/ajv-mapper.d.ts +27 -0
- package/types/core/boot.d.ts +233 -0
- package/types/core/cluster-metrics.d.ts +52 -0
- package/types/core/config-loader.d.ts +13 -0
- package/types/core/config-validator.d.ts +30 -0
- package/types/core/ctx-builder.d.ts +80 -0
- package/types/core/envelope.d.ts +79 -0
- package/types/core/error-mapper.d.ts +17 -0
- package/types/core/formbody.d.ts +41 -0
- package/types/core/hub-link.d.ts +264 -0
- package/types/core/i18n.d.ts +178 -0
- package/types/core/index.d.ts +28 -0
- package/types/core/mega-app.d.ts +529 -0
- package/types/core/mega-cluster.d.ts +104 -0
- package/types/core/mega-server.d.ts +91 -0
- package/types/core/mega-service.d.ts +31 -0
- package/types/core/migration/dialect-registry.d.ts +22 -0
- package/types/core/migration/dialects/maria.d.ts +99 -0
- package/types/core/migration/dialects/mongo.d.ts +89 -0
- package/types/core/migration/dialects/postgres.d.ts +117 -0
- package/types/core/migration/dialects/sqlite.d.ts +111 -0
- package/types/core/migration/differ.d.ts +47 -0
- package/types/core/migration/generate.d.ts +56 -0
- package/types/core/migration/journal.d.ts +52 -0
- package/types/core/migration/model-scan.d.ts +19 -0
- package/types/core/migration/mongo-migration-db.d.ts +7 -0
- package/types/core/migration/schema-builder.d.ts +197 -0
- package/types/core/migration/schema-validator.d.ts +20 -0
- package/types/core/migration-lock.d.ts +33 -0
- package/types/core/migration-runner.d.ts +101 -0
- package/types/core/multipart.d.ts +86 -0
- package/types/core/openapi.d.ts +62 -0
- package/types/core/pipeline.d.ts +92 -0
- package/types/core/router.d.ts +159 -0
- package/types/core/routes-loader.d.ts +21 -0
- package/types/core/scope-registry.d.ts +14 -0
- package/types/core/security.d.ts +77 -0
- package/types/core/services-loader.d.ts +27 -0
- package/types/core/session-cleanup-schedule.d.ts +19 -0
- package/types/core/session-store.d.ts +18 -0
- package/types/core/session.d.ts +77 -0
- package/types/core/static-assets.d.ts +73 -0
- package/types/core/template.d.ts +106 -0
- package/types/core/workers-manager.d.ts +79 -0
- package/types/core/ws-cluster.d.ts +208 -0
- package/types/core/ws-compression.d.ts +112 -0
- package/types/core/ws-controller.d.ts +65 -0
- package/types/core/ws-message.d.ts +106 -0
- package/types/core/ws-presence.d.ts +273 -0
- package/types/core/ws-roster.d.ts +96 -0
- package/types/core/ws-upgrade.d.ts +231 -0
- package/types/errors/config-error.d.ts +10 -0
- package/types/errors/http-errors.d.ts +120 -0
- package/types/errors/index.d.ts +3 -0
- package/types/errors/mega-error.d.ts +32 -0
- package/types/index.d.ts +39 -0
- package/types/lib/asp/config.d.ts +49 -0
- package/types/lib/asp/crypto.d.ts +43 -0
- package/types/lib/asp/errors.d.ts +30 -0
- package/types/lib/asp/nonce-cache.d.ts +52 -0
- package/types/lib/asp/plugin.d.ts +30 -0
- package/types/lib/asp/ws-terminator.d.ts +45 -0
- package/types/lib/env-mapper.d.ts +14 -0
- package/types/lib/hub-protocol.d.ts +106 -0
- package/types/lib/index.d.ts +22 -0
- package/types/lib/logger/telegram-core.d.ts +104 -0
- package/types/lib/logger/telegram-transport.d.ts +45 -0
- package/types/lib/mega-brute-force.d.ts +66 -0
- package/types/lib/mega-circuit-breaker.d.ts +241 -0
- package/types/lib/mega-cron.d.ts +66 -0
- package/types/lib/mega-hash.d.ts +32 -0
- package/types/lib/mega-health.d.ts +41 -0
- package/types/lib/mega-job-queue.d.ts +176 -0
- package/types/lib/mega-job-worker.d.ts +130 -0
- package/types/lib/mega-job.d.ts +138 -0
- package/types/lib/mega-logger.d.ts +45 -0
- package/types/lib/mega-metrics.d.ts +285 -0
- package/types/lib/mega-plugin.d.ts +245 -0
- package/types/lib/mega-retry.d.ts +85 -0
- package/types/lib/mega-schedule.d.ts +260 -0
- package/types/lib/mega-shutdown.d.ts +135 -0
- package/types/lib/mega-tracing.d.ts +224 -0
- package/types/lib/mega-worker.d.ts +127 -0
- package/types/lib/otel-resource.d.ts +16 -0
- package/types/lib/worker-runner/process-entry.d.ts +1 -0
- package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
- package/types/lib/worker-runner/thread-entry.d.ts +1 -0
- package/types/lib/ws-hub.d.ts +234 -0
- package/types/models/crud-sql-builder.d.ts +48 -0
- package/types/models/index.d.ts +1 -0
- package/types/models/mega-model.d.ts +138 -0
- package/types/models/model-crud.d.ts +82 -0
- package/types/models/mongo-crud.d.ts +59 -0
- package/types/test/index.d.ts +84 -0
- package/.env +0 -127
- package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
- package/sample/crud/apps/main/models/note.js +0 -71
- package/sample/crud/apps/main/models/user.js +0 -86
- package/sample/crud/package-lock.json +0 -5665
- package/sample/crud/yarn.lock +0 -2142
- package/sample/simple/package-lock.json +0 -1851
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaWsPresence — 앱의 WS presence/클러스터 동기화 협력자 (MegaApp 에서 분리).
|
|
4
|
+
*
|
|
5
|
+
* 책임: ① 로컬 연결 인덱스 3종(ns→연결·userId→연결·sessionId→연결) 관리(_track/_untrack),
|
|
6
|
+
* ② hub link(connectHub — BROADCAST/DIRECT 수신·presence 재동기화·admin-kick), ③ NATS
|
|
7
|
+
* wsCluster/redis wsRoster 동기화, ④ broadcast/directToUser/updateMetadata 의 로컬+클러스터
|
|
8
|
+
* fan-out, ⑤ roster/presenceList 조회. MegaApp 은 같은 이름의 공개 메서드를 본 협력자에
|
|
9
|
+
* 위임한다(분리 전 호출부·테스트와 표면 동일).
|
|
10
|
+
*
|
|
11
|
+
* MegaApp(HTTP 앱 골격: 보안/세션/i18n/템플릿/헬스/upgrade 핸드셰이크)과 분리한 이유 —
|
|
12
|
+
* 연결 인덱스 불변식(joinSession 의 dangling 정리, _untrack 의 동일성 확인)이 한 모듈에
|
|
13
|
+
* 모여야 검증·유지보수가 좁아진다. 분리는 위임 기반이라 동작 변화 0.
|
|
14
|
+
*
|
|
15
|
+
* @module core/ws-presence
|
|
16
|
+
*/
|
|
17
|
+
import { MegaHubLink } from './hub-link.js'
|
|
18
|
+
import { CLOSE_CODE_REQUEUE } from './ws-upgrade.js'
|
|
19
|
+
import { HUB_MESSAGE_TYPES } from '../lib/hub-protocol.js'
|
|
20
|
+
import { MegaShutdown } from '../lib/mega-shutdown.js'
|
|
21
|
+
|
|
22
|
+
export class MegaWsPresence {
|
|
23
|
+
/**
|
|
24
|
+
* @param {Object} opts
|
|
25
|
+
* @param {string} opts.appName - 소유 앱 이름(hook 이름·로그 식별자).
|
|
26
|
+
* @param {import('pino').Logger | { debug?: Function, warn?: Function } | null} [opts.log] -
|
|
27
|
+
* 앱 공유 로거(보통 `app.fastify.log`). 미주입이면 무로그.
|
|
28
|
+
*/
|
|
29
|
+
constructor({ appName, log } = /** @type {any} */ ({})) {
|
|
30
|
+
if (typeof appName !== 'string' || appName.length === 0) {
|
|
31
|
+
throw new Error('MegaWsPresence: appName is required')
|
|
32
|
+
}
|
|
33
|
+
/** @type {string} */
|
|
34
|
+
this._appName = appName
|
|
35
|
+
/** @type {any} */
|
|
36
|
+
this._log = log ?? null
|
|
37
|
+
/** @type {MegaHubLink|null} hub 연결 (scaffold 권장). */
|
|
38
|
+
this._hubLink = null
|
|
39
|
+
/** @type {import('./ws-cluster.js').MegaWsCluster|null} NATS 기반 클러스터 fan-out/roster (ADR-176, boot 자동배선). */
|
|
40
|
+
this._wsCluster = null
|
|
41
|
+
/** @type {import('./ws-roster.js').MegaWsRedisRoster|null} 채널별 redis roster(ADR-177, boot 자동배선).
|
|
42
|
+
* 접속자 목록(상태)을 redis HASH 로 cluster-wide 관리한다(broadcast 와 별개 — 멀티 허브 정합·즉시 스냅샷). */
|
|
43
|
+
this._wsRoster = null
|
|
44
|
+
/** ns(=ws path) → 활성 로컬 연결 집합 (hub broadcast 의 local fan-out 용). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
45
|
+
this._wsConns = new Map()
|
|
46
|
+
/** userId → 활성 로컬 연결 집합 (DIRECT 타겟팅, H-latent guard). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
47
|
+
this._userConns = new Map()
|
|
48
|
+
/** sessionId → 활성 로컬 연결 1개 (세션단위 JOIN/LEAVE). @type {Map<string, import('./ws-upgrade.js').MegaWsConnection>} */
|
|
49
|
+
this._sessionConns = new Map()
|
|
50
|
+
/** @type {string[]} connectHub 으로 구독한 채널. */
|
|
51
|
+
this._hubChannels = []
|
|
52
|
+
/** connectHub 의 bridgeId (재연결 재구독·세션 JOIN 에 재사용). @type {string|null} */
|
|
53
|
+
this._hubBridgeId = null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 현재 연결된 hub link (미연결 시 null). */
|
|
57
|
+
get hubLink() {
|
|
58
|
+
return this._hubLink
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 이 bridge 를 hub 에 연결한다 (ADR-033/059). REGISTER 핸드셰이크 완료 후
|
|
63
|
+
* 선언 채널을 구독(bridge-subscriber JOIN)하고, hub 의 BROADCAST/DIRECT 를 로컬 소켓에 전달한다.
|
|
64
|
+
*
|
|
65
|
+
* single 모드는 embedded 종단이라 hub 가 필수는 아니지만(02-architecture §3), 멀티 인스턴스
|
|
66
|
+
* fan-out 이 필요하면 single 에서도 사용 가능하다. scaffold(멀티앱/클러스터)에서 권장.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} config - MegaBridgeHubConfig (§2.1) + 구독 채널.
|
|
69
|
+
* @param {string} config.url - hub URL.
|
|
70
|
+
* @param {string} config.token - Bearer 토큰.
|
|
71
|
+
* @param {string} config.bridgeId - 운영 식별자.
|
|
72
|
+
* @param {string} [config.instanceId]
|
|
73
|
+
* @param {string[]} [config.capabilities]
|
|
74
|
+
* @param {string[]} [config.channels] - 자동 구독할 채널 목록.
|
|
75
|
+
* @param {import('../lib/mega-retry.js').MegaRetryOptions} [config.retry] - 지정 시 재연결 활성(ADR-098).
|
|
76
|
+
* hub 재시작·drain(4503)·네트워크 단절 시 지수 백오프로 재연결하고, 성공하면 presence(채널·세션
|
|
77
|
+
* JOIN)를 자동 재동기화한다(hub 는 절단 시점 presence 를 잃으므로).
|
|
78
|
+
* @param {import('./ws-compression.js').WsCompressionConfig} [config.compression] - Bridge↔Hub
|
|
79
|
+
* link 압축(ADR-078 / MegaWsHubCompressionConfig). Global `wsHub.compression`
|
|
80
|
+
* 블록을 그대로 전달한다 — hub 서버와 같은 스키마. 디폴트 OFF. 잘못된 threshold/windowBits 면
|
|
81
|
+
* 즉시 throw(부팅 fail-fast).
|
|
82
|
+
* @returns {Promise<MegaHubLink>} 등록 완료된 link.
|
|
83
|
+
*/
|
|
84
|
+
async connectHub(config = /** @type {any} */ ({})) {
|
|
85
|
+
const link = new MegaHubLink({
|
|
86
|
+
url: config.url,
|
|
87
|
+
token: config.token,
|
|
88
|
+
bridgeId: config.bridgeId,
|
|
89
|
+
instanceId: config.instanceId,
|
|
90
|
+
capabilities: config.capabilities,
|
|
91
|
+
retry: config.retry,
|
|
92
|
+
compression: config.compression,
|
|
93
|
+
logger: this._log,
|
|
94
|
+
})
|
|
95
|
+
this._hubLink = link
|
|
96
|
+
this._hubBridgeId = config.bridgeId
|
|
97
|
+
this._hubChannels = Array.isArray(config.channels) ? [...config.channels] : []
|
|
98
|
+
// hub → bridge 푸시를 로컬 소켓에 전달.
|
|
99
|
+
link.on(HUB_MESSAGE_TYPES.BROADCAST, (msg) => this._deliverBroadcast(/** @type {any} */ (msg).payload))
|
|
100
|
+
link.on(HUB_MESSAGE_TYPES.DIRECT, (msg) => this._deliverDirect(/** @type {any} */ (msg).payload))
|
|
101
|
+
// admin-kick (ADR-097 양방향 DISCONNECT): 다른 bridge 의 강제 종료 요청이 세션 소유 bridge(여기)로
|
|
102
|
+
// 라우팅되면 해당 로컬 소켓을 닫는다. 이 핸들러가 없으면 hub 가 라우팅해도 소켓이 안 닫힌다.
|
|
103
|
+
link.on(HUB_MESSAGE_TYPES.DISCONNECT, (msg) => this._handleHubDisconnect(/** @type {any} */ (msg).payload))
|
|
104
|
+
// 허브가 fan-out 하는 presence(JOIN/LEAVE/METADATA)·heartbeat 는 broadcast 채널 멤버십·keepalive 용으로만
|
|
105
|
+
// 의미 있다. **roster(접속자 목록)는 redis(MegaWsRedisRoster, ADR-177)가 별도 관리**하므로 브릿지는 이들을
|
|
106
|
+
// 소비하지 않는다 — no-op 핸들러로 "no handler for type" 노이즈만 차단한다(JOIN 자체는 joinSession 이
|
|
107
|
+
// broadcast 채널 가입용으로 계속 송신). 멀티 허브에서도 redis 가 single source-of-truth 라 명단이 정합.
|
|
108
|
+
const noopHub = () => {}
|
|
109
|
+
link.on(HUB_MESSAGE_TYPES.JOIN, noopHub)
|
|
110
|
+
link.on(HUB_MESSAGE_TYPES.LEAVE, noopHub)
|
|
111
|
+
link.on(HUB_MESSAGE_TYPES.BULK_LEAVE, noopHub)
|
|
112
|
+
link.on(HUB_MESSAGE_TYPES.METADATA, noopHub)
|
|
113
|
+
link.on(HUB_MESSAGE_TYPES.HEARTBEAT, noopHub)
|
|
114
|
+
// 재연결 성공 시 presence 재동기화(ADR-098) — hub 는 절단 시점 presence 를 잃으므로 broadcast 채널을 다시 JOIN.
|
|
115
|
+
link.on(MegaHubLink.EVENTS.RECONNECTED, () => this._resyncPresence())
|
|
116
|
+
await link.connect()
|
|
117
|
+
|
|
118
|
+
// 채널 구독 — bridge-subscriber 세션 JOIN(zero-config 브로드캐스트 수신용) + 이미 붙어 있는 실
|
|
119
|
+
// 사용자 세션 재JOIN(예: 첫 connect 전에 클라가 연결됐을 수 있음).
|
|
120
|
+
this._resyncPresence()
|
|
121
|
+
|
|
122
|
+
// shutdown hook 누수 방지(L1) — 재연결 등으로 connectHub 가 다시 불려도 hook 은 항상 1개.
|
|
123
|
+
const hookName = `mega-hublink:${this._appName}`
|
|
124
|
+
MegaShutdown.unregister(hookName)
|
|
125
|
+
MegaShutdown.register(hookName, async () => link.close())
|
|
126
|
+
return link
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* hub presence 재동기화 — bridge-subscriber 채널 JOIN + 활성 사용자 세션 JOIN 을 모두 다시 보낸다.
|
|
131
|
+
* 최초 등록 직후와 재연결(RECONNECTED) 직후에 호출된다. hub 의 JOIN 처리는 멱등(같은 sessionId 덮어씀).
|
|
132
|
+
* @returns {void}
|
|
133
|
+
* @private
|
|
134
|
+
*/
|
|
135
|
+
_resyncPresence() {
|
|
136
|
+
const link = this._hubLink
|
|
137
|
+
if (!link?.isRegistered) return
|
|
138
|
+
const bridgeId = this._hubBridgeId ?? this._appName
|
|
139
|
+
// 1) bridge-subscriber JOIN — bridge 가 채널 멤버가 되어 zero-config 브로드캐스트를 받게 한다.
|
|
140
|
+
for (const ch of this._hubChannels) {
|
|
141
|
+
link.join({
|
|
142
|
+
userId: `bridge:${bridgeId}`,
|
|
143
|
+
sessionId: `bridge:${bridgeId}#${ch}`,
|
|
144
|
+
channels: [ch],
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
// 2) 실 사용자 세션 JOIN — joinSession 으로 매핑된 활성 세션을 다시 등록(DIRECT 타겟 복구).
|
|
148
|
+
// 채널 + metadata 까지 재동기화한다(M-1) — hub 는 절단 시점 presence 를 통째로 잃으므로,
|
|
149
|
+
// metadata 를 빠뜨리면 재연결 후 hub presence 의 메타가 silent 사라진다.
|
|
150
|
+
for (const [sessionId, conn] of this._sessionConns) {
|
|
151
|
+
if (!conn.isOpen) continue
|
|
152
|
+
link.join({
|
|
153
|
+
userId: /** @type {string} */ (conn.userId),
|
|
154
|
+
sessionId,
|
|
155
|
+
channels: conn.channels ? [...conn.channels] : [],
|
|
156
|
+
...(conn.metadata ? { metadata: conn.metadata } : {}),
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 실 사용자 세션을 hub presence 에 등록하고 로컬 매핑을 만든다 (OQ-010/ADR-098).
|
|
163
|
+
*
|
|
164
|
+
* 표준 패턴: WS upgrade 의 `before` 미들웨어가 인증 후 신원을 `ctx.auth` 로 싣고(ADR-091 DI),
|
|
165
|
+
* 채널의 `onConnect(sock, ctx)` 에서 `ctx.app.joinSession(sock, { userId: ctx.auth.userId, ... })`
|
|
166
|
+
* 를 호출한다. 이 매핑이 있어야 DIRECT 가 **해당 userId 세션에만** 전달된다(cross-user flood 방지,
|
|
167
|
+
* H-latent guard). 매핑 없는 연결은 DIRECT 대상에서 제외된다.
|
|
168
|
+
*
|
|
169
|
+
* @param {import('./ws-upgrade.js').MegaWsConnection} conn - onConnect 가 받은 소켓 래퍼.
|
|
170
|
+
* @param {Object} entry
|
|
171
|
+
* @param {string} entry.userId - 인증된 사용자 식별자(비어 있으면 throw).
|
|
172
|
+
* @param {string} entry.sessionId - 세션 식별자(비어 있으면 throw). 전역 유일 권장.
|
|
173
|
+
* @param {string[]} [entry.channels] - 가입 채널 목록.
|
|
174
|
+
* @param {Object} [entry.metadata] - presence 메타데이터(명시 필드만, ADR-059).
|
|
175
|
+
* @returns {this}
|
|
176
|
+
* @throws {Error} conn/userId/sessionId 누락 시 — 잘못된 매핑을 silent 통과시키지 않는다.
|
|
177
|
+
*/
|
|
178
|
+
joinSession(conn, { userId, sessionId, channels = [], metadata } = /** @type {any} */ ({})) {
|
|
179
|
+
if (!conn || typeof conn.send !== 'function') {
|
|
180
|
+
throw new Error('MegaApp.joinSession: conn (MegaWsConnection) is required.')
|
|
181
|
+
}
|
|
182
|
+
if (typeof userId !== 'string' || userId.length === 0) {
|
|
183
|
+
throw new Error('MegaApp.joinSession: userId (non-empty string) is required.')
|
|
184
|
+
}
|
|
185
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
186
|
+
throw new Error('MegaApp.joinSession: sessionId (non-empty string) is required.')
|
|
187
|
+
}
|
|
188
|
+
const chans = Array.isArray(channels) ? [...channels] : []
|
|
189
|
+
|
|
190
|
+
// L-4: 같은 sessionId 가 다른 conn 으로 다시 join 되면(전역 유일 계약 위반) 옛 conn 을 인덱스에서
|
|
191
|
+
// 떼어 dangling 을 막는다 — 단 소켓 자체는 닫지 않는다(클라가 정리). 옛 conn 의 신원도 비워,
|
|
192
|
+
// 이후 옛 conn 의 close(_untrackWsConn)가 새 conn 이 차지한 sessionId 로 LEAVE 를 잘못 보내지
|
|
193
|
+
// 않게 한다(그대로 두면 새 세션의 hub presence 가 silent 제거됨).
|
|
194
|
+
const prior = this._sessionConns.get(sessionId)
|
|
195
|
+
if (prior && prior !== conn) {
|
|
196
|
+
this._log?.warn?.(
|
|
197
|
+
{ app: this._appName, sessionId, priorUserId: prior.userId, userId },
|
|
198
|
+
'ws.joinSession duplicate sessionId — prior conn left dangling (detached, not closed)',
|
|
199
|
+
)
|
|
200
|
+
if (prior.userId !== undefined) {
|
|
201
|
+
const pset = this._userConns.get(prior.userId)
|
|
202
|
+
if (pset) {
|
|
203
|
+
pset.delete(prior)
|
|
204
|
+
if (pset.size === 0) this._userConns.delete(prior.userId)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (prior.ns !== undefined) {
|
|
208
|
+
const nsset = this._wsConns.get(prior.ns)
|
|
209
|
+
if (nsset) {
|
|
210
|
+
nsset.delete(prior)
|
|
211
|
+
if (nsset.size === 0) this._wsConns.delete(prior.ns)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
prior.userId = undefined
|
|
215
|
+
prior.sessionId = undefined
|
|
216
|
+
prior.channels = null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 연결에 신원 부착(매핑 키). _untrackWsConn 이 close 시 이 값으로 인덱스를 정리한다.
|
|
220
|
+
conn.userId = userId
|
|
221
|
+
conn.sessionId = sessionId
|
|
222
|
+
conn.channels = new Set(chans)
|
|
223
|
+
conn.metadata = metadata // M-1: 재연결 재동기화(_resyncPresence)가 보존할 수 있게 저장.
|
|
224
|
+
|
|
225
|
+
let uset = this._userConns.get(userId)
|
|
226
|
+
if (!uset) {
|
|
227
|
+
uset = new Set()
|
|
228
|
+
this._userConns.set(userId, uset)
|
|
229
|
+
}
|
|
230
|
+
uset.add(conn)
|
|
231
|
+
this._sessionConns.set(sessionId, conn)
|
|
232
|
+
|
|
233
|
+
this._log?.debug?.({ app: this._appName, userId, sessionId, channels: chans }, 'ws.joinSession')
|
|
234
|
+
// hub presence 등록 — 등록 상태일 때만(미연결/재연결 중이면 _resyncPresence 가 나중에 복구).
|
|
235
|
+
if (this._hubLink?.isRegistered) {
|
|
236
|
+
this._hubLink.join({ userId, sessionId, channels: chans, ...(metadata ? { metadata } : {}) })
|
|
237
|
+
}
|
|
238
|
+
// NATS roster 동기화 (ADR-176) — 프레임워크가 클러스터 접속자 목록을 자동 관리한다(개발자 코드 불요).
|
|
239
|
+
// ns 는 연결의 namespace. roster:'none' 이면 로컬만 갱신한다.
|
|
240
|
+
if (this._wsCluster && typeof conn.ns === 'string') {
|
|
241
|
+
this._wsCluster.rosterAdd({ ns: conn.ns, sessionId, userId, ...(metadata ? { metadata } : {}) })
|
|
242
|
+
}
|
|
243
|
+
// redis roster 동기화 (ADR-177) — **채널별** 접속자 목록을 redis HASH 에 add(멀티 허브 정합). best-effort.
|
|
244
|
+
if (this._wsRoster && chans.length > 0) {
|
|
245
|
+
const member = { userId, ...(metadata ? { metadata } : {}) }
|
|
246
|
+
for (const ch of chans) {
|
|
247
|
+
this._wsRoster.add(ch, sessionId, member).catch((err) =>
|
|
248
|
+
this._log?.warn?.({ err, channel: ch, app: this._appName }, 'ws-roster add failed'),
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return this
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 채널 broadcast — 로컬 ns 소켓에 즉시 전달 + (hub 연결 시) 클러스터 fan-out.
|
|
257
|
+
*
|
|
258
|
+
* @param {{ ns: string, channel: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }} args
|
|
259
|
+
* @returns {void}
|
|
260
|
+
* @throws {Error} message.type(string) 누락 시 — 호출부 입력 오류를 silent drop 하지 않고 즉시 알린다(L6).
|
|
261
|
+
*/
|
|
262
|
+
broadcast({ ns, channel, message, exceptSessionIds }) {
|
|
263
|
+
// 입력 검증을 한곳에서(L6) — 로컬은 받고 hub 는 message.type 없이 전송하던 비대칭을 제거.
|
|
264
|
+
if (!message || typeof message.type !== 'string') {
|
|
265
|
+
throw new Error('MegaApp.broadcast: message.type (string) is required')
|
|
266
|
+
}
|
|
267
|
+
this._deliverBroadcast({ ns, channel, message, exceptSessionIds })
|
|
268
|
+
if (this._hubLink?.isRegistered) {
|
|
269
|
+
// L-7: 빈 배열도 truthy 라 `exceptSessionIds: []` 가 wire 로 새던 비대칭 제거 — 비어 있으면 생략.
|
|
270
|
+
const hasExcept = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0
|
|
271
|
+
try {
|
|
272
|
+
this._hubLink.broadcast({ ns, channel, message, ...(hasExcept ? { exceptSessionIds } : {}) })
|
|
273
|
+
} catch (err) {
|
|
274
|
+
// 로컬 전달은 이미 성공. 클러스터 fan-out 은 best-effort(at-most-once, ADR-033)이며 소켓이
|
|
275
|
+
// 닫히는 중이면 비치명적 — 재연결 시 presence 가 재동기화된다. warn 후 호출자 보호.
|
|
276
|
+
const log = /** @type {any} */ (this._log)
|
|
277
|
+
log?.warn?.({ err, ns, channel, app: this._appName }, 'app.broadcast hub fan-out failed (local delivered)')
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// NATS 클러스터 fan-out (ADR-176, boot 자동배선). 로컬은 위에서 전달했고, 다른 인스턴스는 구독으로
|
|
281
|
+
// 받아 각자 전달한다(echo 는 instanceId 로 스킵). publish 실패는 best-effort — local 은 이미 성공.
|
|
282
|
+
if (this._wsCluster) {
|
|
283
|
+
this._wsCluster.publishBroadcast({ ns, channel, message, exceptSessionIds }).catch((err) => {
|
|
284
|
+
const log = /** @type {any} */ (this._log)
|
|
285
|
+
log?.warn?.({ err, ns, channel, app: this._appName }, 'app.broadcast nats fan-out failed (local delivered)')
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 특정 사용자에게 직접 전송 (directToUser, ADR-035) — 로컬에서 그 userId 로 매핑된 연결에만 전달
|
|
292
|
+
* (H-latent guard) + (hub 연결 시) 클러스터 fan-out(다른 bridge 의 같은 userId 세션까지).
|
|
293
|
+
*
|
|
294
|
+
* {@link MegaWsPresence#joinSession} 으로 매핑된 연결만 대상이다 — 매핑 없는 userId 면 로컬 no-op.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} userId - 대상 사용자.
|
|
297
|
+
* @param {{ type: string, payload?: Object }} message - 내부 envelope `{ type, payload }`.
|
|
298
|
+
* @returns {void}
|
|
299
|
+
* @throws {Error} userId/message.type 누락 시(broadcast 와 동일한 입력 보호, L6).
|
|
300
|
+
*/
|
|
301
|
+
directToUser(userId, message) {
|
|
302
|
+
if (typeof userId !== 'string' || userId.length === 0) {
|
|
303
|
+
throw new Error('MegaApp.directToUser: userId (non-empty string) is required')
|
|
304
|
+
}
|
|
305
|
+
if (!message || typeof message.type !== 'string') {
|
|
306
|
+
throw new Error('MegaApp.directToUser: message.type (string) is required')
|
|
307
|
+
}
|
|
308
|
+
this._deliverDirect({ userId, message })
|
|
309
|
+
if (this._hubLink?.isRegistered) {
|
|
310
|
+
try {
|
|
311
|
+
this._hubLink.direct({ userId, message })
|
|
312
|
+
} catch (err) {
|
|
313
|
+
// 로컬 전달은 이미 성공. 클러스터 fan-out 은 best-effort(at-most-once, ADR-033). warn 후 보호.
|
|
314
|
+
const log = /** @type {any} */ (this._log)
|
|
315
|
+
log?.warn?.({ err, userId, app: this._appName }, 'app.directToUser hub fan-out failed (local delivered)')
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// NATS 클러스터 direct (ADR-176) — 다른 인스턴스의 같은 userId 세션까지. echo 는 instanceId 로 스킵.
|
|
319
|
+
if (this._wsCluster) {
|
|
320
|
+
this._wsCluster.publishDirect(userId, message).catch((err) => {
|
|
321
|
+
const log = /** @type {any} */ (this._log)
|
|
322
|
+
log?.warn?.({ err, userId, app: this._appName }, 'app.directToUser nats fan-out failed (local delivered)')
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* 세션 presence 메타데이터 갱신 (METADATA, ADR-059) — 로컬 conn 에 저장 + (hub 연결 시) 전파.
|
|
329
|
+
*
|
|
330
|
+
* 로컬 conn 의 `metadata` 를 갱신해 두면 이후 재연결 시 {@link MegaWsPresence#_resyncPresence} 가 최신
|
|
331
|
+
* 메타까지 복구한다(M-1). 매핑 없는 sessionId 면 no-op(로컬 저장 없이 hub 전파만은 하지 않음 —
|
|
332
|
+
* 재연결 보존 대상이 없으므로). broadcast/directToUser 와 같은 best-effort fan-out.
|
|
333
|
+
*
|
|
334
|
+
* @param {string} sessionId - 대상 세션(joinSession 으로 매핑된 것).
|
|
335
|
+
* @param {Object} metadata - 갱신할 메타데이터(명시 필드만).
|
|
336
|
+
* @returns {this}
|
|
337
|
+
* @throws {Error} sessionId/metadata 누락 시(입력 보호, L6 와 동일 원칙).
|
|
338
|
+
*/
|
|
339
|
+
updateMetadata(sessionId, metadata) {
|
|
340
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
341
|
+
throw new Error('MegaApp.updateMetadata: sessionId (non-empty string) is required')
|
|
342
|
+
}
|
|
343
|
+
if (!metadata || typeof metadata !== 'object') {
|
|
344
|
+
throw new Error('MegaApp.updateMetadata: metadata (object) is required')
|
|
345
|
+
}
|
|
346
|
+
const conn = this._sessionConns.get(sessionId)
|
|
347
|
+
if (!conn) {
|
|
348
|
+
// 매핑 없는 세션 — 재연결로 보존할 로컬 대상이 없으므로 no-op(다른 bridge 세션은 그쪽이 관리).
|
|
349
|
+
this._log?.debug?.({ app: this._appName, sessionId }, 'ws.updateMetadata — no local session (no-op)')
|
|
350
|
+
return this
|
|
351
|
+
}
|
|
352
|
+
conn.metadata = metadata // 재연결 재동기화가 최신 메타를 복구하도록 저장(M-1).
|
|
353
|
+
if (this._hubLink?.isRegistered) {
|
|
354
|
+
try {
|
|
355
|
+
this._hubLink.updateMetadata({ sessionId, metadata })
|
|
356
|
+
} catch (err) {
|
|
357
|
+
// hub 전파 실패는 비치명적 — 로컬 저장은 됐고 재연결 시 _resyncPresence 가 복구.
|
|
358
|
+
const log = /** @type {any} */ (this._log)
|
|
359
|
+
log?.warn?.({ err, sessionId, app: this._appName }, 'app.updateMetadata hub propagate failed (local stored)')
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return this
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* NATS 클러스터 fan-out/roster 를 이 앱에 배선한다 (ADR-176). boot 가 `wsCluster` config 를 보고
|
|
367
|
+
* 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
|
|
368
|
+
* @param {import('./ws-cluster.js').MegaWsCluster|null} cluster
|
|
369
|
+
* @returns {this}
|
|
370
|
+
*/
|
|
371
|
+
setWsCluster(cluster) {
|
|
372
|
+
this._wsCluster = cluster
|
|
373
|
+
return this
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* 해당 ns(WS 채널 경로)의 **클러스터 전역 접속자 목록**을 반환한다 (ADR-176). `wsCluster` 자동배선 +
|
|
378
|
+
* `joinSession`/disconnect 훅으로 프레임워크가 동기화하므로 개발자는 roster 코드를 짜지 않고 읽기만 한다.
|
|
379
|
+
* wsCluster 미배선(또는 roster:'none')이면 로컬 멤버만 반환한다.
|
|
380
|
+
*
|
|
381
|
+
* @param {string} ns - WS namespace(채널 경로, 예: '/ws/chat').
|
|
382
|
+
* @returns {Array<{ sessionId: string, userId: string, metadata?: Object }>}
|
|
383
|
+
*/
|
|
384
|
+
roster(ns) {
|
|
385
|
+
if (this._wsCluster) return this._wsCluster.roster(ns) // NATS: 이미 cluster-wide(roster 동기화 포함).
|
|
386
|
+
// ns 기준 로컬 명단(동기 API). **채널 기준 redis roster(ADR-177)** 는 `ctx.presence.list()`(async)가
|
|
387
|
+
// 다룬다 — 이 메서드는 NATS/로컬용 ns 기준 동기 API 로 유지(하위호환).
|
|
388
|
+
/** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
|
|
389
|
+
const out = []
|
|
390
|
+
for (const [sessionId, conn] of this._sessionConns) {
|
|
391
|
+
if (conn.ns !== ns || !conn.isOpen) continue
|
|
392
|
+
out.push({ sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
|
|
393
|
+
}
|
|
394
|
+
return out
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 채널별 redis roster(ADR-177)를 이 앱에 배선한다. boot 가 `bridgeHub.roster.driver==='redis'` 일 때
|
|
399
|
+
* 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
|
|
400
|
+
* @param {import('./ws-roster.js').MegaWsRedisRoster|null} roster
|
|
401
|
+
* @returns {this}
|
|
402
|
+
*/
|
|
403
|
+
setWsRoster(roster) {
|
|
404
|
+
this._wsRoster = roster
|
|
405
|
+
return this
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* 주어진 **채널들**의 cluster-wide 접속자 목록 — redis roster(원격 포함) + 로컬 세션을 병합한다(ADR-177).
|
|
410
|
+
* 로컬을 항상 포함해 join 직후 redis HSET 반영 전에도 본인이 빠지지 않게 한다. `ctx.presence.list()` 의
|
|
411
|
+
* redis 경로가 호출(async). roster 미배선이면 로컬 채널 멤버만.
|
|
412
|
+
* @param {string[]} channels
|
|
413
|
+
* @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
|
|
414
|
+
*/
|
|
415
|
+
async presenceList(channels) {
|
|
416
|
+
const want = new Set(Array.isArray(channels) ? channels : [])
|
|
417
|
+
/** @type {Map<string, { sessionId: string, userId: string, metadata?: Object }>} */
|
|
418
|
+
const out = new Map()
|
|
419
|
+
// 로컬 세션(이 워커) — 요청 채널에 가입한 것. 항상 fresh(본인 포함).
|
|
420
|
+
for (const [sessionId, conn] of this._sessionConns) {
|
|
421
|
+
if (!conn.isOpen || !conn.channels) continue
|
|
422
|
+
let inCh = false
|
|
423
|
+
for (const ch of conn.channels) {
|
|
424
|
+
if (want.has(ch)) {
|
|
425
|
+
inCh = true
|
|
426
|
+
break
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (inCh) out.set(sessionId, { sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
|
|
430
|
+
}
|
|
431
|
+
// redis(cluster-wide) — 다른 워커/허브의 세션까지.
|
|
432
|
+
if (this._wsRoster) {
|
|
433
|
+
// 여러 채널 redis 조회를 **병렬화**(Promise.all) — onConnect 핫패스의 직렬 라운드트립 누적 제거(Med).
|
|
434
|
+
const lists = await Promise.all([...want].map((ch) => this._wsRoster.list(ch)))
|
|
435
|
+
for (const list of lists) for (const m of list) if (!out.has(m.sessionId)) out.set(m.sessionId, m)
|
|
436
|
+
}
|
|
437
|
+
return [...out.values()]
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 이 워커의 로컬 멤버 목록 — redis roster heartbeat 갱신 대상(ADR-177). joinSession 으로 매핑된
|
|
442
|
+
* (채널 × 세션)마다 1개. MegaWsRedisRoster 가 주기적으로 이 목록의 expiresAt 을 갱신한다.
|
|
443
|
+
* @returns {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>}
|
|
444
|
+
*/
|
|
445
|
+
localRosterMembers() {
|
|
446
|
+
/** @type {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>} */
|
|
447
|
+
const out = []
|
|
448
|
+
for (const [sessionId, conn] of this._sessionConns) {
|
|
449
|
+
if (!conn.isOpen || !conn.channels) continue
|
|
450
|
+
const member = { userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) }
|
|
451
|
+
for (const ch of conn.channels) out.push({ channel: ch, sessionId, member })
|
|
452
|
+
}
|
|
453
|
+
return out
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* broadcast payload 를 로컬 ns 소켓에 전달한다. message 는 `{ type, payload }` 내부 envelope.
|
|
458
|
+
*
|
|
459
|
+
* `exceptSessionIds` 에 든 sessionId 로 매핑된 연결은 제외한다(ADR-098). 세션 매핑이 없는
|
|
460
|
+
* 연결(zero-config·미JOIN)은 sessionId 가 없어 제외 대상에 걸리지 않으므로 그대로 받는다.
|
|
461
|
+
*
|
|
462
|
+
* @param {{ ns: string, channel?: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }} payload
|
|
463
|
+
* @returns {void}
|
|
464
|
+
*/
|
|
465
|
+
_deliverBroadcast({ ns, message, exceptSessionIds }) {
|
|
466
|
+
const set = this._wsConns.get(ns)
|
|
467
|
+
if (!set || !message || typeof message.type !== 'string') return
|
|
468
|
+
const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
|
|
469
|
+
for (const conn of set) {
|
|
470
|
+
if (!conn.isOpen) continue
|
|
471
|
+
if (except && conn.sessionId !== undefined && except.has(conn.sessionId)) continue
|
|
472
|
+
conn.send({ type: message.type, ns, payload: message.payload })
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* direct payload 를 **해당 userId 로 매핑된 로컬 연결에만** 전달한다 (H-latent guard).
|
|
478
|
+
*
|
|
479
|
+
* 초기에는 매핑이 없어 모든 연결에 flood 됐다(cross-user 누출). {@link MegaWsPresence#joinSession}
|
|
480
|
+
* 으로 만든 `userId → 연결` 매핑을 통해 대상 사용자에게만 보낸다. 매핑이 없는 userId 면 no-op
|
|
481
|
+
* (다른 사용자에게 새지 않음).
|
|
482
|
+
*
|
|
483
|
+
* @param {{ userId: string, message: { type: string, payload?: Object } }} payload
|
|
484
|
+
* @returns {void}
|
|
485
|
+
*/
|
|
486
|
+
_deliverDirect({ userId, message }) {
|
|
487
|
+
if (!message || typeof message.type !== 'string') return
|
|
488
|
+
if (typeof userId !== 'string' || userId.length === 0) return
|
|
489
|
+
const set = this._userConns.get(userId)
|
|
490
|
+
if (!set) return
|
|
491
|
+
for (const conn of set) {
|
|
492
|
+
if (conn.isOpen) conn.send({ type: message.type, payload: message.payload })
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* hub 가 라우팅한 DISCONNECT(admin-kick/재배치, ADR-097)를 처리한다 — sessionId 로 매핑된 로컬
|
|
498
|
+
* 소켓을 닫는다. `requeue` 로 두 시맨틱이 갈린다:
|
|
499
|
+
*
|
|
500
|
+
* - `requeue: false`(기본) = **kick** — RFC 6455 `1008`(policy violation, hub Bearer 거부와 동일
|
|
501
|
+
* 코드). "돌아오지 마라" — 클라이언트는 재연결하지 않아야 한다.
|
|
502
|
+
* - `requeue: true` = **우아한 재배치** — `4503`({@link import('./ws-upgrade.js').CLOSE_CODE_REQUEUE},
|
|
503
|
+
* bridge↔hub drain 과 동일 규약). "세션 유지한 채 즉시 재연결하라" — HTTP 세션은 그대로이므로
|
|
504
|
+
* 재연결이 LB 를 거쳐 다른 워커에 닿아도 `before` 인증이 세션 쿠키로 자연 통과한다
|
|
505
|
+
* (transparent re-route — 워커 drain·리밸런싱용).
|
|
506
|
+
*
|
|
507
|
+
* reason 은 WS close frame 한도(123 bytes)에 맞춰 자른다.
|
|
508
|
+
*
|
|
509
|
+
* @param {{ sessionId: string, reason?: string, requeue?: boolean }} payload
|
|
510
|
+
* @returns {void}
|
|
511
|
+
*/
|
|
512
|
+
_handleHubDisconnect({ sessionId, reason, requeue } = /** @type {any} */ ({})) {
|
|
513
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return
|
|
514
|
+
const conn = this._sessionConns.get(sessionId)
|
|
515
|
+
this._log?.debug?.(
|
|
516
|
+
{ sessionId, reason, requeue: requeue === true, found: Boolean(conn), app: this._appName },
|
|
517
|
+
'ws.hub disconnect received',
|
|
518
|
+
)
|
|
519
|
+
if (!conn) return // 이미 닫혔거나 다른 인스턴스로 옮겨간 세션 — hub presence 정리는 LEAVE 가 처리.
|
|
520
|
+
if (conn.isOpen) {
|
|
521
|
+
if (requeue === true) {
|
|
522
|
+
const closeReason = typeof reason === 'string' ? reason.slice(0, 123) : 'requeued — reconnect'
|
|
523
|
+
conn.close(CLOSE_CODE_REQUEUE, closeReason)
|
|
524
|
+
} else {
|
|
525
|
+
const closeReason = typeof reason === 'string' ? reason.slice(0, 123) : 'disconnected by hub'
|
|
526
|
+
conn.close(1008, closeReason)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* 로컬 WS 연결 등록 (driveWsConnection 이 호출 — framework-internal). hub broadcast 의 local 전달 대상.
|
|
533
|
+
* @param {import('./ws-upgrade.js').MegaWsConnection} conn
|
|
534
|
+
* @returns {void}
|
|
535
|
+
*/
|
|
536
|
+
_trackWsConn(conn) {
|
|
537
|
+
if (!conn.ns) return
|
|
538
|
+
let set = this._wsConns.get(conn.ns)
|
|
539
|
+
if (!set) {
|
|
540
|
+
set = new Set()
|
|
541
|
+
this._wsConns.set(conn.ns, set)
|
|
542
|
+
}
|
|
543
|
+
set.add(conn)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* 로컬 WS 연결 해제 (close 시 driveWsConnection 이 호출 — framework-internal).
|
|
548
|
+
* @param {import('./ws-upgrade.js').MegaWsConnection} conn
|
|
549
|
+
* @returns {void}
|
|
550
|
+
*/
|
|
551
|
+
_untrackWsConn(conn) {
|
|
552
|
+
const set = conn.ns ? this._wsConns.get(conn.ns) : undefined
|
|
553
|
+
if (set) {
|
|
554
|
+
set.delete(conn)
|
|
555
|
+
if (set.size === 0) this._wsConns.delete(conn.ns)
|
|
556
|
+
}
|
|
557
|
+
// 세션·유저 매핑 정리 + hub presence LEAVE (joinSession 으로 매핑된 연결만).
|
|
558
|
+
if (conn.userId !== undefined) {
|
|
559
|
+
const uset = this._userConns.get(conn.userId)
|
|
560
|
+
if (uset) {
|
|
561
|
+
uset.delete(conn)
|
|
562
|
+
if (uset.size === 0) this._userConns.delete(conn.userId)
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (conn.sessionId !== undefined) {
|
|
566
|
+
// 같은 sessionId 가 다른(새) 연결로 교체된 경우엔 이 연결만 지운다(오래된 연결의 close 가
|
|
567
|
+
// 새 매핑을 지우지 않게 동일성 확인).
|
|
568
|
+
if (this._sessionConns.get(conn.sessionId) === conn) this._sessionConns.delete(conn.sessionId)
|
|
569
|
+
if (this._hubLink?.isRegistered) {
|
|
570
|
+
try {
|
|
571
|
+
this._hubLink.leave(conn.sessionId)
|
|
572
|
+
} catch (err) {
|
|
573
|
+
// 소켓이 닫히는 중이면 LEAVE 송신 실패 — 비치명적. hub 의 bridge-gone 정리가 보강한다.
|
|
574
|
+
this._log?.debug?.({ err, sessionId: conn.sessionId, app: this._appName }, 'ws.leave send failed')
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// NATS roster 제거 (ADR-176) — disconnect 시 클러스터 접속자 목록에서 자동 제거(개발자 코드 불요).
|
|
578
|
+
this._wsCluster?.rosterRemove(conn.sessionId)
|
|
579
|
+
// redis roster 제거 (ADR-177) — 이 세션이 가입한 모든 채널에서 제거. best-effort.
|
|
580
|
+
if (this._wsRoster && conn.channels) {
|
|
581
|
+
for (const ch of conn.channels) {
|
|
582
|
+
this._wsRoster.remove(ch, /** @type {string} */ (conn.sessionId)).catch((err) =>
|
|
583
|
+
this._log?.warn?.({ err, channel: ch, app: this._appName }, 'ws-roster remove failed'),
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* presence 전체 정리 — hub link → NATS cluster → redis roster → 연결 인덱스 순.
|
|
593
|
+
* 각 자원의 shutdown hook 도 짝맞춰 해제한다(닫힌 앱의 hook 잔존·중복 정리 방지).
|
|
594
|
+
* MegaApp.close 가 WS 클라이언트 종료(1001) **전에** 호출한다.
|
|
595
|
+
* @returns {Promise<void>}
|
|
596
|
+
*/
|
|
597
|
+
async close() {
|
|
598
|
+
// hub link 먼저 끊는다 (더 이상 fan-out 수신 불필요). shutdown hook 도 함께 떼어 누수 방지(L1).
|
|
599
|
+
if (this._hubLink) {
|
|
600
|
+
this._hubLink.close()
|
|
601
|
+
this._hubLink = null
|
|
602
|
+
this._hubBridgeId = null
|
|
603
|
+
MegaShutdown.unregister(`mega-hublink:${this._appName}`)
|
|
604
|
+
}
|
|
605
|
+
// NATS 클러스터 정리(ADR-176) — 구독·heartbeat/sweep 타이머·roster 멤버 해제. boot 의 shutdown hook 과
|
|
606
|
+
// **양쪽**에서 정리해야 시그널을 안 거치는 종료(server.close()/프로그램적 재시작/멀티앱 부분 종료)에서도
|
|
607
|
+
// 누수가 없다(_hubLink/_wsRoster 와 동일 대칭, H1). stop() 은 _isStarted 가드로 멱등이라 중복 안전.
|
|
608
|
+
if (this._wsCluster) {
|
|
609
|
+
await this._wsCluster.stop().catch((err) => this._log?.warn?.({ err, app: this._appName }, 'ws-cluster stop failed'))
|
|
610
|
+
this._wsCluster = null
|
|
611
|
+
MegaShutdown.unregister(`mega-ws-cluster:${this._appName}`)
|
|
612
|
+
}
|
|
613
|
+
// redis roster 정리(ADR-177) — heartbeat 중지 + 로컬 멤버 즉시 제거. _sessionConns 를 비우기 **전에**
|
|
614
|
+
// 호출해야 stop() 이 localRosterMembers 로 자기 멤버를 redis 에서 뺄 수 있다.
|
|
615
|
+
if (this._wsRoster) {
|
|
616
|
+
await this._wsRoster.stop().catch((err) => this._log?.warn?.({ err, app: this._appName }, 'ws-roster stop failed'))
|
|
617
|
+
this._wsRoster = null
|
|
618
|
+
MegaShutdown.unregister(`mega-wsroster:${this._appName}`)
|
|
619
|
+
}
|
|
620
|
+
this._wsConns.clear()
|
|
621
|
+
this._userConns.clear()
|
|
622
|
+
this._sessionConns.clear()
|
|
623
|
+
}
|
|
624
|
+
}
|
package/src/core/ws-roster.js
CHANGED
|
@@ -156,8 +156,11 @@ export class MegaWsRedisRoster {
|
|
|
156
156
|
if (this._hbTimer) clearInterval(this._hbTimer)
|
|
157
157
|
this._hbTimer = null
|
|
158
158
|
// graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리).
|
|
159
|
+
// 실패해도 종료는 계속(lazy 만료가 정리) — 단 명단에 TTL 까지 남으므로 묵히지 않고 알린다.
|
|
159
160
|
for (const { channel, sessionId } of this._getLocalMembers()) {
|
|
160
|
-
await this.remove(channel, sessionId).catch(() =>
|
|
161
|
+
await this.remove(channel, sessionId).catch((err) =>
|
|
162
|
+
this._log?.warn?.({ err, channel, sessionId }, 'ws-roster graceful remove failed (stale until TTL)'),
|
|
163
|
+
)
|
|
161
164
|
}
|
|
162
165
|
}
|
|
163
166
|
}
|