mega-framework 0.1.6 → 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 +1 -1
- package/sample/crud/.env.example +1 -1
- 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 +3 -2
- package/sample/crud/package.json +1 -1
- 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 +353 -100
- package/src/core/ajv-mapper.js +27 -2
- package/src/core/boot.js +464 -245
- package/src/core/cluster-metrics.js +13 -4
- 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 +201 -463
- package/src/core/mega-cluster.js +4 -1
- 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/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-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 +114 -39
- 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
package/src/core/mega-app.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import Fastify from 'fastify'
|
|
3
3
|
import { WebSocketServer } from 'ws'
|
|
4
|
-
import { wrapEnvelope, REPLY_START_SYMBOL } from './envelope.js'
|
|
4
|
+
import { wrapEnvelope, synthesizeEnvelopeResponseSchema, REPLY_START_SYMBOL } from './envelope.js'
|
|
5
5
|
import { buildErrorHandler } from './error-mapper.js'
|
|
6
6
|
import { Router } from './router.js'
|
|
7
7
|
import { driveWsConnection, createPlainCodec, createAspCodec, rejectUpgrade } from './ws-upgrade.js'
|
|
8
|
+
import { negotiateWsProtocol, WS_SUBPROTOCOL_PATTERN, WS_PROTOCOL_VERSION } from './ws-message.js'
|
|
8
9
|
import { buildPerMessageDeflate } from './ws-compression.js'
|
|
10
|
+
import { MegaWsPresence } from './ws-presence.js'
|
|
9
11
|
import { MegaAspTerminator } from '../lib/asp/ws-terminator.js'
|
|
10
|
-
import { MegaHubLink } from './hub-link.js'
|
|
11
|
-
import { HUB_MESSAGE_TYPES } from '../lib/hub-protocol.js'
|
|
12
12
|
import * as MegaHealth from '../lib/mega-health.js'
|
|
13
13
|
import { MegaShutdown } from '../lib/mega-shutdown.js'
|
|
14
14
|
import { buildAdapterAccessors, getHttpCtx } from './ctx-builder.js'
|
|
15
|
+
import { wrapPreHandler, composeTransform, composeAfter } from './pipeline.js'
|
|
15
16
|
import { registerSecurityPlugins } from './security.js'
|
|
16
17
|
import { registerMultipart } from './multipart.js'
|
|
17
18
|
import { registerSession } from './session.js'
|
|
@@ -33,7 +34,7 @@ const HTTP_SPAN_SYMBOL = Symbol('mega.httpSpan')
|
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* 브라우저↔Bridge WS 프레임 최대 크기 디폴트 (bytes, L-3 / ADR-099).
|
|
36
|
-
* 1 MiB — Hub(`DEFAULT_MAX_PAYLOAD_BYTES`, src/
|
|
37
|
+
* 1 MiB — Hub(`DEFAULT_MAX_PAYLOAD_BYTES`, src/lib/ws-hub.js)와 대칭. 초과 프레임은 ws 가 1009 close.
|
|
37
38
|
* core→cli 역방향 import 를 피하려 값을 여기 별도 정의(두 곳 모두 1 MiB 로 동일).
|
|
38
39
|
*/
|
|
39
40
|
export const DEFAULT_WS_MAX_PAYLOAD_BYTES = 1_048_576
|
|
@@ -116,8 +117,10 @@ export class MegaApp {
|
|
|
116
117
|
* (디폴트 OFF). `cacheControl` = raw `Cache-Control` 헤더 문자열(예 `'public, max-age=3600'`). `dotfiles`
|
|
117
118
|
* 디폴트 false(`.git`/`.env` 차단). `enabled:true` 인데 `dir` 누락/미존재 시 프로덕션 부팅 throw / dev warn+skip.
|
|
118
119
|
* prefix 가 `health.metricsPath` 와 같으면 부팅 throw(ADR-072).
|
|
119
|
-
* @param {{ enabled?: boolean, paths?: { live?: string, ready?: string }, exposeMetrics?: boolean, metricsPath?: string, metricsAllowList?: string[] }} [opts.health] -
|
|
120
|
+
* @param {{ enabled?: boolean, paths?: { live?: string, ready?: string }, exposeCheckDetails?: boolean, exposeMetrics?: boolean, metricsPath?: string, metricsAllowList?: string[] }} [opts.health] -
|
|
120
121
|
* 운영 관측성 config(Global-only, ADR-072/131). bootApp 이 global `health` 블록을 주입한다.
|
|
122
|
+
* `exposeCheckDetails:true` 면 readiness 응답에 체크별 전체 필드(error 메시지 등)를 노출 — 기본 false
|
|
123
|
+
* (체크별 `{ ok }` 만, ADR-186).
|
|
121
124
|
* `exposeMetrics:true` 면 `metricsPath`(디폴트 `/metrics`)에 Prometheus `/metrics` 라우트를 등록(보안 면제).
|
|
122
125
|
* `metricsAllowList` = 접근 허용 IP/CIDR(빈 배열이면 메인 포트 전체 노출, ADR-131). metricsPath 가 health
|
|
123
126
|
* 경로와 충돌하면 부팅 throw. SDK 초기화(MegaMetrics.init)는 bootApp/prepareRuntime 이 담당.
|
|
@@ -130,6 +133,13 @@ export class MegaApp {
|
|
|
130
133
|
* — 라우트 핸들러와 동일한 canonical 시그니처(ADR-074/134). `ctx` 는 요청당 1회 만들어 핸들러와
|
|
131
134
|
* 공유하므로, 미들웨어가 `ctx` 에 심은 값을 핸들러가 본다. 기존 `(req, reply)` 미들웨어는 3번째
|
|
132
135
|
* 인자를 무시하므로 하위 호환.
|
|
136
|
+
* @param {Function[]} [opts.globalTransforms] - 앱/전역 레벨 응답 변환(ADR-021 체인의 "앱 → 전역"
|
|
137
|
+
* 구간, ADR-194). 계약: `async (req, reply, payload) => payload`. 라우트(+파일) transform 뒤·
|
|
138
|
+
* 자동 envelope wrap 직전에 배열 순서대로 실행(주입자가 앱 항목 먼저·전역 항목 뒤로 배열).
|
|
139
|
+
* health/metrics 등 `config.skipGlobalLifecycle` 라우트는 제외.
|
|
140
|
+
* @param {Function[]} [opts.globalAfters] - 앱/전역 레벨 응답 후 side-effect(ADR-194). 계약:
|
|
141
|
+
* `async (req, reply) => void` — 응답 전송 후(onResponse), throw 는 warn 로그 후 무시(ADR-091).
|
|
142
|
+
* 라우트(+파일) after 뒤에 실행. `config.skipGlobalLifecycle` 라우트는 제외.
|
|
133
143
|
*/
|
|
134
144
|
constructor(opts) {
|
|
135
145
|
if (!opts || typeof opts.name !== 'string' || opts.name.length === 0) {
|
|
@@ -162,6 +172,22 @@ export class MegaApp {
|
|
|
162
172
|
/** @type {string|null} OpenAPI 옵트인 시 swagger-ui 경로(ADR-140). envelope onRoute 가 이 경로를 제외. */
|
|
163
173
|
this._openapiPath = null
|
|
164
174
|
|
|
175
|
+
// 앱/전역 레벨 transform·after 슬롯 (ADR-021 체인의 "앱 → 전역" 구간, ADR-194). orchestrator/
|
|
176
|
+
// 플러그인이 주입하며 배열 순서가 곧 실행 순서(앱 항목 먼저·전역 항목 뒤를 주입자가 표현).
|
|
177
|
+
// onRoute 가 라우트별 체인에 합성한다 — transform 은 envelope wrap 직전, after 는 체인 끝.
|
|
178
|
+
for (const [key, list] of /** @type {Array<[string, unknown]>} */ ([
|
|
179
|
+
['globalTransforms', opts.globalTransforms],
|
|
180
|
+
['globalAfters', opts.globalAfters],
|
|
181
|
+
])) {
|
|
182
|
+
if (list !== undefined && (!Array.isArray(list) || list.some((f) => typeof f !== 'function'))) {
|
|
183
|
+
throw new TypeError(`MegaApp('${this.name}'): ${key} must be an array of functions.`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** @type {Function[]} */
|
|
187
|
+
this._globalTransforms = Array.isArray(opts.globalTransforms) ? [...opts.globalTransforms] : []
|
|
188
|
+
/** @type {Function[]} */
|
|
189
|
+
this._globalAfters = Array.isArray(opts.globalAfters) ? [...opts.globalAfters] : []
|
|
190
|
+
|
|
165
191
|
// WS ASP 옵트인 정규화. masterSecret 없으면 null = 평문 WS.
|
|
166
192
|
this._wsAsp = MegaApp._normalizeWsAsp(opts.asp)
|
|
167
193
|
// 브라우저↔Bridge WS 압축 (ADR-078). enabled=false → false (압축 OFF).
|
|
@@ -180,33 +206,26 @@ export class MegaApp {
|
|
|
180
206
|
this._router = null
|
|
181
207
|
/** @type {WebSocketServer|null} noServer 모드 WS 핸드셰이커 (lazy). */
|
|
182
208
|
this._wss = null
|
|
183
|
-
/** @type {MegaHubLink|null} hub 연결 (scaffold 권장). */
|
|
184
|
-
this._hubLink = null
|
|
185
|
-
/** @type {import('./ws-cluster.js').MegaWsCluster|null} NATS 기반 클러스터 fan-out/roster (ADR-176, boot 자동배선). */
|
|
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
|
|
190
|
-
/** ns(=ws path) → 활성 로컬 연결 집합 (hub broadcast 의 local fan-out 용). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
191
|
-
this._wsConns = new Map()
|
|
192
|
-
/** userId → 활성 로컬 연결 집합 (DIRECT 타겟팅, H-latent guard). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
193
|
-
this._userConns = new Map()
|
|
194
|
-
/** sessionId → 활성 로컬 연결 1개 (세션단위 JOIN/LEAVE). @type {Map<string, import('./ws-upgrade.js').MegaWsConnection>} */
|
|
195
|
-
this._sessionConns = new Map()
|
|
196
|
-
/** @type {string[]} connectHub 으로 구독한 채널. */
|
|
197
|
-
this._hubChannels = []
|
|
198
|
-
/** connectHub 의 bridgeId (재연결 재구독·세션 JOIN 에 재사용). @type {string|null} */
|
|
199
|
-
this._hubBridgeId = null
|
|
200
209
|
|
|
201
210
|
this.fastify = Fastify({
|
|
202
211
|
// pino 로거(ADR-023/141) — bootApp 이 global.logger 로 만든 **인스턴스**를 주입(opts.logger). Fastify v5 는
|
|
203
212
|
// 인스턴스를 `loggerInstance` 로 받는다(`logger` 는 config 객체/bool 전용). 미주입이면 logger:false(무로그,
|
|
204
213
|
// 기존 동작·테스트 호환). 인스턴스를 받으면 Fastify 가 요청별 child(reqId 바인딩)를 만든다.
|
|
205
214
|
...(opts.logger ? { loggerInstance: opts.logger } : { logger: false }),
|
|
215
|
+
// AJV 위반 전수 수집 (ADR-193) — 디폴트는 첫 위반 1개만 보고해 envelope details 배열(ADR-075)
|
|
216
|
+
// 표준·WS 검증(allErrors:true, ADR-096)과 비대칭이었다. customOptions 는 @fastify/ajv-compiler 가
|
|
217
|
+
// 디폴트(coerceTypes/useDefaults/removeAdditional 등) **위에 merge** 하므로(소스 확인:
|
|
218
|
+
// lib/validator-compiler.js Object.assign) allErrors 만 바꾼다. DoS 방어는 이중: 입력은 Fastify
|
|
219
|
+
// bodyLimit(기본 1 MiB)이, 응답·로그는 ajv-mapper 의 details cap(MAX_VALIDATION_DETAILS)이 bound.
|
|
220
|
+
ajv: { customOptions: { allErrors: true } },
|
|
206
221
|
// request id 자동 부여는 Fastify v5 기본 제공 (meta.request_id 용)
|
|
207
222
|
...opts.fastifyOptions,
|
|
208
223
|
})
|
|
209
224
|
|
|
225
|
+
// WS presence/hub 협력자 — 연결 인덱스 3종·hub link·cluster/roster 동기화를 전담(ws-presence.js).
|
|
226
|
+
// fastify 생성 직후 만들어 WS 경로 로그가 같은 pino 인스턴스를 쓴다.
|
|
227
|
+
this._presence = new MegaWsPresence({ appName: this.name, log: this.fastify.log })
|
|
228
|
+
|
|
210
229
|
// 1) 응답 시작 시각 기록 (meta.took_ms 용, ADR-014)
|
|
211
230
|
this.fastify.addHook('onRequest', async (req, reply) => {
|
|
212
231
|
// REPLY_START_SYMBOL 은 Fastify 타입에 없는 우리 전용 symbol 키 — 부착 시 cast.
|
|
@@ -228,6 +247,9 @@ export class MegaApp {
|
|
|
228
247
|
path: req.url,
|
|
229
248
|
host,
|
|
230
249
|
app: this.name,
|
|
250
|
+
// inbound traceparent/tracestate 를 부모로 복원(W3C trace context, ADR-196) — 게이트웨이/업스트림
|
|
251
|
+
// trace 에 루트 span 이 이어진다. 무효/부재 헤더는 종전대로 새 루트(fail-safe).
|
|
252
|
+
headers: req.headers,
|
|
231
253
|
})
|
|
232
254
|
;(/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL] = handle
|
|
233
255
|
})
|
|
@@ -272,13 +294,34 @@ export class MegaApp {
|
|
|
272
294
|
// 을 기대하므로 `{ok,data,meta}` 로 감싸면 UI 가 깨진다(ADR-140). 옵트인 OFF(_openapiPath=null)면 무영향.
|
|
273
295
|
const url = /** @type {string} */ (routeOptions.url)
|
|
274
296
|
if (this._openapiPath && (url === this._openapiPath || url.startsWith(`${this._openapiPath}/`))) return
|
|
297
|
+
// response schema 는 envelope 모양으로 합성 — raw data 모양 그대로 두면 직렬화기가 envelope 를
|
|
298
|
+
// 그 스키마로 직렬화해 ok/data/meta 가 통째로 사라진다(silent 데이터 소실). 합성하면 사용자는
|
|
299
|
+
// raw data 모양만 선언(ADR-091)하면서 strict 직렬화(ADR-020)·OpenAPI 명세 정합이 함께 성립한다.
|
|
300
|
+
const schema = /** @type {Record<string, any> | undefined} */ (routeOptions.schema)
|
|
301
|
+
if (schema?.response && typeof schema.response === 'object') {
|
|
302
|
+
routeOptions.schema = { ...schema, response: synthesizeEnvelopeResponseSchema(schema.response) }
|
|
303
|
+
}
|
|
275
304
|
const existing = routeOptions.preSerialization
|
|
276
305
|
const chain = existing ? (Array.isArray(existing) ? [...existing] : [existing]) : []
|
|
306
|
+
// 앱/전역 transform·after (ADR-194) — health/metrics 류 인프라 라우트는 제외
|
|
307
|
+
// (config.skipGlobalLifecycle — 프로브 응답 모양은 인프라 계약이라 사용자 전역 변환 대상 아님).
|
|
308
|
+
const skipGlobalLifecycle = /** @type {any} */ (routeOptions.config)?.skipGlobalLifecycle === true
|
|
309
|
+
if (!skipGlobalLifecycle && this._globalTransforms.length > 0) {
|
|
310
|
+
// 라우트(+파일) transform 뒤·envelope wrap 앞 = ADR-021 순서(라우트 → 파일 → 앱/전역 → wrap).
|
|
311
|
+
chain.push(composeTransform(this._globalTransforms))
|
|
312
|
+
}
|
|
277
313
|
// async 어댑터로 감싼다: Fastify 는 done 콜백 없는 preSerialization 훅을
|
|
278
314
|
// "Promise 반환(async)" 으로만 인식한다. wrapEnvelope 는 순수 동기 함수(단위 테스트·
|
|
279
315
|
// 재사용 용이)라 그대로 넣으면 Fastify 가 콜백 스타일로 오인해 done 을 영원히 기다린다.
|
|
280
316
|
chain.push(async (req, reply, payload) => wrapEnvelope(req, reply, payload))
|
|
281
317
|
routeOptions.preSerialization = chain
|
|
318
|
+
if (!skipGlobalLifecycle && this._globalAfters.length > 0) {
|
|
319
|
+
// after: 라우트(+파일) onResponse 뒤에 앱/전역 합성 append (ADR-021 — 라우트 → 파일 → 앱/전역).
|
|
320
|
+
const existingAfter = routeOptions.onResponse
|
|
321
|
+
const afterChain = existingAfter ? (Array.isArray(existingAfter) ? [...existingAfter] : [existingAfter]) : []
|
|
322
|
+
afterChain.push(composeAfter(this._globalAfters, { method: String(routeOptions.method), path: url }))
|
|
323
|
+
routeOptions.onResponse = afterChain
|
|
324
|
+
}
|
|
282
325
|
})
|
|
283
326
|
|
|
284
327
|
// 3) 글로벌 에러 핸들러 (AJV → MegaValidationError, MegaError → envelope, ADR-090).
|
|
@@ -402,7 +445,9 @@ export class MegaApp {
|
|
|
402
445
|
// MegaHealth 통합 — /health (liveness) + /health/ready (readiness).
|
|
403
446
|
// onRoute 훅 등록 이후라 자동 envelope 적용됨. config.skip{Asp,Csrf,RateLimit} 3종이 각 보안 hook 의
|
|
404
447
|
// 면제 신호다(ADR-072 면제 실효, ADR-127): ASP onRequest·CSRF preHandler·rate-limit allowList 가 검사.
|
|
405
|
-
|
|
448
|
+
// skipGlobalLifecycle(ADR-194): health/metrics 응답 모양은 인프라(프로브) 계약 — 사용자
|
|
449
|
+
// 앱/전역 transform·after 의 대상이 아니므로 제외한다(자동 envelope 는 기존대로 적용).
|
|
450
|
+
const HEALTH_EXEMPT = { skipAsp: true, skipCsrf: true, skipRateLimit: true, skipGlobalLifecycle: true }
|
|
406
451
|
const healthCfg = opts.health && typeof opts.health === 'object' ? opts.health : {}
|
|
407
452
|
// 헬스 경로(ADR-072/181) — 설정 가능. 미지정/빈 문자열이면 기본 /health · /health/ready.
|
|
408
453
|
const livePath = typeof healthCfg.paths?.live === 'string' && healthCfg.paths.live.length > 0 ? healthCfg.paths.live : '/health'
|
|
@@ -418,13 +463,24 @@ export class MegaApp {
|
|
|
418
463
|
ts: Date.now(),
|
|
419
464
|
}))
|
|
420
465
|
|
|
421
|
-
// readiness (checkAll 후 200 or 503)
|
|
466
|
+
// readiness (checkAll 후 200 or 503). 응답의 체크 상세는 기본 ok 불리언만 — 체크 함수가 담는
|
|
467
|
+
// error 메시지·부가 필드(내부 호스트·드라이버 정보)가 비인증 경로로 새지 않게 한다(ADR-186).
|
|
468
|
+
// 전체 상세가 필요하면 `health.exposeCheckDetails: true` 옵트인(내부망/가드 전제, 운영자 결정).
|
|
469
|
+
// 실패 상세는 응답 대신 서버 로그(warn)로 — 운영자는 항상 원인을 본다.
|
|
470
|
+
const exposeCheckDetails = healthCfg.exposeCheckDetails === true
|
|
422
471
|
this.fastify.get(readyPath, { config: HEALTH_EXEMPT }, async (req, reply) => {
|
|
423
472
|
const snapshot = await MegaHealth.checkAll()
|
|
424
|
-
if (!snapshot.ok)
|
|
473
|
+
if (!snapshot.ok) {
|
|
474
|
+
reply.code(503)
|
|
475
|
+
this.fastify.log.warn?.({ app: this.name, checks: snapshot.checks }, 'health.ready failed')
|
|
476
|
+
}
|
|
477
|
+
const checks = exposeCheckDetails
|
|
478
|
+
? snapshot.checks
|
|
479
|
+
: Object.fromEntries(Object.entries(snapshot.checks).map(([name, r]) => [name, { ok: /** @type {any} */ (r)?.ok === true }]))
|
|
425
480
|
return {
|
|
426
481
|
app: this.name,
|
|
427
|
-
|
|
482
|
+
ok: snapshot.ok,
|
|
483
|
+
checks,
|
|
428
484
|
uptime_ms: Math.floor(process.uptime() * 1000),
|
|
429
485
|
ts: Date.now(),
|
|
430
486
|
}
|
|
@@ -474,18 +530,13 @@ export class MegaApp {
|
|
|
474
530
|
}
|
|
475
531
|
}
|
|
476
532
|
if (Array.isArray(opts.globalMiddlewares)) {
|
|
477
|
-
const self = this
|
|
478
533
|
for (const mw of opts.globalMiddlewares) {
|
|
479
534
|
if (typeof mw !== 'function') {
|
|
480
535
|
throw new TypeError(`MegaApp('${this.name}'): globalMiddlewares[] entry must be a function.`)
|
|
481
536
|
}
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
|
|
485
|
-
this.fastify.addHook('preHandler', async (req, reply) => {
|
|
486
|
-
const ctx = getHttpCtx({ app: self, req, reply })
|
|
487
|
-
return /** @type {any} */ (mw)(req, reply, ctx)
|
|
488
|
-
})
|
|
537
|
+
// arity-2 래퍼 + canonical ctx 주입(ADR-134) — 합성 정본은 pipeline.js(ADR-185). 라우트
|
|
538
|
+
// before/use 와 같은 래퍼라 같은 요청의 핸들러가 동일 ctx 를 이어받는다(요청당 캐싱).
|
|
539
|
+
this.fastify.addHook('preHandler', wrapPreHandler(/** @type {Function} */ (mw), this))
|
|
489
540
|
}
|
|
490
541
|
}
|
|
491
542
|
|
|
@@ -550,11 +601,6 @@ export class MegaApp {
|
|
|
550
601
|
return Array.isArray(f._megaWsRoutes) ? f._megaWsRoutes : []
|
|
551
602
|
}
|
|
552
603
|
|
|
553
|
-
/** 현재 연결된 hub link (미연결 시 null). */
|
|
554
|
-
get hubLink() {
|
|
555
|
-
return this._hubLink
|
|
556
|
-
}
|
|
557
|
-
|
|
558
604
|
/**
|
|
559
605
|
* 이 앱의 `ctx.db/cache/bus` 접근자 3종 (ADR-102). 요청 ctx 빌더(HTTP·WS)가 spread 해서 노출한다.
|
|
560
606
|
* 별명→globalKey→전역 공유 어댑터로 해석하며, 미선언 별명·미등록 키는 호출 시 throw.
|
|
@@ -603,453 +649,149 @@ export class MegaApp {
|
|
|
603
649
|
return this._bruteForce
|
|
604
650
|
}
|
|
605
651
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
* @param {string} config.url - hub URL.
|
|
615
|
-
* @param {string} config.token - Bearer 토큰.
|
|
616
|
-
* @param {string} config.bridgeId - 운영 식별자.
|
|
617
|
-
* @param {string} [config.instanceId]
|
|
618
|
-
* @param {string[]} [config.capabilities]
|
|
619
|
-
* @param {string[]} [config.channels] - 자동 구독할 채널 목록.
|
|
620
|
-
* @param {import('../lib/mega-retry.js').MegaRetryOptions} [config.retry] - 지정 시 재연결 활성(ADR-098).
|
|
621
|
-
* hub 재시작·drain(4503)·네트워크 단절 시 지수 백오프로 재연결하고, 성공하면 presence(채널·세션
|
|
622
|
-
* JOIN)를 자동 재동기화한다(hub 는 절단 시점 presence 를 잃으므로).
|
|
623
|
-
* @param {import('./ws-compression.js').WsCompressionConfig} [config.compression] - Bridge↔Hub
|
|
624
|
-
* link 압축(ADR-078 / MegaWsHubCompressionConfig). Global `wsHub.compression`
|
|
625
|
-
* 블록을 그대로 전달한다 — hub 서버와 같은 스키마. 디폴트 OFF. 잘못된 threshold/windowBits 면
|
|
626
|
-
* 즉시 throw(부팅 fail-fast).
|
|
627
|
-
* @returns {Promise<MegaHubLink>} 등록 완료된 link.
|
|
628
|
-
*/
|
|
629
|
-
async connectHub(config = /** @type {any} */ ({})) {
|
|
630
|
-
const link = new MegaHubLink({
|
|
631
|
-
url: config.url,
|
|
632
|
-
token: config.token,
|
|
633
|
-
bridgeId: config.bridgeId,
|
|
634
|
-
instanceId: config.instanceId,
|
|
635
|
-
capabilities: config.capabilities,
|
|
636
|
-
retry: config.retry,
|
|
637
|
-
compression: config.compression,
|
|
638
|
-
logger: this.fastify.log,
|
|
639
|
-
})
|
|
640
|
-
this._hubLink = link
|
|
641
|
-
this._hubBridgeId = config.bridgeId
|
|
642
|
-
this._hubChannels = Array.isArray(config.channels) ? [...config.channels] : []
|
|
643
|
-
// hub → bridge 푸시를 로컬 소켓에 전달.
|
|
644
|
-
link.on(HUB_MESSAGE_TYPES.BROADCAST, (msg) => this._deliverBroadcast(/** @type {any} */ (msg).payload))
|
|
645
|
-
link.on(HUB_MESSAGE_TYPES.DIRECT, (msg) => this._deliverDirect(/** @type {any} */ (msg).payload))
|
|
646
|
-
// 허브가 fan-out 하는 presence(JOIN/LEAVE/METADATA)·heartbeat 는 broadcast 채널 멤버십·keepalive 용으로만
|
|
647
|
-
// 의미 있다. **roster(접속자 목록)는 redis(MegaWsRedisRoster, ADR-177)가 별도 관리**하므로 브릿지는 이들을
|
|
648
|
-
// 소비하지 않는다 — no-op 핸들러로 "no handler for type" 노이즈만 차단한다(JOIN 자체는 joinSession 이
|
|
649
|
-
// broadcast 채널 가입용으로 계속 송신). 멀티 허브에서도 redis 가 single source-of-truth 라 명단이 정합.
|
|
650
|
-
const noopHub = () => {}
|
|
651
|
-
link.on(HUB_MESSAGE_TYPES.JOIN, noopHub)
|
|
652
|
-
link.on(HUB_MESSAGE_TYPES.LEAVE, noopHub)
|
|
653
|
-
link.on(HUB_MESSAGE_TYPES.BULK_LEAVE, noopHub)
|
|
654
|
-
link.on(HUB_MESSAGE_TYPES.METADATA, noopHub)
|
|
655
|
-
link.on(HUB_MESSAGE_TYPES.HEARTBEAT, noopHub)
|
|
656
|
-
// 재연결 성공 시 presence 재동기화(ADR-098) — hub 는 절단 시점 presence 를 잃으므로 broadcast 채널을 다시 JOIN.
|
|
657
|
-
link.on(MegaHubLink.EVENTS.RECONNECTED, () => this._resyncPresence())
|
|
658
|
-
await link.connect()
|
|
659
|
-
|
|
660
|
-
// 채널 구독 — bridge-subscriber 세션 JOIN(zero-config 브로드캐스트 수신용) + 이미 붙어 있는 실
|
|
661
|
-
// 사용자 세션 재JOIN(예: 첫 connect 전에 클라가 연결됐을 수 있음).
|
|
662
|
-
this._resyncPresence()
|
|
663
|
-
|
|
664
|
-
// shutdown hook 누수 방지(L1) — 재연결 등으로 connectHub 가 다시 불려도 hook 은 항상 1개.
|
|
665
|
-
const hookName = `mega-hublink:${this.name}`
|
|
666
|
-
MegaShutdown.unregister(hookName)
|
|
667
|
-
MegaShutdown.register(hookName, async () => link.close())
|
|
668
|
-
return link
|
|
652
|
+
// ── WS presence/hub — MegaWsPresence(ws-presence.js) 위임 ─────────────────────────────
|
|
653
|
+
// 연결 인덱스 3종·hub link·cluster/roster 동기화는 협력자(MegaWsPresence)가 전담한다.
|
|
654
|
+
// MegaApp 은 공개 표면(체이닝 포함)과 framework-internal(_접두) 멤버를 위임으로 보존한다 —
|
|
655
|
+
// driveWsConnection(_track/_untrack)·boot 자동배선(_deliver*/localRosterMembers)·기존 테스트 호환.
|
|
656
|
+
|
|
657
|
+
/** 현재 연결된 hub link (미연결 시 null). */
|
|
658
|
+
get hubLink() {
|
|
659
|
+
return this._presence.hubLink
|
|
669
660
|
}
|
|
670
661
|
|
|
671
662
|
/**
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
* @returns {
|
|
675
|
-
* @private
|
|
663
|
+
* 이 bridge 를 hub 에 연결한다 (ADR-033/059) — 계약·옵션은 {@link MegaWsPresence#connectHub} 정본.
|
|
664
|
+
* @param {any} [config] - MegaBridgeHubConfig(§2.1) + 구독 채널.
|
|
665
|
+
* @returns {Promise<import('./hub-link.js').MegaHubLink>} 등록 완료된 link.
|
|
676
666
|
*/
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
if (!link?.isRegistered) return
|
|
680
|
-
const bridgeId = this._hubBridgeId ?? this.name
|
|
681
|
-
// 1) bridge-subscriber JOIN — bridge 가 채널 멤버가 되어 zero-config 브로드캐스트를 받게 한다.
|
|
682
|
-
for (const ch of this._hubChannels) {
|
|
683
|
-
link.join({
|
|
684
|
-
userId: `bridge:${bridgeId}`,
|
|
685
|
-
sessionId: `bridge:${bridgeId}#${ch}`,
|
|
686
|
-
channels: [ch],
|
|
687
|
-
})
|
|
688
|
-
}
|
|
689
|
-
// 2) 실 사용자 세션 JOIN — joinSession 으로 매핑된 활성 세션을 다시 등록(DIRECT 타겟 복구).
|
|
690
|
-
// 채널 + metadata 까지 재동기화한다(M-1) — hub 는 절단 시점 presence 를 통째로 잃으므로,
|
|
691
|
-
// metadata 를 빠뜨리면 재연결 후 hub presence 의 메타가 silent 사라진다.
|
|
692
|
-
for (const [sessionId, conn] of this._sessionConns) {
|
|
693
|
-
if (!conn.isOpen) continue
|
|
694
|
-
link.join({
|
|
695
|
-
userId: /** @type {string} */ (conn.userId),
|
|
696
|
-
sessionId,
|
|
697
|
-
channels: conn.channels ? [...conn.channels] : [],
|
|
698
|
-
...(conn.metadata ? { metadata: conn.metadata } : {}),
|
|
699
|
-
})
|
|
700
|
-
}
|
|
667
|
+
async connectHub(config) {
|
|
668
|
+
return this._presence.connectHub(config)
|
|
701
669
|
}
|
|
702
670
|
|
|
703
671
|
/**
|
|
704
|
-
* 실 사용자 세션을 hub presence 에 등록하고 로컬 매핑을 만든다
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
* 채널의 `onConnect(sock, ctx)` 에서 `ctx.app.joinSession(sock, { userId: ctx.auth.userId, ... })`
|
|
708
|
-
* 를 호출한다. 이 매핑이 있어야 DIRECT 가 **해당 userId 세션에만** 전달된다(cross-user flood 방지,
|
|
709
|
-
* H-latent guard). 매핑 없는 연결은 DIRECT 대상에서 제외된다.
|
|
710
|
-
*
|
|
711
|
-
* @param {import('./ws-upgrade.js').MegaWsConnection} conn - onConnect 가 받은 소켓 래퍼.
|
|
712
|
-
* @param {Object} entry
|
|
713
|
-
* @param {string} entry.userId - 인증된 사용자 식별자(비어 있으면 throw).
|
|
714
|
-
* @param {string} entry.sessionId - 세션 식별자(비어 있으면 throw). 전역 유일 권장.
|
|
715
|
-
* @param {string[]} [entry.channels] - 가입 채널 목록.
|
|
716
|
-
* @param {Object} [entry.metadata] - presence 메타데이터(명시 필드만, ADR-059).
|
|
672
|
+
* 실 사용자 세션을 hub presence 에 등록하고 로컬 매핑을 만든다 — {@link MegaWsPresence#joinSession} 위임.
|
|
673
|
+
* @param {import('./ws-upgrade.js').MegaWsConnection} conn
|
|
674
|
+
* @param {{ userId: string, sessionId: string, channels?: string[], metadata?: Object }} [entry]
|
|
717
675
|
* @returns {this}
|
|
718
|
-
* @throws {Error} conn/userId/sessionId 누락 시 — 잘못된 매핑을 silent 통과시키지 않는다.
|
|
719
676
|
*/
|
|
720
|
-
joinSession(conn,
|
|
721
|
-
|
|
722
|
-
throw new Error('MegaApp.joinSession: conn (MegaWsConnection) is required.')
|
|
723
|
-
}
|
|
724
|
-
if (typeof userId !== 'string' || userId.length === 0) {
|
|
725
|
-
throw new Error('MegaApp.joinSession: userId (non-empty string) is required.')
|
|
726
|
-
}
|
|
727
|
-
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
728
|
-
throw new Error('MegaApp.joinSession: sessionId (non-empty string) is required.')
|
|
729
|
-
}
|
|
730
|
-
const chans = Array.isArray(channels) ? [...channels] : []
|
|
731
|
-
|
|
732
|
-
// L-4: 같은 sessionId 가 다른 conn 으로 다시 join 되면(전역 유일 계약 위반) 옛 conn 을 인덱스에서
|
|
733
|
-
// 떼어 dangling 을 막는다 — 단 소켓 자체는 닫지 않는다(클라가 정리). 옛 conn 의 신원도 비워,
|
|
734
|
-
// 이후 옛 conn 의 close(_untrackWsConn)가 새 conn 이 차지한 sessionId 로 LEAVE 를 잘못 보내지
|
|
735
|
-
// 않게 한다(그대로 두면 새 세션의 hub presence 가 silent 제거됨).
|
|
736
|
-
const prior = this._sessionConns.get(sessionId)
|
|
737
|
-
if (prior && prior !== conn) {
|
|
738
|
-
this.fastify.log?.warn?.(
|
|
739
|
-
{ app: this.name, sessionId, priorUserId: prior.userId, userId },
|
|
740
|
-
'ws.joinSession duplicate sessionId — prior conn left dangling (detached, not closed)',
|
|
741
|
-
)
|
|
742
|
-
if (prior.userId !== undefined) {
|
|
743
|
-
const pset = this._userConns.get(prior.userId)
|
|
744
|
-
if (pset) {
|
|
745
|
-
pset.delete(prior)
|
|
746
|
-
if (pset.size === 0) this._userConns.delete(prior.userId)
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
if (prior.ns !== undefined) {
|
|
750
|
-
const nsset = this._wsConns.get(prior.ns)
|
|
751
|
-
if (nsset) {
|
|
752
|
-
nsset.delete(prior)
|
|
753
|
-
if (nsset.size === 0) this._wsConns.delete(prior.ns)
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
prior.userId = undefined
|
|
757
|
-
prior.sessionId = undefined
|
|
758
|
-
prior.channels = null
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
// 연결에 신원 부착(매핑 키). _untrackWsConn 이 close 시 이 값으로 인덱스를 정리한다.
|
|
762
|
-
conn.userId = userId
|
|
763
|
-
conn.sessionId = sessionId
|
|
764
|
-
conn.channels = new Set(chans)
|
|
765
|
-
conn.metadata = metadata // M-1: 재연결 재동기화(_resyncPresence)가 보존할 수 있게 저장.
|
|
766
|
-
|
|
767
|
-
let uset = this._userConns.get(userId)
|
|
768
|
-
if (!uset) {
|
|
769
|
-
uset = new Set()
|
|
770
|
-
this._userConns.set(userId, uset)
|
|
771
|
-
}
|
|
772
|
-
uset.add(conn)
|
|
773
|
-
this._sessionConns.set(sessionId, conn)
|
|
774
|
-
|
|
775
|
-
this.fastify.log?.debug?.({ app: this.name, userId, sessionId, channels: chans }, 'ws.joinSession')
|
|
776
|
-
// hub presence 등록 — 등록 상태일 때만(미연결/재연결 중이면 _resyncPresence 가 나중에 복구).
|
|
777
|
-
if (this._hubLink?.isRegistered) {
|
|
778
|
-
this._hubLink.join({ userId, sessionId, channels: chans, ...(metadata ? { metadata } : {}) })
|
|
779
|
-
}
|
|
780
|
-
// NATS roster 동기화 (ADR-176) — 프레임워크가 클러스터 접속자 목록을 자동 관리한다(개발자 코드 불요).
|
|
781
|
-
// ns 는 연결의 namespace. roster:'none' 이면 로컬만 갱신한다.
|
|
782
|
-
if (this._wsCluster && typeof conn.ns === 'string') {
|
|
783
|
-
this._wsCluster.rosterAdd({ ns: conn.ns, sessionId, userId, ...(metadata ? { metadata } : {}) })
|
|
784
|
-
}
|
|
785
|
-
// redis roster 동기화 (ADR-177) — **채널별** 접속자 목록을 redis HASH 에 add(멀티 허브 정합). best-effort.
|
|
786
|
-
if (this._wsRoster && chans.length > 0) {
|
|
787
|
-
const member = { userId, ...(metadata ? { metadata } : {}) }
|
|
788
|
-
for (const ch of chans) {
|
|
789
|
-
this._wsRoster.add(ch, sessionId, member).catch((err) =>
|
|
790
|
-
this.fastify.log?.warn?.({ err, channel: ch, app: this.name }, 'ws-roster add failed'),
|
|
791
|
-
)
|
|
792
|
-
}
|
|
793
|
-
}
|
|
677
|
+
joinSession(conn, entry) {
|
|
678
|
+
this._presence.joinSession(conn, entry)
|
|
794
679
|
return this
|
|
795
680
|
}
|
|
796
681
|
|
|
797
682
|
/**
|
|
798
|
-
* 채널 broadcast — 로컬
|
|
799
|
-
*
|
|
683
|
+
* 채널 broadcast — 로컬 즉시 전달 + (hub/NATS 연결 시) 클러스터 fan-out. {@link MegaWsPresence#broadcast} 위임.
|
|
800
684
|
* @param {{ ns: string, channel: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }} args
|
|
801
685
|
* @returns {void}
|
|
802
|
-
* @throws {Error} message.type(string) 누락 시 — 호출부 입력 오류를 silent drop 하지 않고 즉시 알린다(L6).
|
|
803
686
|
*/
|
|
804
|
-
broadcast(
|
|
805
|
-
|
|
806
|
-
if (!message || typeof message.type !== 'string') {
|
|
807
|
-
throw new Error('MegaApp.broadcast: message.type (string) is required')
|
|
808
|
-
}
|
|
809
|
-
this._deliverBroadcast({ ns, channel, message, exceptSessionIds })
|
|
810
|
-
if (this._hubLink?.isRegistered) {
|
|
811
|
-
// L-7: 빈 배열도 truthy 라 `exceptSessionIds: []` 가 wire 로 새던 비대칭 제거 — 비어 있으면 생략.
|
|
812
|
-
const hasExcept = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0
|
|
813
|
-
try {
|
|
814
|
-
this._hubLink.broadcast({ ns, channel, message, ...(hasExcept ? { exceptSessionIds } : {}) })
|
|
815
|
-
} catch (err) {
|
|
816
|
-
// 로컬 전달은 이미 성공. 클러스터 fan-out 은 best-effort(at-most-once, ADR-033)이며 소켓이
|
|
817
|
-
// 닫히는 중이면 비치명적 — 재연결 시 presence 가 재동기화된다. warn 후 호출자 보호.
|
|
818
|
-
const log = /** @type {any} */ (this.fastify.log)
|
|
819
|
-
log?.warn?.({ err, ns, channel, app: this.name }, 'app.broadcast hub fan-out failed (local delivered)')
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
// NATS 클러스터 fan-out (ADR-176, boot 자동배선). 로컬은 위에서 전달했고, 다른 인스턴스는 구독으로
|
|
823
|
-
// 받아 각자 전달한다(echo 는 instanceId 로 스킵). publish 실패는 best-effort — local 은 이미 성공.
|
|
824
|
-
if (this._wsCluster) {
|
|
825
|
-
this._wsCluster.publishBroadcast({ ns, channel, message, exceptSessionIds }).catch((err) => {
|
|
826
|
-
const log = /** @type {any} */ (this.fastify.log)
|
|
827
|
-
log?.warn?.({ err, ns, channel, app: this.name }, 'app.broadcast nats fan-out failed (local delivered)')
|
|
828
|
-
})
|
|
829
|
-
}
|
|
687
|
+
broadcast(args) {
|
|
688
|
+
this._presence.broadcast(args)
|
|
830
689
|
}
|
|
831
690
|
|
|
832
691
|
/**
|
|
833
|
-
* 특정 사용자에게 직접 전송 (
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
* {@link MegaApp#joinSession} 으로 매핑된 연결만 대상이다 — 매핑 없는 userId 면 로컬 no-op.
|
|
837
|
-
*
|
|
838
|
-
* @param {string} userId - 대상 사용자.
|
|
839
|
-
* @param {{ type: string, payload?: Object }} message - 내부 envelope `{ type, payload }`.
|
|
692
|
+
* 특정 사용자에게 직접 전송 (ADR-035) — {@link MegaWsPresence#directToUser} 위임.
|
|
693
|
+
* @param {string} userId
|
|
694
|
+
* @param {{ type: string, payload?: Object }} message
|
|
840
695
|
* @returns {void}
|
|
841
|
-
* @throws {Error} userId/message.type 누락 시(broadcast 와 동일한 입력 보호, L6).
|
|
842
696
|
*/
|
|
843
697
|
directToUser(userId, message) {
|
|
844
|
-
|
|
845
|
-
throw new Error('MegaApp.directToUser: userId (non-empty string) is required')
|
|
846
|
-
}
|
|
847
|
-
if (!message || typeof message.type !== 'string') {
|
|
848
|
-
throw new Error('MegaApp.directToUser: message.type (string) is required')
|
|
849
|
-
}
|
|
850
|
-
this._deliverDirect({ userId, message })
|
|
851
|
-
if (this._hubLink?.isRegistered) {
|
|
852
|
-
try {
|
|
853
|
-
this._hubLink.direct({ userId, message })
|
|
854
|
-
} catch (err) {
|
|
855
|
-
// 로컬 전달은 이미 성공. 클러스터 fan-out 은 best-effort(at-most-once, ADR-033). warn 후 보호.
|
|
856
|
-
const log = /** @type {any} */ (this.fastify.log)
|
|
857
|
-
log?.warn?.({ err, userId, app: this.name }, 'app.directToUser hub fan-out failed (local delivered)')
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
// NATS 클러스터 direct (ADR-176) — 다른 인스턴스의 같은 userId 세션까지. echo 는 instanceId 로 스킵.
|
|
861
|
-
if (this._wsCluster) {
|
|
862
|
-
this._wsCluster.publishDirect(userId, message).catch((err) => {
|
|
863
|
-
const log = /** @type {any} */ (this.fastify.log)
|
|
864
|
-
log?.warn?.({ err, userId, app: this.name }, 'app.directToUser nats fan-out failed (local delivered)')
|
|
865
|
-
})
|
|
866
|
-
}
|
|
698
|
+
this._presence.directToUser(userId, message)
|
|
867
699
|
}
|
|
868
700
|
|
|
869
701
|
/**
|
|
870
|
-
* 세션 presence 메타데이터 갱신 (
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
* 메타까지 복구한다(M-1). 매핑 없는 sessionId 면 no-op(로컬 저장 없이 hub 전파만은 하지 않음 —
|
|
874
|
-
* 재연결 보존 대상이 없으므로). broadcast/directToUser 와 같은 best-effort fan-out.
|
|
875
|
-
*
|
|
876
|
-
* @param {string} sessionId - 대상 세션(joinSession 으로 매핑된 것).
|
|
877
|
-
* @param {Object} metadata - 갱신할 메타데이터(명시 필드만).
|
|
702
|
+
* 세션 presence 메타데이터 갱신 (ADR-059) — {@link MegaWsPresence#updateMetadata} 위임.
|
|
703
|
+
* @param {string} sessionId
|
|
704
|
+
* @param {Object} metadata
|
|
878
705
|
* @returns {this}
|
|
879
|
-
* @throws {Error} sessionId/metadata 누락 시(입력 보호, L6 와 동일 원칙).
|
|
880
706
|
*/
|
|
881
707
|
updateMetadata(sessionId, metadata) {
|
|
882
|
-
|
|
883
|
-
throw new Error('MegaApp.updateMetadata: sessionId (non-empty string) is required')
|
|
884
|
-
}
|
|
885
|
-
if (!metadata || typeof metadata !== 'object') {
|
|
886
|
-
throw new Error('MegaApp.updateMetadata: metadata (object) is required')
|
|
887
|
-
}
|
|
888
|
-
const conn = this._sessionConns.get(sessionId)
|
|
889
|
-
if (!conn) {
|
|
890
|
-
// 매핑 없는 세션 — 재연결로 보존할 로컬 대상이 없으므로 no-op(다른 bridge 세션은 그쪽이 관리).
|
|
891
|
-
this.fastify.log?.debug?.({ app: this.name, sessionId }, 'ws.updateMetadata — no local session (no-op)')
|
|
892
|
-
return this
|
|
893
|
-
}
|
|
894
|
-
conn.metadata = metadata // 재연결 재동기화가 최신 메타를 복구하도록 저장(M-1).
|
|
895
|
-
if (this._hubLink?.isRegistered) {
|
|
896
|
-
try {
|
|
897
|
-
this._hubLink.updateMetadata({ sessionId, metadata })
|
|
898
|
-
} catch (err) {
|
|
899
|
-
// hub 전파 실패는 비치명적 — 로컬 저장은 됐고 재연결 시 _resyncPresence 가 복구.
|
|
900
|
-
const log = /** @type {any} */ (this.fastify.log)
|
|
901
|
-
log?.warn?.({ err, sessionId, app: this.name }, 'app.updateMetadata hub propagate failed (local stored)')
|
|
902
|
-
}
|
|
903
|
-
}
|
|
708
|
+
this._presence.updateMetadata(sessionId, metadata)
|
|
904
709
|
return this
|
|
905
710
|
}
|
|
906
711
|
|
|
907
712
|
/**
|
|
908
|
-
* NATS 클러스터 fan-out/roster
|
|
909
|
-
* 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
|
|
713
|
+
* NATS 클러스터 fan-out/roster 배선 (ADR-176, boot 자동 호출) — {@link MegaWsPresence#setWsCluster} 위임.
|
|
910
714
|
* @param {import('./ws-cluster.js').MegaWsCluster|null} cluster
|
|
911
715
|
* @returns {this}
|
|
912
716
|
*/
|
|
913
717
|
setWsCluster(cluster) {
|
|
914
|
-
this.
|
|
718
|
+
this._presence.setWsCluster(cluster)
|
|
915
719
|
return this
|
|
916
720
|
}
|
|
917
721
|
|
|
918
722
|
/**
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
* wsCluster 미배선(또는 roster:'none')이면 로컬 멤버만 반환한다.
|
|
922
|
-
*
|
|
923
|
-
* @param {string} ns - WS namespace(채널 경로, 예: '/ws/chat').
|
|
723
|
+
* ns(WS 채널 경로) 기준 접속자 목록 (ADR-176) — {@link MegaWsPresence#roster} 위임.
|
|
724
|
+
* @param {string} ns
|
|
924
725
|
* @returns {Array<{ sessionId: string, userId: string, metadata?: Object }>}
|
|
925
726
|
*/
|
|
926
727
|
roster(ns) {
|
|
927
|
-
|
|
928
|
-
// ns 기준 로컬 명단(동기 API). **채널 기준 redis roster(ADR-177)** 는 `ctx.presence.list()`(async)가
|
|
929
|
-
// 다룬다 — 이 메서드는 NATS/로컬용 ns 기준 동기 API 로 유지(하위호환).
|
|
930
|
-
/** @type {Array<{ sessionId: string, userId: string, metadata?: Object }>} */
|
|
931
|
-
const out = []
|
|
932
|
-
for (const [sessionId, conn] of this._sessionConns) {
|
|
933
|
-
if (conn.ns !== ns || !conn.isOpen) continue
|
|
934
|
-
out.push({ sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
|
|
935
|
-
}
|
|
936
|
-
return out
|
|
728
|
+
return this._presence.roster(ns)
|
|
937
729
|
}
|
|
938
730
|
|
|
939
731
|
/**
|
|
940
|
-
* 채널별 redis roster(ADR-177
|
|
941
|
-
* 자동 호출하므로 개발자가 직접 부를 일은 없다(테스트·고급 용도로만 공개).
|
|
732
|
+
* 채널별 redis roster 배선 (ADR-177, boot 자동 호출) — {@link MegaWsPresence#setWsRoster} 위임.
|
|
942
733
|
* @param {import('./ws-roster.js').MegaWsRedisRoster|null} roster
|
|
943
734
|
* @returns {this}
|
|
944
735
|
*/
|
|
945
736
|
setWsRoster(roster) {
|
|
946
|
-
this.
|
|
737
|
+
this._presence.setWsRoster(roster)
|
|
947
738
|
return this
|
|
948
739
|
}
|
|
949
740
|
|
|
950
741
|
/**
|
|
951
|
-
*
|
|
952
|
-
* 로컬을 항상 포함해 join 직후 redis HSET 반영 전에도 본인이 빠지지 않게 한다. `ctx.presence.list()` 의
|
|
953
|
-
* redis 경로가 호출(async). roster 미배선이면 로컬 채널 멤버만.
|
|
742
|
+
* 채널들의 cluster-wide 접속자 목록 (ADR-177) — {@link MegaWsPresence#presenceList} 위임.
|
|
954
743
|
* @param {string[]} channels
|
|
955
744
|
* @returns {Promise<Array<{ sessionId: string, userId: string, metadata?: Object }>>}
|
|
956
745
|
*/
|
|
957
746
|
async presenceList(channels) {
|
|
958
|
-
|
|
959
|
-
/** @type {Map<string, { sessionId: string, userId: string, metadata?: Object }>} */
|
|
960
|
-
const out = new Map()
|
|
961
|
-
// 로컬 세션(이 워커) — 요청 채널에 가입한 것. 항상 fresh(본인 포함).
|
|
962
|
-
for (const [sessionId, conn] of this._sessionConns) {
|
|
963
|
-
if (!conn.isOpen || !conn.channels) continue
|
|
964
|
-
let inCh = false
|
|
965
|
-
for (const ch of conn.channels) {
|
|
966
|
-
if (want.has(ch)) {
|
|
967
|
-
inCh = true
|
|
968
|
-
break
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
if (inCh) out.set(sessionId, { sessionId, userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) })
|
|
972
|
-
}
|
|
973
|
-
// redis(cluster-wide) — 다른 워커/허브의 세션까지.
|
|
974
|
-
if (this._wsRoster) {
|
|
975
|
-
// 여러 채널 redis 조회를 **병렬화**(Promise.all) — onConnect 핫패스의 직렬 라운드트립 누적 제거(Med).
|
|
976
|
-
const lists = await Promise.all([...want].map((ch) => this._wsRoster.list(ch)))
|
|
977
|
-
for (const list of lists) for (const m of list) if (!out.has(m.sessionId)) out.set(m.sessionId, m)
|
|
978
|
-
}
|
|
979
|
-
return [...out.values()]
|
|
747
|
+
return this._presence.presenceList(channels)
|
|
980
748
|
}
|
|
981
749
|
|
|
982
750
|
/**
|
|
983
|
-
* 이 워커의 로컬 멤버 목록
|
|
984
|
-
* (채널 × 세션)마다 1개. MegaWsRedisRoster 가 주기적으로 이 목록의 expiresAt 을 갱신한다.
|
|
751
|
+
* 이 워커의 로컬 멤버 목록 (redis roster heartbeat 갱신 대상, ADR-177) — {@link MegaWsPresence#localRosterMembers} 위임.
|
|
985
752
|
* @returns {Array<{ channel: string, sessionId: string, member: { userId: string, metadata?: Object } }>}
|
|
986
753
|
*/
|
|
987
754
|
localRosterMembers() {
|
|
988
|
-
|
|
989
|
-
const out = []
|
|
990
|
-
for (const [sessionId, conn] of this._sessionConns) {
|
|
991
|
-
if (!conn.isOpen || !conn.channels) continue
|
|
992
|
-
const member = { userId: /** @type {string} */ (conn.userId), ...(conn.metadata ? { metadata: conn.metadata } : {}) }
|
|
993
|
-
for (const ch of conn.channels) out.push({ channel: ch, sessionId, member })
|
|
994
|
-
}
|
|
995
|
-
return out
|
|
755
|
+
return this._presence.localRosterMembers()
|
|
996
756
|
}
|
|
997
757
|
|
|
998
758
|
/**
|
|
999
|
-
* broadcast payload
|
|
1000
|
-
*
|
|
1001
|
-
* `exceptSessionIds` 에 든 sessionId 로 매핑된 연결은 제외한다(ADR-098). 세션 매핑이 없는
|
|
1002
|
-
* 연결(zero-config·미JOIN)은 sessionId 가 없어 제외 대상에 걸리지 않으므로 그대로 받는다.
|
|
1003
|
-
*
|
|
759
|
+
* broadcast payload 로컬 전달 (framework-internal — boot 의 MegaWsCluster 배선이 사용).
|
|
1004
760
|
* @param {{ ns: string, channel?: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }} payload
|
|
1005
761
|
* @returns {void}
|
|
1006
762
|
* @private
|
|
1007
763
|
*/
|
|
1008
|
-
_deliverBroadcast(
|
|
1009
|
-
|
|
1010
|
-
if (!set || !message || typeof message.type !== 'string') return
|
|
1011
|
-
const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
|
|
1012
|
-
for (const conn of set) {
|
|
1013
|
-
if (!conn.isOpen) continue
|
|
1014
|
-
if (except && conn.sessionId !== undefined && except.has(conn.sessionId)) continue
|
|
1015
|
-
conn.send({ type: message.type, ns, payload: message.payload })
|
|
1016
|
-
}
|
|
764
|
+
_deliverBroadcast(payload) {
|
|
765
|
+
this._presence._deliverBroadcast(payload)
|
|
1017
766
|
}
|
|
1018
767
|
|
|
1019
768
|
/**
|
|
1020
|
-
* direct payload
|
|
1021
|
-
*
|
|
1022
|
-
* 초기에는 매핑이 없어 모든 연결에 flood 됐다(cross-user 누출). {@link MegaApp#joinSession}
|
|
1023
|
-
* 으로 만든 `userId → 연결` 매핑을 통해 대상 사용자에게만 보낸다. 매핑이 없는 userId 면 no-op
|
|
1024
|
-
* (다른 사용자에게 새지 않음).
|
|
1025
|
-
*
|
|
769
|
+
* direct payload 로컬 전달 (framework-internal — boot 의 MegaWsCluster 배선이 사용).
|
|
1026
770
|
* @param {{ userId: string, message: { type: string, payload?: Object } }} payload
|
|
1027
771
|
* @returns {void}
|
|
1028
772
|
* @private
|
|
1029
773
|
*/
|
|
1030
|
-
_deliverDirect(
|
|
1031
|
-
|
|
1032
|
-
if (typeof userId !== 'string' || userId.length === 0) return
|
|
1033
|
-
const set = this._userConns.get(userId)
|
|
1034
|
-
if (!set) return
|
|
1035
|
-
for (const conn of set) {
|
|
1036
|
-
if (conn.isOpen) conn.send({ type: message.type, payload: message.payload })
|
|
1037
|
-
}
|
|
774
|
+
_deliverDirect(payload) {
|
|
775
|
+
this._presence._deliverDirect(payload)
|
|
1038
776
|
}
|
|
1039
777
|
|
|
1040
778
|
/**
|
|
1041
|
-
*
|
|
779
|
+
* hub 의 DISCONNECT(admin-kick, ADR-097) 라우팅 처리 (framework-internal).
|
|
780
|
+
* @param {{ sessionId: string, reason?: string, requeue?: boolean }} payload
|
|
781
|
+
* @returns {void}
|
|
782
|
+
* @private
|
|
783
|
+
*/
|
|
784
|
+
_handleHubDisconnect(payload) {
|
|
785
|
+
this._presence._handleHubDisconnect(payload)
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* 로컬 WS 연결 등록 (driveWsConnection 이 호출 — framework-internal).
|
|
1042
790
|
* @param {import('./ws-upgrade.js').MegaWsConnection} conn
|
|
1043
791
|
* @returns {void}
|
|
1044
792
|
*/
|
|
1045
793
|
_trackWsConn(conn) {
|
|
1046
|
-
|
|
1047
|
-
let set = this._wsConns.get(conn.ns)
|
|
1048
|
-
if (!set) {
|
|
1049
|
-
set = new Set()
|
|
1050
|
-
this._wsConns.set(conn.ns, set)
|
|
1051
|
-
}
|
|
1052
|
-
set.add(conn)
|
|
794
|
+
this._presence._trackWsConn(conn)
|
|
1053
795
|
}
|
|
1054
796
|
|
|
1055
797
|
/**
|
|
@@ -1058,42 +800,43 @@ export class MegaApp {
|
|
|
1058
800
|
* @returns {void}
|
|
1059
801
|
*/
|
|
1060
802
|
_untrackWsConn(conn) {
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
803
|
+
this._presence._untrackWsConn(conn)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// 상태 필드 호환 접근자 — 외부 @private 사용처(ws-upgrade 의 `_wsRoster` 게이트, 테스트의 mock 주입·
|
|
807
|
+
// 검증)가 분리 전 필드명을 그대로 쓸 수 있게 presence 로 위임한다.
|
|
808
|
+
/** @returns {import('./hub-link.js').MegaHubLink|null} */
|
|
809
|
+
get _hubLink() {
|
|
810
|
+
return this._presence._hubLink
|
|
811
|
+
}
|
|
812
|
+
set _hubLink(v) {
|
|
813
|
+
this._presence._hubLink = v
|
|
814
|
+
}
|
|
815
|
+
/** @returns {import('./ws-cluster.js').MegaWsCluster|null} */
|
|
816
|
+
get _wsCluster() {
|
|
817
|
+
return this._presence._wsCluster
|
|
818
|
+
}
|
|
819
|
+
set _wsCluster(v) {
|
|
820
|
+
this._presence._wsCluster = v
|
|
821
|
+
}
|
|
822
|
+
/** @returns {import('./ws-roster.js').MegaWsRedisRoster|null} */
|
|
823
|
+
get _wsRoster() {
|
|
824
|
+
return this._presence._wsRoster
|
|
825
|
+
}
|
|
826
|
+
set _wsRoster(v) {
|
|
827
|
+
this._presence._wsRoster = v
|
|
828
|
+
}
|
|
829
|
+
/** @returns {Map<string, import('./ws-upgrade.js').MegaWsConnection>} */
|
|
830
|
+
get _sessionConns() {
|
|
831
|
+
return this._presence._sessionConns
|
|
832
|
+
}
|
|
833
|
+
/** @returns {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
834
|
+
get _userConns() {
|
|
835
|
+
return this._presence._userConns
|
|
836
|
+
}
|
|
837
|
+
/** @returns {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
838
|
+
get _wsConns() {
|
|
839
|
+
return this._presence._wsConns
|
|
1097
840
|
}
|
|
1098
841
|
|
|
1099
842
|
/**
|
|
@@ -1157,7 +900,11 @@ export class MegaApp {
|
|
|
1157
900
|
// WebSocket 인스턴스 생성됨 → ws 가 소켓 error 를 인수한다. 임시 가드 해제.
|
|
1158
901
|
detachSocketGuard()
|
|
1159
902
|
const codec = this._buildWsCodec(route, req)
|
|
1160
|
-
|
|
903
|
+
// 핸드셰이크에서 협상된 envelope 버전(_ensureWss handleProtocols, `mega.v<N>` subprotocol).
|
|
904
|
+
// 미협상(레거시·subprotocol 없음)이면 v1 — 연결별 envelope 검증 기준이 된다.
|
|
905
|
+
const m = WS_SUBPROTOCOL_PATTERN.exec(raw.protocol ?? '')
|
|
906
|
+
const protocolVersion = m ? Number(m[1]) : WS_PROTOCOL_VERSION
|
|
907
|
+
driveWsConnection({ raw, req, route, app: this, codec, log, auth, protocolVersion })
|
|
1161
908
|
})
|
|
1162
909
|
})
|
|
1163
910
|
.catch((err) => {
|
|
@@ -1206,6 +953,16 @@ export class MegaApp {
|
|
|
1206
953
|
noServer: true,
|
|
1207
954
|
perMessageDeflate: this._wsPerMessageDeflate,
|
|
1208
955
|
maxPayload: this._wsMaxPayloadBytes,
|
|
956
|
+
// envelope 버전 협상 — 클라가 `mega.v<N>` subprotocol 을 제안하면 최고 상호 버전을 채택해
|
|
957
|
+
// 응답 Sec-WebSocket-Protocol 로 확정 통지한다. mega.* 미제안(레거시)·상호 버전 없음이면
|
|
958
|
+
// subprotocol 없이 수락(= v1 폴백) — 핸드셰이크를 절대 거부하지 않아 구버전 클라가 깨지지 않는다.
|
|
959
|
+
// ⚠️ ws 기본(handleProtocols 미설정)은 첫 제안 토큰을 맹목 echo 한다(미지원 버전을 "지원한다"고
|
|
960
|
+
// 답하는 거짓 신호) — 명시 핸들러가 협상 정확성의 전제다.
|
|
961
|
+
handleProtocols: (/** @type {Set<string>} */ protocols) => {
|
|
962
|
+
const result = negotiateWsProtocol(protocols)
|
|
963
|
+
if (!result || result.subprotocol === undefined) return false // subprotocol 미선택 = v1 폴백.
|
|
964
|
+
return result.subprotocol
|
|
965
|
+
},
|
|
1209
966
|
})
|
|
1210
967
|
}
|
|
1211
968
|
return this._wss
|
|
@@ -1256,31 +1013,8 @@ export class MegaApp {
|
|
|
1256
1013
|
|
|
1257
1014
|
/** 인스턴스 close — hub link · WS 연결 정리 후 진행 중 요청 grace 후 종료. */
|
|
1258
1015
|
async close() {
|
|
1259
|
-
// hub link
|
|
1260
|
-
|
|
1261
|
-
this._hubLink.close()
|
|
1262
|
-
this._hubLink = null
|
|
1263
|
-
this._hubBridgeId = null
|
|
1264
|
-
MegaShutdown.unregister(`mega-hublink:${this.name}`)
|
|
1265
|
-
}
|
|
1266
|
-
// NATS 클러스터 정리(ADR-176) — 구독·heartbeat/sweep 타이머·roster 멤버 해제. boot 의 shutdown hook 과
|
|
1267
|
-
// **양쪽**에서 정리해야 시그널을 안 거치는 종료(server.close()/프로그램적 재시작/멀티앱 부분 종료)에서도
|
|
1268
|
-
// 누수가 없다(_hubLink/_wsRoster 와 동일 대칭, H1). stop() 은 _isStarted 가드로 멱등이라 중복 안전.
|
|
1269
|
-
if (this._wsCluster) {
|
|
1270
|
-
await this._wsCluster.stop().catch((err) => this.fastify.log?.warn?.({ err, app: this.name }, 'ws-cluster stop failed'))
|
|
1271
|
-
this._wsCluster = null
|
|
1272
|
-
MegaShutdown.unregister(`mega-ws-cluster:${this.name}`)
|
|
1273
|
-
}
|
|
1274
|
-
// redis roster 정리(ADR-177) — heartbeat 중지 + 로컬 멤버 즉시 제거. _sessionConns 를 비우기 **전에**
|
|
1275
|
-
// 호출해야 stop() 이 localRosterMembers 로 자기 멤버를 redis 에서 뺄 수 있다.
|
|
1276
|
-
if (this._wsRoster) {
|
|
1277
|
-
await this._wsRoster.stop().catch((err) => this.fastify.log?.warn?.({ err, app: this.name }, 'ws-roster stop failed'))
|
|
1278
|
-
this._wsRoster = null
|
|
1279
|
-
MegaShutdown.unregister(`mega-wsroster:${this.name}`)
|
|
1280
|
-
}
|
|
1281
|
-
this._wsConns.clear()
|
|
1282
|
-
this._userConns.clear()
|
|
1283
|
-
this._sessionConns.clear()
|
|
1016
|
+
// WS presence 정리(hub link → cluster → roster → 연결 인덱스, 협력자가 자기 hook 까지 짝맞춰 해제).
|
|
1017
|
+
await this._presence.close()
|
|
1284
1018
|
// 활성 WS 연결을 먼저 정리 (1001 going away). noServer wss 는 clientTracking 기본 on.
|
|
1285
1019
|
if (this._wss) {
|
|
1286
1020
|
for (const client of this._wss.clients) client.close(1001, 'server shutting down')
|
|
@@ -1288,6 +1022,10 @@ export class MegaApp {
|
|
|
1288
1022
|
this._wss = null
|
|
1289
1023
|
}
|
|
1290
1024
|
await this.fastify.close()
|
|
1025
|
+
// 생성자가 등록한 자기 close hook 도 해제 — hublink/ws-cluster/wsroster/session 4종과 동일한
|
|
1026
|
+
// 짝맞춤. 남겨두면 graceful shutdown 시 이미 닫힌 fastify 를 중복 close 하고, 앱을 만들고 닫는
|
|
1027
|
+
// 장수 프로세스(테스트·동적 마운트)에서 handlers 배열이 누적된다.
|
|
1028
|
+
MegaShutdown.unregister(`mega-app:${this.name}`)
|
|
1291
1029
|
// 세션 store 정리 (있으면) + shutdown hook 해제(누수 방지, hublink 패턴 정합).
|
|
1292
1030
|
if (this._sessionStore) {
|
|
1293
1031
|
MegaShutdown.unregister(`mega-session:${this.name}`)
|