mega-framework 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +127 -0
- package/.env.example +186 -0
- package/.prettierrc.json +8 -0
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/bin/mega-ws-hub.js +15 -0
- package/bin/mega.js +38 -0
- package/docker-compose.yml +201 -0
- package/eslint.config.js +57 -0
- package/infra/otel-collector-config.yaml +43 -0
- package/jsconfig.json +18 -0
- package/package.json +121 -0
- package/sample/crud/.env +18 -0
- package/sample/crud/.env.example +50 -0
- package/sample/crud/README.md +85 -0
- package/sample/crud/apps/main/app.config.js +114 -0
- package/sample/crud/apps/main/channels/chat-bus.js +115 -0
- package/sample/crud/apps/main/channels/chat-channel.js +145 -0
- package/sample/crud/apps/main/controllers/auth-controller.js +144 -0
- package/sample/crud/apps/main/controllers/cron-controller.js +34 -0
- package/sample/crud/apps/main/controllers/guide-controller.js +37 -0
- package/sample/crud/apps/main/controllers/jobs-controller.js +43 -0
- package/sample/crud/apps/main/controllers/logs-controller.js +35 -0
- package/sample/crud/apps/main/controllers/metrics-controller.js +22 -0
- package/sample/crud/apps/main/controllers/note-controller.js +116 -0
- package/sample/crud/apps/main/controllers/perf-controller.js +38 -0
- package/sample/crud/apps/main/controllers/redis-controller.js +36 -0
- package/sample/crud/apps/main/controllers/tracing-controller.js +43 -0
- package/sample/crud/apps/main/controllers/upload-controller.js +98 -0
- package/sample/crud/apps/main/controllers/user-controller.js +34 -0
- package/sample/crud/apps/main/controllers/web-controller.js +137 -0
- package/sample/crud/apps/main/controllers/worker-controller.js +57 -0
- package/sample/crud/apps/main/controllers/ws-controller.js +29 -0
- package/sample/crud/apps/main/jobs/email-job.js +72 -0
- package/sample/crud/apps/main/locales/client/en.json +3 -0
- package/sample/crud/apps/main/locales/client/ko.json +3 -0
- package/sample/crud/apps/main/locales/server/en.json +316 -0
- package/sample/crud/apps/main/locales/server/ko.json +316 -0
- package/sample/crud/apps/main/middleware/web-auth.js +40 -0
- package/sample/crud/apps/main/middleware/ws-auth.js +48 -0
- package/sample/crud/apps/main/migrations/20260606000001-create-users.js +27 -0
- package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +30 -0
- package/sample/crud/apps/main/models/note.js +71 -0
- package/sample/crud/apps/main/models/user.js +86 -0
- package/sample/crud/apps/main/public/css/app.css +101 -0
- package/sample/crud/apps/main/public/css/guide.css +137 -0
- package/sample/crud/apps/main/public/js/app.js +54 -0
- package/sample/crud/apps/main/public/js/perf.js +129 -0
- package/sample/crud/apps/main/public/js/theme-init.js +12 -0
- package/sample/crud/apps/main/public/js/upload-demo.js +63 -0
- package/sample/crud/apps/main/public/js/worker-demo.js +92 -0
- package/sample/crud/apps/main/public/js/ws-chat.js +161 -0
- package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/sample/crud/apps/main/public/vendor/highlight/github-dark.css +109 -0
- package/sample/crud/apps/main/public/vendor/highlight/github.css +118 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/README.md +19 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.d.ts +196 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.js +1187 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm_bg.wasm +0 -0
- package/sample/crud/apps/main/routes/auth.js +15 -0
- package/sample/crud/apps/main/routes/cron.js +14 -0
- package/sample/crud/apps/main/routes/guide.js +25 -0
- package/sample/crud/apps/main/routes/jobs.js +14 -0
- package/sample/crud/apps/main/routes/logs.js +28 -0
- package/sample/crud/apps/main/routes/metrics.js +13 -0
- package/sample/crud/apps/main/routes/notes.js +19 -0
- package/sample/crud/apps/main/routes/perf.js +47 -0
- package/sample/crud/apps/main/routes/redis.js +14 -0
- package/sample/crud/apps/main/routes/tracing.js +14 -0
- package/sample/crud/apps/main/routes/upload.js +16 -0
- package/sample/crud/apps/main/routes/users.js +54 -0
- package/sample/crud/apps/main/routes/web.js +23 -0
- package/sample/crud/apps/main/routes/worker.js +15 -0
- package/sample/crud/apps/main/routes/ws.js +30 -0
- package/sample/crud/apps/main/schedules/cron-counter-schedule.js +30 -0
- package/sample/crud/apps/main/services/auth-service.js +74 -0
- package/sample/crud/apps/main/services/cron-demo-service.js +66 -0
- package/sample/crud/apps/main/services/guide-service.js +145 -0
- package/sample/crud/apps/main/services/jobs-demo-service.js +83 -0
- package/sample/crud/apps/main/services/logs-demo-service.js +59 -0
- package/sample/crud/apps/main/services/metrics-demo-service.js +144 -0
- package/sample/crud/apps/main/services/note-service.js +75 -0
- package/sample/crud/apps/main/services/perf-service.js +302 -0
- package/sample/crud/apps/main/services/redis-demo-service.js +75 -0
- package/sample/crud/apps/main/services/tracing-demo-service.js +69 -0
- package/sample/crud/apps/main/services/upload-demo-service.js +48 -0
- package/sample/crud/apps/main/services/user-service.js +65 -0
- package/sample/crud/apps/main/views/auth/login.ejs +57 -0
- package/sample/crud/apps/main/views/auth/register.ejs +71 -0
- package/sample/crud/apps/main/views/cron/index.ejs +92 -0
- package/sample/crud/apps/main/views/guide/index.ejs +24 -0
- package/sample/crud/apps/main/views/guide/page.ejs +64 -0
- package/sample/crud/apps/main/views/home.ejs +82 -0
- package/sample/crud/apps/main/views/jobs/index.ejs +113 -0
- package/sample/crud/apps/main/views/layouts/main.ejs +112 -0
- package/sample/crud/apps/main/views/logs/index.ejs +80 -0
- package/sample/crud/apps/main/views/metrics/index.ejs +123 -0
- package/sample/crud/apps/main/views/notes/edit.ejs +45 -0
- package/sample/crud/apps/main/views/notes/list.ejs +74 -0
- package/sample/crud/apps/main/views/notes/new.ejs +45 -0
- package/sample/crud/apps/main/views/perf/index.ejs +90 -0
- package/sample/crud/apps/main/views/redis/index.ejs +65 -0
- package/sample/crud/apps/main/views/tracing/index.ejs +106 -0
- package/sample/crud/apps/main/views/upload/index.ejs +79 -0
- package/sample/crud/apps/main/views/users/edit.ejs +48 -0
- package/sample/crud/apps/main/views/users/list.ejs +81 -0
- package/sample/crud/apps/main/views/users/new.ejs +48 -0
- package/sample/crud/apps/main/views/worker/index.ejs +70 -0
- package/sample/crud/apps/main/views/ws/index.ejs +62 -0
- package/sample/crud/apps/main/workers/hash-worker.js +17 -0
- package/sample/crud/apps/main/workers/hash.task.js +22 -0
- package/sample/crud/ecosystem.config.cjs +9 -0
- package/sample/crud/mega.config.js +105 -0
- package/sample/crud/package-lock.json +5665 -0
- package/sample/crud/package.json +28 -0
- package/sample/crud/test/apps/main/auth-flow.integration.test.js +177 -0
- package/sample/crud/test/apps/main/auth-service.test.js +93 -0
- package/sample/crud/test/apps/main/chat-bus.test.js +101 -0
- package/sample/crud/test/apps/main/chat-channel.test.js +144 -0
- package/sample/crud/test/apps/main/cron-demo-service.test.js +93 -0
- package/sample/crud/test/apps/main/demo-flow.integration.test.js +386 -0
- package/sample/crud/test/apps/main/email-job.test.js +76 -0
- package/sample/crud/test/apps/main/guide-service.test.js +68 -0
- package/sample/crud/test/apps/main/hash-task.test.js +30 -0
- package/sample/crud/test/apps/main/jobs-demo-service.test.js +88 -0
- package/sample/crud/test/apps/main/logs-demo-service.test.js +85 -0
- package/sample/crud/test/apps/main/metrics-demo-service.test.js +90 -0
- package/sample/crud/test/apps/main/note-service.test.js +68 -0
- package/sample/crud/test/apps/main/perf-service.test.js +121 -0
- package/sample/crud/test/apps/main/perf.integration.test.js +202 -0
- package/sample/crud/test/apps/main/redis-demo-service.test.js +98 -0
- package/sample/crud/test/apps/main/tracing-demo-service.test.js +90 -0
- package/sample/crud/test/apps/main/upload-demo-service.test.js +61 -0
- package/sample/crud/test/apps/main/user-service.test.js +65 -0
- package/sample/crud/test/apps/main/ws-chat.integration.test.js +232 -0
- package/sample/crud/vitest.config.js +8 -0
- package/sample/crud/yarn.lock +2142 -0
- package/sample/simple/.env.example +15 -0
- package/sample/simple/README.md +52 -0
- package/sample/simple/apps/main/app.config.js +35 -0
- package/sample/simple/apps/main/controllers/pages-controller.js +22 -0
- package/sample/simple/apps/main/locales/client/en.json +3 -0
- package/sample/simple/apps/main/locales/client/ko.json +3 -0
- package/sample/simple/apps/main/locales/server/en.json +23 -0
- package/sample/simple/apps/main/locales/server/ko.json +23 -0
- package/sample/simple/apps/main/public/css/app.css +101 -0
- package/sample/simple/apps/main/public/hello.txt +1 -0
- package/sample/simple/apps/main/public/js/app.js +54 -0
- package/sample/simple/apps/main/public/js/theme-init.js +12 -0
- package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/sample/simple/apps/main/routes/index.js +9 -0
- package/sample/simple/apps/main/routes/pages.js +12 -0
- package/sample/simple/apps/main/views/index.ejs +56 -0
- package/sample/simple/apps/main/views/layouts/main.ejs +74 -0
- package/sample/simple/ecosystem.config.cjs +10 -0
- package/sample/simple/mega.config.js +27 -0
- package/sample/simple/package-lock.json +1851 -0
- package/sample/simple/package.json +25 -0
- package/sample/simple/test/apps/main/index.test.js +13 -0
- package/sample/simple/vitest.config.js +8 -0
- package/src/adapters/adapter-manager.js +305 -0
- package/src/adapters/adapter-options.js +208 -0
- package/src/adapters/file-adapter.js +350 -0
- package/src/adapters/file-session-adapter.js +363 -0
- package/src/adapters/index.js +38 -0
- package/src/adapters/maria-adapter.js +425 -0
- package/src/adapters/mega-adapter.js +511 -0
- package/src/adapters/mega-bus-adapter.js +81 -0
- package/src/adapters/mega-cache-adapter.js +94 -0
- package/src/adapters/mega-db-adapter.js +72 -0
- package/src/adapters/mega-lock-adapter.js +118 -0
- package/src/adapters/mega-log-sink-adapter.js +46 -0
- package/src/adapters/mega-session-adapter.js +72 -0
- package/src/adapters/mongo-adapter.js +396 -0
- package/src/adapters/nats-adapter.js +370 -0
- package/src/adapters/postgres-adapter.js +341 -0
- package/src/adapters/redis-adapter.js +331 -0
- package/src/adapters/redis-session-adapter.js +261 -0
- package/src/adapters/redlock-adapter.js +385 -0
- package/src/adapters/registry.js +157 -0
- package/src/adapters/sqlite-adapter.js +309 -0
- package/src/auth/index.js +103 -0
- package/src/cli/commands/console-cmd.js +56 -0
- package/src/cli/commands/new.js +101 -0
- package/src/cli/commands/routes.js +107 -0
- package/src/cli/commands/scaffold.js +120 -0
- package/src/cli/commands/test-cmd.js +45 -0
- package/src/cli/generators/index.js +368 -0
- package/src/cli/index.js +472 -0
- package/src/cli/template-engine.js +72 -0
- package/src/cli/ws-hub.js +582 -0
- package/src/core/ajv-mapper.js +80 -0
- package/src/core/boot.js +323 -0
- package/src/core/cluster-metrics.js +278 -0
- package/src/core/config-loader.js +115 -0
- package/src/core/config-validator.js +322 -0
- package/src/core/ctx-builder.js +253 -0
- package/src/core/envelope.js +88 -0
- package/src/core/error-mapper.js +116 -0
- package/src/core/formbody.js +69 -0
- package/src/core/hub-link.js +552 -0
- package/src/core/i18n.js +525 -0
- package/src/core/index.js +63 -0
- package/src/core/mega-app.js +1138 -0
- package/src/core/mega-cluster.js +232 -0
- package/src/core/mega-server.js +176 -0
- package/src/core/mega-service.js +41 -0
- package/src/core/migration-runner.js +196 -0
- package/src/core/multipart.js +282 -0
- package/src/core/openapi.js +114 -0
- package/src/core/router.js +388 -0
- package/src/core/routes-loader.js +57 -0
- package/src/core/scope-registry.js +53 -0
- package/src/core/security.js +275 -0
- package/src/core/services-loader.js +98 -0
- package/src/core/session-cleanup-schedule.js +57 -0
- package/src/core/session-store.js +55 -0
- package/src/core/session.js +414 -0
- package/src/core/static-assets.js +126 -0
- package/src/core/template.js +294 -0
- package/src/core/workers-manager.js +193 -0
- package/src/core/ws-compression.js +112 -0
- package/src/core/ws-controller.js +109 -0
- package/src/core/ws-message.js +176 -0
- package/src/core/ws-upgrade.js +445 -0
- package/src/errors/config-error.js +16 -0
- package/src/errors/http-errors.js +130 -0
- package/src/errors/index.js +19 -0
- package/src/errors/mega-error.js +34 -0
- package/src/eslint-plugin/index.js +15 -0
- package/src/eslint-plugin/no-direct-model-import.js +113 -0
- package/src/index.js +131 -0
- package/src/lib/asp/config.js +83 -0
- package/src/lib/asp/crypto.js +145 -0
- package/src/lib/asp/errors.js +49 -0
- package/src/lib/asp/nonce-cache.js +94 -0
- package/src/lib/asp/plugin.js +263 -0
- package/src/lib/asp/ws-terminator.js +101 -0
- package/src/lib/env-mapper.js +222 -0
- package/src/lib/hub-protocol.js +322 -0
- package/src/lib/index.js +42 -0
- package/src/lib/logger/telegram-core.js +150 -0
- package/src/lib/logger/telegram-transport.js +126 -0
- package/src/lib/mega-brute-force.js +225 -0
- package/src/lib/mega-circuit-breaker.js +412 -0
- package/src/lib/mega-cron.js +169 -0
- package/src/lib/mega-hash.js +179 -0
- package/src/lib/mega-health.js +91 -0
- package/src/lib/mega-job-queue.js +600 -0
- package/src/lib/mega-job-worker.js +295 -0
- package/src/lib/mega-job.js +140 -0
- package/src/lib/mega-logger.js +128 -0
- package/src/lib/mega-metrics.js +661 -0
- package/src/lib/mega-plugin.js +650 -0
- package/src/lib/mega-retry.js +95 -0
- package/src/lib/mega-schedule.js +507 -0
- package/src/lib/mega-shutdown.js +176 -0
- package/src/lib/mega-tracing.js +715 -0
- package/src/lib/mega-worker.js +653 -0
- package/src/lib/worker-runner/process-entry.js +30 -0
- package/src/lib/worker-runner/task-dispatch.js +72 -0
- package/src/lib/worker-runner/thread-entry.js +26 -0
- package/src/models/index.js +7 -0
- package/src/models/mega-model.js +151 -0
- package/src/test/index.js +288 -0
- package/templates/adapter/code.tpl +40 -0
- package/templates/adapter/test.tpl +13 -0
- package/templates/app/app.config.tpl +10 -0
- package/templates/app/route.tpl +10 -0
- package/templates/app/test.tpl +13 -0
- package/templates/channel/code.tpl +38 -0
- package/templates/channel/test.tpl +19 -0
- package/templates/controller/code.tpl +16 -0
- package/templates/controller/route.tpl +9 -0
- package/templates/controller/test.tpl +14 -0
- package/templates/job/code.tpl +23 -0
- package/templates/job/test.tpl +17 -0
- package/templates/locale/code.tpl +3 -0
- package/templates/locale/test.tpl +13 -0
- package/templates/middleware/code.tpl +13 -0
- package/templates/middleware/test.tpl +11 -0
- package/templates/migration/code.tpl +20 -0
- package/templates/migration/test.tpl +14 -0
- package/templates/model/code.tpl +21 -0
- package/templates/model/test.tpl +29 -0
- package/templates/project/app.config.tpl +8 -0
- package/templates/project/app.config.views.tpl +37 -0
- package/templates/project/ecosystem.config.tpl +10 -0
- package/templates/project/env.tpl +12 -0
- package/templates/project/gitignore.tpl +8 -0
- package/templates/project/locales/client/en.json.tpl +3 -0
- package/templates/project/locales/client/ko.json.tpl +3 -0
- package/templates/project/locales/server/en.json.tpl +17 -0
- package/templates/project/locales/server/ko.json.tpl +17 -0
- package/templates/project/mega.config.tpl +11 -0
- package/templates/project/package.tpl +25 -0
- package/templates/project/public/css/app.css +101 -0
- package/templates/project/public/js/app.js +54 -0
- package/templates/project/public/js/theme-init.js +12 -0
- package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/templates/project/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/templates/project/readme.tpl +48 -0
- package/templates/project/route.test.tpl +13 -0
- package/templates/project/route.test.views.tpl +15 -0
- package/templates/project/route.tpl +10 -0
- package/templates/project/route.views.tpl +10 -0
- package/templates/project/views/index.ejs.tpl +58 -0
- package/templates/project/views/layout.ejs.tpl +73 -0
- package/templates/project/vitest.config.tpl +8 -0
- package/templates/route/code.tpl +11 -0
- package/templates/route/test.tpl +26 -0
- package/templates/schedule/code.tpl +19 -0
- package/templates/schedule/test.tpl +17 -0
- package/templates/service/code.tpl +18 -0
- package/templates/service/test.tpl +17 -0
- package/templates/worker/code.tpl +14 -0
- package/templates/worker/task.tpl +13 -0
- package/templates/worker/test.tpl +18 -0
- package/vitest.config.js +33 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaWsHub — `mega ws-hub` 독립 프로세스의 본 hub (ADR-032/033/059).
|
|
4
|
+
*
|
|
5
|
+
* bridge(=mega server)들이 **plaintext + Bearer** WebSocket 으로 붙어 12-타입 프로토콜
|
|
6
|
+
* (lib/hub-protocol.js)을 주고받는 hub. hub 는 presence(어떤 세션이 어느 bridge·채널에 있는지)를
|
|
7
|
+
* 들고, JOIN/LEAVE 를 클러스터 전 bridge 에 fan-out 하고, BROADCAST/DIRECT 를 가입 bridge 로
|
|
8
|
+
* 라우팅한다(ADR-034 namespace=채널, ADR-035 directToUser).
|
|
9
|
+
*
|
|
10
|
+
* # 인증 (ADR-059)
|
|
11
|
+
* hub link 는 ASP 를 쓰지 않는다. bridge 는 핸드셰이크 Authorization 헤더와 `hub.register`
|
|
12
|
+
* payload 양쪽에 Bearer 토큰을 싣고, hub 는 REGISTER payload 의 token 을 `acceptedTokens` 와
|
|
13
|
+
* **timing-safe 비교**(`crypto.timingSafeEqual`)한다. 불일치 → `hub.error`(hub.unauthorized) + close.
|
|
14
|
+
*
|
|
15
|
+
* # 데이터 보장 = at-most-once (ADR-033)
|
|
16
|
+
* in-memory 라우팅이라 절단 시점 메시지 손실 가능. 영속 큐는 코어 미포함(OQ-013).
|
|
17
|
+
*
|
|
18
|
+
* # 본 Step 범위
|
|
19
|
+
* 12-타입 프로토콜 + Bearer + heartbeat + presence + broadcast/direct fan-out. drain(4503)·
|
|
20
|
+
* 재연결 backoff 지원(05-roadmap).
|
|
21
|
+
*
|
|
22
|
+
* # 변경 이력
|
|
23
|
+
* ADR-094 의 ASP echo 데모 hub 를 본 12-타입 hub 로 교체(ADR-097). 클라↔bridge ASP
|
|
24
|
+
* round-trip 검증은 embedded 종단(`core/ws-upgrade.js`, ws-upgrade.integration)으로 이전됨.
|
|
25
|
+
*
|
|
26
|
+
* @module cli/ws-hub
|
|
27
|
+
*/
|
|
28
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
29
|
+
import { WebSocketServer } from 'ws'
|
|
30
|
+
import { generateMessageId } from '../core/ws-message.js'
|
|
31
|
+
import { buildPerMessageDeflate, COMPRESSION_DEFAULTS } from '../core/ws-compression.js'
|
|
32
|
+
import {
|
|
33
|
+
HUB_MESSAGE_TYPES,
|
|
34
|
+
HUB_CLOSE_CODES,
|
|
35
|
+
createHubMessage,
|
|
36
|
+
validateHubMessage,
|
|
37
|
+
} from '../lib/hub-protocol.js'
|
|
38
|
+
import { MegaShutdown } from '../lib/mega-shutdown.js'
|
|
39
|
+
|
|
40
|
+
const T = HUB_MESSAGE_TYPES
|
|
41
|
+
|
|
42
|
+
/** 기본 heartbeat 주기 (ms, 04-data-models MegaWsHubConfig.heartbeatMs 기본값). */
|
|
43
|
+
export const DEFAULT_HEARTBEAT_MS = 25_000
|
|
44
|
+
|
|
45
|
+
/** 기본 최대 프레임 크기 (bytes, L3). 1 MiB — 정상 envelope 은 수 KB 이므로 넉넉하다. */
|
|
46
|
+
export const DEFAULT_MAX_PAYLOAD_BYTES = 1_048_576
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 토큰 timing-safe 비교 — sha256 으로 길이 정규화 후 `timingSafeEqual`. 후보 전체를 순회하여
|
|
50
|
+
* 조기 반환 timing 누출도 줄인다(early-return 안 함).
|
|
51
|
+
* @param {string} token - 검사 대상.
|
|
52
|
+
* @param {string[]} acceptedTokens - 화이트리스트.
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
function isTokenAccepted(token, acceptedTokens) {
|
|
56
|
+
const h = createHash('sha256').update(String(token)).digest()
|
|
57
|
+
let ok = false
|
|
58
|
+
for (const accepted of acceptedTokens) {
|
|
59
|
+
const ah = createHash('sha256').update(String(accepted)).digest()
|
|
60
|
+
if (timingSafeEqual(h, ah)) ok = true
|
|
61
|
+
}
|
|
62
|
+
return ok
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class MegaWsHub {
|
|
66
|
+
/**
|
|
67
|
+
* @param {Object} [opts]
|
|
68
|
+
* @param {string[]} [opts.acceptedTokens] - Bridge Bearer 토큰 화이트리스트 (비거나 누락 시 throw).
|
|
69
|
+
* @param {number} [opts.heartbeatMs=25000] - register_ok 로 알려줄 heartbeat 주기.
|
|
70
|
+
* @param {number} [opts.maxPayloadBytes=1048576] - WS 프레임 최대 크기(L3). 초과 시 ws 가 1009 close.
|
|
71
|
+
* @param {string} [opts.hubId] - hub 식별자. 기본 ULID 자동 생성.
|
|
72
|
+
* @param {import('../core/ws-compression.js').WsCompressionConfig} [opts.compression] - Bridge↔Hub
|
|
73
|
+
* link per-message deflate 압축(ADR-078 / wsHub.compression). 디폴트 OFF.
|
|
74
|
+
* bridge(MegaHubLink)와 양쪽이 협상해야 활성. 잘못된 threshold/windowBits 면 즉시 throw.
|
|
75
|
+
* @param {{ warn?: Function, debug?: Function, info?: Function, error?: Function }} [opts.logger]
|
|
76
|
+
*/
|
|
77
|
+
constructor({ acceptedTokens, heartbeatMs = DEFAULT_HEARTBEAT_MS, maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES, hubId, compression, logger } = {}) {
|
|
78
|
+
if (!Array.isArray(acceptedTokens) || acceptedTokens.length === 0) {
|
|
79
|
+
throw new Error('MegaWsHub: acceptedTokens must be a non-empty array (ADR-059).')
|
|
80
|
+
}
|
|
81
|
+
this._acceptedTokens = [...acceptedTokens]
|
|
82
|
+
this._heartbeatMs = Number.isInteger(heartbeatMs) && heartbeatMs > 0 ? heartbeatMs : DEFAULT_HEARTBEAT_MS
|
|
83
|
+
this._maxPayloadBytes = Number.isInteger(maxPayloadBytes) && maxPayloadBytes > 0 ? maxPayloadBytes : DEFAULT_MAX_PAYLOAD_BYTES
|
|
84
|
+
this._hubId = typeof hubId === 'string' && hubId.length > 0 ? hubId : `hub-${generateMessageId()}`
|
|
85
|
+
// Bridge↔Hub link 압축(ADR-078). enabled=false → false. 잘못된 값은 생성자에서 즉시 throw.
|
|
86
|
+
/** @type {false | Object} WebSocketServer perMessageDeflate 로 전달(start). */
|
|
87
|
+
this._perMessageDeflate = buildPerMessageDeflate(compression, 'wsHub.compression')
|
|
88
|
+
this._log = logger ?? null
|
|
89
|
+
|
|
90
|
+
/** @type {WebSocketServer | null} */
|
|
91
|
+
this._wss = null
|
|
92
|
+
/** heartbeat liveness 체크 interval (M3). @type {ReturnType<typeof setInterval> | null} */
|
|
93
|
+
this._livenessTimer = null
|
|
94
|
+
/** 등록된 bridge 연결. connId → { socket, lastSeen }. @type {Map<string, { socket: import('ws').WebSocket, lastSeen: number }>} */
|
|
95
|
+
this._bridges = new Map()
|
|
96
|
+
/** presence. sessionId → { bridgeConnId, userId, channels:Set, metadata }. @type {Map<string, { bridgeConnId: string, userId: string, channels: Set<string>, metadata: Object }>} */
|
|
97
|
+
this._sessions = new Map()
|
|
98
|
+
/** channel → sessionId 집합. @type {Map<string, Set<string>>} */
|
|
99
|
+
this._channelSessions = new Map()
|
|
100
|
+
/** userId → sessionId 집합 (DIRECT fan-out, ADR-035). @type {Map<string, Set<string>>} */
|
|
101
|
+
this._userSessions = new Map()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** hub 식별자. */
|
|
105
|
+
get hubId() {
|
|
106
|
+
return this._hubId
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 등록된 bridge 수 (테스트/관측용). */
|
|
110
|
+
get bridgeCount() {
|
|
111
|
+
return this._bridges.size
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 현재 presence 세션 수 (테스트/관측용). */
|
|
115
|
+
get sessionCount() {
|
|
116
|
+
return this._sessions.size
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* hub 시작.
|
|
121
|
+
* @param {{ port?: number, host?: string }} [opts]
|
|
122
|
+
* @returns {Promise<{ port: number, host: string }>} 실제 바인딩 주소.
|
|
123
|
+
*/
|
|
124
|
+
async start({ port = 0, host = '127.0.0.1' } = {}) {
|
|
125
|
+
const wss = new WebSocketServer({ host, port, maxPayload: this._maxPayloadBytes, perMessageDeflate: this._perMessageDeflate })
|
|
126
|
+
this._wss = wss
|
|
127
|
+
wss.on('connection', (socket, req) => this._onConnection(socket, req))
|
|
128
|
+
await new Promise((resolve, reject) => {
|
|
129
|
+
const onErr = (/** @type {Error} */ err) => reject(err)
|
|
130
|
+
wss.once('error', onErr)
|
|
131
|
+
wss.once('listening', () => {
|
|
132
|
+
wss.removeListener('error', onErr)
|
|
133
|
+
resolve(undefined)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
// heartbeat liveness 감시 시작(M3) — heartbeatMs 주기로 stale bridge 를 terminate.
|
|
137
|
+
// unref 로 프로세스 종료를 막지 않는다(테스트/CLI 모두 안전).
|
|
138
|
+
this._livenessTimer = setInterval(() => this._checkLiveness(), this._heartbeatMs)
|
|
139
|
+
this._livenessTimer.unref?.()
|
|
140
|
+
const addr = /** @type {import('node:net').AddressInfo | string | null} */ (wss.address())
|
|
141
|
+
return typeof addr === 'object' && addr ? { port: addr.port, host: addr.address } : { port, host }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 현재 바인딩 주소 (테스트용). */
|
|
145
|
+
address() {
|
|
146
|
+
const addr = /** @type {import('node:net').AddressInfo | string | null} */ (this._wss?.address() ?? null)
|
|
147
|
+
return typeof addr === 'object' && addr ? { port: addr.port, host: addr.address } : null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 연결 1건 처리. register 전엔 hub.register 만 허용. 인증 통과 시 presence 라우팅 활성.
|
|
152
|
+
* @param {import('ws').WebSocket} socket
|
|
153
|
+
* @param {import('node:http').IncomingMessage} _req
|
|
154
|
+
* @private
|
|
155
|
+
*/
|
|
156
|
+
_onConnection(socket, _req) {
|
|
157
|
+
const connId = generateMessageId()
|
|
158
|
+
let isRegistered = false
|
|
159
|
+
this._log?.debug?.({ connId }, 'ws-hub connection (awaiting register)')
|
|
160
|
+
|
|
161
|
+
socket.on('message', (raw) => {
|
|
162
|
+
const frame = Array.isArray(raw) ? Buffer.concat(raw).toString('utf8') : raw.toString('utf8')
|
|
163
|
+
let msg
|
|
164
|
+
try {
|
|
165
|
+
msg = JSON.parse(frame)
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// 평문이 JSON 아님 = 규약 위반. fail-closed hub.error(연결 유지, 비치명적) + 로그.
|
|
168
|
+
this._log?.warn?.({ err, connId }, 'ws-hub non-json frame')
|
|
169
|
+
this._safeSend(socket, createHubMessage({ type: T.ERROR, error: { code: 'hub.invalid_message', message: 'frame is not valid JSON' } }))
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
const errors = validateHubMessage(msg)
|
|
173
|
+
if (errors.length > 0) {
|
|
174
|
+
this._log?.warn?.({ connId, errors }, 'ws-hub invalid hub message')
|
|
175
|
+
this._safeSend(socket, createHubMessage({ type: T.ERROR, error: { code: 'hub.invalid_message', message: 'hub protocol violation', details: errors }, ref: typeof msg?.id === 'string' ? msg.id : undefined }))
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!isRegistered) {
|
|
180
|
+
if (msg.type !== T.REGISTER) {
|
|
181
|
+
// register 전 다른 타입 = 규약 위반. fail-closed.
|
|
182
|
+
this._log?.warn?.({ connId, type: msg.type }, 'ws-hub message before register')
|
|
183
|
+
this._safeSend(socket, createHubMessage({ type: T.ERROR, error: { code: 'hub.not_registered', message: 'REGISTER must be the first message' }, ref: msg.id }))
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
isRegistered = this._handleRegister(connId, socket, msg)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
this._route(connId, socket, msg)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
socket.on('close', () => {
|
|
193
|
+
if (isRegistered) this._handleBridgeGone(connId)
|
|
194
|
+
this._log?.debug?.({ connId }, 'ws-hub connection closed')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
socket.on('error', (err) => {
|
|
198
|
+
// 소켓 레벨 에러 — 비치명적, close 가 뒤따른다. 로그만.
|
|
199
|
+
this._log?.warn?.({ err, connId }, 'ws-hub socket error')
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* hub.register 처리 — Bearer 검증(timing-safe). 성공 시 bridge 등록 + register_ok.
|
|
205
|
+
* @param {string} connId
|
|
206
|
+
* @param {import('ws').WebSocket} socket
|
|
207
|
+
* @param {Object} msg - 검증된 hub.register envelope.
|
|
208
|
+
* @returns {boolean} 등록 성공 여부(연결의 isRegistered 갱신용).
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
_handleRegister(connId, socket, msg) {
|
|
212
|
+
const payload = /** @type {{ instanceId: string, token: string, capabilities: string[] }} */ (/** @type {any} */ (msg).payload)
|
|
213
|
+
if (!isTokenAccepted(payload.token, this._acceptedTokens)) {
|
|
214
|
+
this._log?.warn?.({ connId, instanceId: payload.instanceId }, 'ws-hub register denied — bad token (ADR-059)')
|
|
215
|
+
this._safeSend(socket, createHubMessage({ type: T.ERROR, error: { code: 'hub.unauthorized', message: 'invalid bridge token' }, ref: /** @type {any} */ (msg).id }))
|
|
216
|
+
socket.close(1008, 'unauthorized') // RFC 6455 1008 = policy violation
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
this._bridges.set(connId, { socket, lastSeen: Date.now() })
|
|
220
|
+
this._log?.info?.({ connId, instanceId: payload.instanceId, hubId: this._hubId }, 'ws-hub bridge registered')
|
|
221
|
+
this._safeSend(socket, createHubMessage({ type: T.REGISTER_OK, payload: { hubId: this._hubId, acceptedAt: Date.now(), heartbeatMs: this._heartbeatMs } }))
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 등록된 bridge 의 수신 메시지 라우팅 (12-타입).
|
|
227
|
+
* @param {string} connId
|
|
228
|
+
* @param {import('ws').WebSocket} socket
|
|
229
|
+
* @param {Object} msg - 검증된 hub 메시지.
|
|
230
|
+
* @returns {void}
|
|
231
|
+
* @private
|
|
232
|
+
*/
|
|
233
|
+
_route(connId, socket, msg) {
|
|
234
|
+
const type = /** @type {any} */ (msg).type
|
|
235
|
+
const payload = /** @type {any} */ (msg).payload
|
|
236
|
+
switch (type) {
|
|
237
|
+
case T.JOIN:
|
|
238
|
+
this._addSession(connId, payload)
|
|
239
|
+
// 클러스터 presence 공유 — 다른 모든 bridge 에 같은 JOIN fan-out (07 §2).
|
|
240
|
+
this._fanOutToOthers(connId, msg)
|
|
241
|
+
break
|
|
242
|
+
case T.LEAVE: {
|
|
243
|
+
const removed = this._removeSession(payload.sessionId)
|
|
244
|
+
if (removed) this._fanOutToOthers(connId, msg)
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
case T.BULK_LEAVE: {
|
|
248
|
+
const any = payload.sessionIds.map((/** @type {string} */ sid) => this._removeSession(sid)).some(Boolean)
|
|
249
|
+
if (any) this._fanOutToOthers(connId, msg)
|
|
250
|
+
break
|
|
251
|
+
}
|
|
252
|
+
case T.BROADCAST:
|
|
253
|
+
// 채널 가입 bridge 들에 fan-out (origin 제외 — origin 은 local 직접 전달). exceptSessionIds 가
|
|
254
|
+
// 있으면 그 세션은 fan-out 대상에서 빠진다(ADR-098). 제외 세션만 가진 bridge 는
|
|
255
|
+
// 통째로 스킵되고, 그 외엔 bridge 가 받아 로컬에서 해당 세션을 건너뛴다.
|
|
256
|
+
this._fanOutChannel(payload.channel, connId, msg, payload.exceptSessionIds)
|
|
257
|
+
break
|
|
258
|
+
case T.DIRECT:
|
|
259
|
+
// userId 의 세션을 가진 bridge 들에 fan-out (ADR-035). origin 제외 — origin 은 local 직접 전달(L7).
|
|
260
|
+
this._fanOutUser(payload.userId, msg, connId)
|
|
261
|
+
break
|
|
262
|
+
case T.METADATA: {
|
|
263
|
+
const session = this._sessions.get(payload.sessionId)
|
|
264
|
+
if (session) {
|
|
265
|
+
session.metadata = payload.metadata
|
|
266
|
+
this._fanOutToOthers(connId, msg) // presence 메타 동기화
|
|
267
|
+
}
|
|
268
|
+
break
|
|
269
|
+
}
|
|
270
|
+
case T.DISCONNECT: {
|
|
271
|
+
// DISCONNECT 는 양방향(M2, ADR-097): bridge A 가 보낸 강제 종료 요청을 hub 가 세션 소유
|
|
272
|
+
// bridge B 로 라우팅한다(admin-kick: bridge A → hub → owner bridge B). mesh 환경 필수.
|
|
273
|
+
const session = this._sessions.get(payload.sessionId)
|
|
274
|
+
if (session) this._sendTo(session.bridgeConnId, JSON.stringify(msg))
|
|
275
|
+
break
|
|
276
|
+
}
|
|
277
|
+
case T.HEARTBEAT: {
|
|
278
|
+
// application-level keepalive — lastSeen 갱신(M3 liveness) 후 응답 heartbeat 회신.
|
|
279
|
+
const bridge = this._bridges.get(connId)
|
|
280
|
+
if (bridge) bridge.lastSeen = Date.now()
|
|
281
|
+
this._safeSend(socket, createHubMessage({ type: T.HEARTBEAT, payload: { at: Date.now(), pendingDeliveries: 0 } }))
|
|
282
|
+
break
|
|
283
|
+
}
|
|
284
|
+
case T.BINARY:
|
|
285
|
+
// 본 Step 은 envelope 검증까지 — raw bytes 후속 프레임 라우팅은 후속(05-roadmap). 수락 로그만.
|
|
286
|
+
this._log?.debug?.({ connId, ref: payload.ref }, 'ws-hub binary meta accepted (raw-frame routing deferred)')
|
|
287
|
+
break
|
|
288
|
+
case T.ERROR:
|
|
289
|
+
this._log?.warn?.({ connId, code: /** @type {any} */ (msg).error?.code }, 'ws-hub received error from bridge')
|
|
290
|
+
break
|
|
291
|
+
default:
|
|
292
|
+
// REGISTER(중복) / REGISTER_OK 등 hub→bridge 전용 타입을 bridge 가 보냄 = 규약 위반.
|
|
293
|
+
this._log?.warn?.({ connId, type }, 'ws-hub unexpected type from bridge')
|
|
294
|
+
this._safeSend(socket, createHubMessage({ type: T.ERROR, error: { code: 'hub.unexpected_type', message: `type '${type}' is not bridge→hub` }, ref: /** @type {any} */ (msg).id }))
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* presence 에 세션 추가.
|
|
300
|
+
*
|
|
301
|
+
* sessionId 가 이미 존재하면(같은 세션 재JOIN, 또는 채널 변경) 먼저 옛 매핑을 정리한 뒤 재삽입한다
|
|
302
|
+
* — 그러지 않으면 옛 채널 인덱스(`_channelSessions`)에 stale 엔트리가 남는다(채널이 바뀐 경우).
|
|
303
|
+
* 옛 매핑이 **다른 bridge** 소속이면 "sessionId 전역 유일" 계약(ADR-059) 위반이므로 warn 로그를 남기고
|
|
304
|
+
* last-writer 로 재배정한다 — silent 덮어쓰기 금지(L-3). 이렇게 하면 옛 bridge 의 채널 인덱스가
|
|
305
|
+
* 제거되어, 옛 bridge 의 `_handleBridgeGone` 이 재배정된 세션을 잘못 지우는 일도 없어진다.
|
|
306
|
+
*
|
|
307
|
+
* @param {string} connId
|
|
308
|
+
* @param {{ userId: string, sessionId: string, channels: string[], metadata?: Object }} entry
|
|
309
|
+
* @private
|
|
310
|
+
*/
|
|
311
|
+
_addSession(connId, entry) {
|
|
312
|
+
const existing = this._sessions.get(entry.sessionId)
|
|
313
|
+
if (existing) {
|
|
314
|
+
if (existing.bridgeConnId !== connId) {
|
|
315
|
+
this._log?.warn?.(
|
|
316
|
+
{ sessionId: entry.sessionId, oldBridge: existing.bridgeConnId, newBridge: connId },
|
|
317
|
+
'ws-hub duplicate sessionId across bridges — reassigning (global-unique contract violated, L-3)',
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
// 옛 채널/유저 인덱스 제거 후 재삽입(같은 bridge 의 채널 변경 stale 방지 + 다른 bridge 재배정 정리).
|
|
321
|
+
this._removeSession(entry.sessionId)
|
|
322
|
+
}
|
|
323
|
+
const channels = new Set(entry.channels)
|
|
324
|
+
this._sessions.set(entry.sessionId, {
|
|
325
|
+
bridgeConnId: connId,
|
|
326
|
+
userId: entry.userId,
|
|
327
|
+
channels,
|
|
328
|
+
metadata: entry.metadata ?? {},
|
|
329
|
+
})
|
|
330
|
+
for (const ch of channels) {
|
|
331
|
+
let set = this._channelSessions.get(ch)
|
|
332
|
+
if (!set) {
|
|
333
|
+
set = new Set()
|
|
334
|
+
this._channelSessions.set(ch, set)
|
|
335
|
+
}
|
|
336
|
+
set.add(entry.sessionId)
|
|
337
|
+
}
|
|
338
|
+
let uset = this._userSessions.get(entry.userId)
|
|
339
|
+
if (!uset) {
|
|
340
|
+
uset = new Set()
|
|
341
|
+
this._userSessions.set(entry.userId, uset)
|
|
342
|
+
}
|
|
343
|
+
uset.add(entry.sessionId)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* presence 에서 세션 제거. 빈 인덱스는 정리.
|
|
348
|
+
* @param {string} sessionId
|
|
349
|
+
* @returns {boolean} 실제 제거 여부.
|
|
350
|
+
* @private
|
|
351
|
+
*/
|
|
352
|
+
_removeSession(sessionId) {
|
|
353
|
+
const session = this._sessions.get(sessionId)
|
|
354
|
+
if (!session) return false
|
|
355
|
+
this._sessions.delete(sessionId)
|
|
356
|
+
for (const ch of session.channels) {
|
|
357
|
+
const set = this._channelSessions.get(ch)
|
|
358
|
+
if (set) {
|
|
359
|
+
set.delete(sessionId)
|
|
360
|
+
if (set.size === 0) this._channelSessions.delete(ch)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const uset = this._userSessions.get(session.userId)
|
|
364
|
+
if (uset) {
|
|
365
|
+
uset.delete(sessionId)
|
|
366
|
+
if (uset.size === 0) this._userSessions.delete(session.userId)
|
|
367
|
+
}
|
|
368
|
+
return true
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* bridge 연결 종료 시 — 그 bridge 의 모든 세션 제거 + 다른 bridge 에 bulk_leave 통지.
|
|
373
|
+
* @param {string} connId
|
|
374
|
+
* @private
|
|
375
|
+
*/
|
|
376
|
+
_handleBridgeGone(connId) {
|
|
377
|
+
this._bridges.delete(connId)
|
|
378
|
+
/** @type {string[]} */
|
|
379
|
+
const orphaned = []
|
|
380
|
+
for (const [sid, session] of this._sessions) {
|
|
381
|
+
if (session.bridgeConnId === connId) orphaned.push(sid)
|
|
382
|
+
}
|
|
383
|
+
for (const sid of orphaned) this._removeSession(sid)
|
|
384
|
+
if (orphaned.length > 0) {
|
|
385
|
+
const env = createHubMessage({ type: T.BULK_LEAVE, payload: { sessionIds: orphaned } })
|
|
386
|
+
this._fanOutToOthers(connId, env)
|
|
387
|
+
}
|
|
388
|
+
this._log?.debug?.({ connId, orphaned: orphaned.length }, 'ws-hub bridge gone — presence cleaned')
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* origin 을 제외한 모든 등록 bridge 로 송신 (presence 공유용). envelope 는 1회만 직렬화(L5).
|
|
393
|
+
* @param {string} exceptConnId
|
|
394
|
+
* @param {Object} envelope
|
|
395
|
+
* @private
|
|
396
|
+
*/
|
|
397
|
+
_fanOutToOthers(exceptConnId, envelope) {
|
|
398
|
+
const data = JSON.stringify(envelope)
|
|
399
|
+
for (const [connId, bridge] of this._bridges) {
|
|
400
|
+
if (connId !== exceptConnId) this._sendSerialized(bridge.socket, data)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 한 채널의 세션을 가진 bridge 들로 fan-out (origin 제외, 중복 bridge 1회). 직렬화 1회(L5).
|
|
406
|
+
*
|
|
407
|
+
* `exceptSessionIds` 가 있으면 해당 세션은 bridge 선정에서 제외한다(ADR-098). 어떤 bridge 의
|
|
408
|
+
* 채널 세션이 **전부** 제외 대상이면 그 bridge 는 통째로 스킵된다(불필요한 전송 절약). 일부만 제외면
|
|
409
|
+
* bridge 는 받아서 로컬에서 해당 세션만 건너뛴다(envelope 의 payload.exceptSessionIds 가 그대로 전달됨).
|
|
410
|
+
*
|
|
411
|
+
* @param {string} channel
|
|
412
|
+
* @param {string} exceptConnId
|
|
413
|
+
* @param {Object} envelope
|
|
414
|
+
* @param {string[]} [exceptSessionIds] - fan-out 에서 뺄 세션 목록(BROADCAST payload).
|
|
415
|
+
* @private
|
|
416
|
+
*/
|
|
417
|
+
_fanOutChannel(channel, exceptConnId, envelope, exceptSessionIds) {
|
|
418
|
+
const sids = this._channelSessions.get(channel)
|
|
419
|
+
if (!sids) return
|
|
420
|
+
const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
|
|
421
|
+
const targets = new Set()
|
|
422
|
+
for (const sid of sids) {
|
|
423
|
+
if (except && except.has(sid)) continue // 제외 세션은 bridge 선정에 기여하지 않음
|
|
424
|
+
const session = this._sessions.get(sid)
|
|
425
|
+
if (session && session.bridgeConnId !== exceptConnId) targets.add(session.bridgeConnId)
|
|
426
|
+
}
|
|
427
|
+
if (targets.size === 0) return
|
|
428
|
+
const data = JSON.stringify(envelope)
|
|
429
|
+
for (const connId of targets) this._sendTo(connId, data)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 한 user 의 세션을 가진 bridge 들로 fan-out (DIRECT, ADR-035). origin 제외(L7) — origin 은
|
|
434
|
+
* local 직접 전달하므로 hub 가 되돌리지 않는다(BROADCAST 와 동일 패턴). 직렬화 1회(L5).
|
|
435
|
+
* @param {string} userId
|
|
436
|
+
* @param {Object} envelope
|
|
437
|
+
* @param {string} [exceptConnId] - 제외할 origin bridge connId.
|
|
438
|
+
* @private
|
|
439
|
+
*/
|
|
440
|
+
_fanOutUser(userId, envelope, exceptConnId) {
|
|
441
|
+
const sids = this._userSessions.get(userId)
|
|
442
|
+
if (!sids) return
|
|
443
|
+
const targets = new Set()
|
|
444
|
+
for (const sid of sids) {
|
|
445
|
+
const session = this._sessions.get(sid)
|
|
446
|
+
if (session && session.bridgeConnId !== exceptConnId) targets.add(session.bridgeConnId)
|
|
447
|
+
}
|
|
448
|
+
if (targets.size === 0) return
|
|
449
|
+
const data = JSON.stringify(envelope)
|
|
450
|
+
for (const connId of targets) this._sendTo(connId, data)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* connId 로 직렬화된 프레임 송신 (등록된 bridge 한정).
|
|
455
|
+
* @param {string} connId
|
|
456
|
+
* @param {string} serialized - 이미 JSON.stringify 된 envelope.
|
|
457
|
+
* @private
|
|
458
|
+
*/
|
|
459
|
+
_sendTo(connId, serialized) {
|
|
460
|
+
const bridge = this._bridges.get(connId)
|
|
461
|
+
if (bridge) this._sendSerialized(bridge.socket, serialized)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 단일 envelope 송신 — 직렬화 후 위임 (register_ok/error/heartbeat 등 1:1 송신용).
|
|
466
|
+
* @param {import('ws').WebSocket} socket
|
|
467
|
+
* @param {Object} envelope
|
|
468
|
+
* @private
|
|
469
|
+
*/
|
|
470
|
+
_safeSend(socket, envelope) {
|
|
471
|
+
this._sendSerialized(socket, JSON.stringify(envelope))
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* 직렬화된 프레임 소켓 송신 — 닫히는 중 등 실패는 비치명적(이유+로그).
|
|
476
|
+
* @param {import('ws').WebSocket} socket
|
|
477
|
+
* @param {string} serialized
|
|
478
|
+
* @private
|
|
479
|
+
*/
|
|
480
|
+
_sendSerialized(socket, serialized) {
|
|
481
|
+
try {
|
|
482
|
+
socket.send(serialized)
|
|
483
|
+
} catch (err) {
|
|
484
|
+
this._log?.debug?.({ err }, 'ws-hub send failed (socket closing)')
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* heartbeat liveness 감시(M3) — heartbeatMs*2 이상 HEARTBEAT 미수신 bridge 를 정리 후 terminate.
|
|
490
|
+
* half-open 연결(TCP 는 살아있으나 상대가 죽음)이 presence 에 영구 잔존하는 것을 막는다.
|
|
491
|
+
* @private
|
|
492
|
+
*/
|
|
493
|
+
_checkLiveness() {
|
|
494
|
+
const now = Date.now()
|
|
495
|
+
const deadline = this._heartbeatMs * 2
|
|
496
|
+
/** @type {string[]} 반복 중 Map 변형을 피하려 먼저 수집. */
|
|
497
|
+
const stale = []
|
|
498
|
+
for (const [connId, bridge] of this._bridges) {
|
|
499
|
+
if (now - bridge.lastSeen > deadline) stale.push(connId)
|
|
500
|
+
}
|
|
501
|
+
for (const connId of stale) {
|
|
502
|
+
const bridge = this._bridges.get(connId)
|
|
503
|
+
if (!bridge) continue
|
|
504
|
+
this._log?.warn?.({ connId, sinceMs: now - bridge.lastSeen }, 'ws-hub bridge heartbeat timeout — terminating')
|
|
505
|
+
// 먼저 presence 정리 + BULK_LEAVE fan-out(이후 close 핸들러의 _handleBridgeGone 은 멱등 no-op).
|
|
506
|
+
this._handleBridgeGone(connId)
|
|
507
|
+
bridge.socket.terminate()
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* hub 종료 — liveness 타이머 정리 + 모든 bridge 연결 닫고 서버 close.
|
|
513
|
+
*
|
|
514
|
+
* `drain: true` 면 bridge 소켓을 close code **4503**(DRAIN, ADR-098) 로 닫는다 — bridge 가 "다른 hub
|
|
515
|
+
* 인스턴스로 재연결하라" 로 해석한다(LB/mesh 회전). 기본(false)은 1001(GOING_AWAY) — 일반 종료.
|
|
516
|
+
*
|
|
517
|
+
* @param {{ drain?: boolean }} [opts]
|
|
518
|
+
* @returns {Promise<void>}
|
|
519
|
+
*/
|
|
520
|
+
async stop({ drain = false } = {}) {
|
|
521
|
+
if (this._livenessTimer) {
|
|
522
|
+
clearInterval(this._livenessTimer)
|
|
523
|
+
this._livenessTimer = null
|
|
524
|
+
}
|
|
525
|
+
if (!this._wss) return
|
|
526
|
+
const wss = this._wss
|
|
527
|
+
this._wss = null
|
|
528
|
+
const code = drain ? HUB_CLOSE_CODES.DRAIN : HUB_CLOSE_CODES.GOING_AWAY
|
|
529
|
+
const reason = drain ? 'hub draining' : 'hub shutting down'
|
|
530
|
+
if (drain) this._log?.info?.({ hubId: this._hubId, bridges: this._bridges.size }, 'ws-hub draining (4503)')
|
|
531
|
+
for (const { socket } of this._bridges.values()) socket.close(code, reason)
|
|
532
|
+
this._bridges.clear()
|
|
533
|
+
this._sessions.clear()
|
|
534
|
+
this._channelSessions.clear()
|
|
535
|
+
this._userSessions.clear()
|
|
536
|
+
await new Promise((resolve, reject) => {
|
|
537
|
+
wss.close((/** @type {Error | undefined} */ err) => (err ? reject(err) : resolve(undefined)))
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* CLI 진입점 — env 로 설정 읽어 hub 기동 (bin/mega-ws-hub.js 에서 호출).
|
|
544
|
+
*
|
|
545
|
+
* env: MEGA_WSHUB_TOKENS (필수, 콤마구분 acceptedTokens), MEGA_WSHUB_PORT (기본 3100),
|
|
546
|
+
* MEGA_WSHUB_HOST (기본 0.0.0.0), MEGA_WSHUB_HEARTBEAT_MS (선택), MEGA_WSHUB_MAX_PAYLOAD (선택, bytes),
|
|
547
|
+
* MEGA_WSHUB_COMPRESSION ('true' 면 압축 ON, ADR-078 디폴트값으로),
|
|
548
|
+
* MEGA_WSHUB_COMPRESSION_THRESHOLD (선택, bytes — 압축 ON 일 때만 적용).
|
|
549
|
+
* 향후 mega.config.js 의 wsHub 블록 로딩으로 대체 (ADR-068) — 그 시점에 6 필드 전체가
|
|
550
|
+
* config 로 전달된다. 지금 CLI 는 env 한정이라 enabled + threshold 만 노출(나머지는 ADR-078 디폴트).
|
|
551
|
+
*
|
|
552
|
+
* SIGTERM/SIGINT 시 MegaShutdown 이 hub.stop({ drain: true })(bridge 소켓 4503 + wss.close)을
|
|
553
|
+
* 실행한다(L2 + drain) — 독립 hub 종료는 "다른 인스턴스로 재연결" 신호(4503)가 맞다(ADR-098).
|
|
554
|
+
*
|
|
555
|
+
* @returns {Promise<MegaWsHub>}
|
|
556
|
+
*/
|
|
557
|
+
export async function runWsHubCli() {
|
|
558
|
+
const raw = process.env.MEGA_WSHUB_TOKENS
|
|
559
|
+
const acceptedTokens = typeof raw === 'string' ? raw.split(',').map((t) => t.trim()).filter(Boolean) : []
|
|
560
|
+
if (acceptedTokens.length === 0) {
|
|
561
|
+
throw new Error('mega ws-hub: MEGA_WSHUB_TOKENS env is required (comma-separated, ADR-059).')
|
|
562
|
+
}
|
|
563
|
+
const port = Number(process.env.MEGA_WSHUB_PORT ?? 3100)
|
|
564
|
+
const host = process.env.MEGA_WSHUB_HOST ?? '0.0.0.0'
|
|
565
|
+
const hbRaw = process.env.MEGA_WSHUB_HEARTBEAT_MS
|
|
566
|
+
const heartbeatMs = hbRaw ? Number(hbRaw) : DEFAULT_HEARTBEAT_MS
|
|
567
|
+
const mpRaw = process.env.MEGA_WSHUB_MAX_PAYLOAD
|
|
568
|
+
const maxPayloadBytes = mpRaw ? Number(mpRaw) : DEFAULT_MAX_PAYLOAD_BYTES
|
|
569
|
+
// 압축(ADR-078) — env 로 ON/threshold 만 노출. enabled=false 면 compression 미전달(OFF).
|
|
570
|
+
const compressionOn = process.env.MEGA_WSHUB_COMPRESSION === 'true'
|
|
571
|
+
const thrRaw = process.env.MEGA_WSHUB_COMPRESSION_THRESHOLD
|
|
572
|
+
const compression = compressionOn
|
|
573
|
+
? { enabled: true, threshold: thrRaw ? Number(thrRaw) : COMPRESSION_DEFAULTS.threshold }
|
|
574
|
+
: undefined
|
|
575
|
+
const hub = new MegaWsHub({ acceptedTokens, heartbeatMs, maxPayloadBytes, compression, logger: console })
|
|
576
|
+
const addr = await hub.start({ port, host })
|
|
577
|
+
// 독립 hub 프로세스 graceful shutdown(L2 + drain) — SIGTERM/SIGINT → hub.stop({ drain: true }).
|
|
578
|
+
MegaShutdown.register('mega-ws-hub', async () => hub.stop({ drain: true }))
|
|
579
|
+
MegaShutdown.setupSignals()
|
|
580
|
+
console.log(`[mega:ws-hub] listening on ${addr.host}:${addr.port} (hubId=${hub.hubId})`)
|
|
581
|
+
return hub
|
|
582
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* AJV(Fastify 네이티브 JSON Schema 검증) 에러 → MegaValidationError 매퍼 (ADR-090).
|
|
4
|
+
*
|
|
5
|
+
* @module core/ajv-mapper
|
|
6
|
+
*/
|
|
7
|
+
import { MegaValidationError } from '../errors/http-errors.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 민감 필드명 패턴 (ADR-090 PII 마스킹). 매칭 시 detail.value 를 '[REDACTED]' 로 치환.
|
|
11
|
+
* 마지막 필드 세그먼트 기준 부분 일치 (대소문자 무시). 시크릿 절대 노출 금지 원칙과 정합.
|
|
12
|
+
*/
|
|
13
|
+
const SENSITIVE_FIELD_RE = /(password|secret|token)/i
|
|
14
|
+
|
|
15
|
+
/** PII 마스킹된 value 표기. */
|
|
16
|
+
const REDACTED = '[REDACTED]'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fastify 가 던진 AJV 검증 에러 객체 → MegaValidationError 로 매핑.
|
|
20
|
+
*
|
|
21
|
+
* Fastify 의 AJV 에러는 `err.validation: AjvErrorObject[]` + `err.validationContext: string`
|
|
22
|
+
* 형태. AjvErrorObject 는 { instancePath, schemaPath, keyword, message, params } 가짐.
|
|
23
|
+
*
|
|
24
|
+
* envelope details 형식 (ADR-075 배열):
|
|
25
|
+
* [{ field: string, rule: string, value?: any, message: string }, ...]
|
|
26
|
+
*
|
|
27
|
+
* @param {Error & { validation?: any[], validationContext?: string }} err
|
|
28
|
+
* @returns {MegaValidationError | null}
|
|
29
|
+
* AJV 에러가 아니면 null (호출자가 그대로 throw).
|
|
30
|
+
*/
|
|
31
|
+
export function ajvErrorToValidationError(err) {
|
|
32
|
+
if (!err || !Array.isArray(err.validation)) return null
|
|
33
|
+
const details = err.validation.map((e) => ajvItemToDetail(e))
|
|
34
|
+
const context = err.validationContext ?? 'request'
|
|
35
|
+
return new MegaValidationError('validation.failed', `Validation failed for ${context}`, {
|
|
36
|
+
details,
|
|
37
|
+
cause: err,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 단일 AJV 에러 항목 → envelope detail. 민감 필드는 value 마스킹 (ADR-090).
|
|
43
|
+
* @param {{ instancePath?: string, dataPath?: string, keyword?: string, message?: string, params?: any }} e
|
|
44
|
+
* @returns {{ field: string, rule: string, value: any, message: string }}
|
|
45
|
+
*/
|
|
46
|
+
function ajvItemToDetail(e) {
|
|
47
|
+
// instancePath 예: '/email' → field = 'email'. '/items/0/name' → 'items[0].name'
|
|
48
|
+
const field = ajvPathToField(e.instancePath || e.dataPath || '')
|
|
49
|
+
// ADR-090: required 같은 키워드는 instancePath 가 부모라 params.missingProperty 가 실제 필드.
|
|
50
|
+
// 마스킹 판단은 instancePath 필드 + missingProperty 둘 다 본다.
|
|
51
|
+
const missing = e.params && typeof e.params.missingProperty === 'string' ? e.params.missingProperty : ''
|
|
52
|
+
const isSensitive = SENSITIVE_FIELD_RE.test(field) || SENSITIVE_FIELD_RE.test(missing)
|
|
53
|
+
return {
|
|
54
|
+
field,
|
|
55
|
+
rule: e.keyword || 'unknown',
|
|
56
|
+
value: isSensitive ? REDACTED : e.params,
|
|
57
|
+
message: e.message || 'invalid',
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* AJV instancePath → 사람이 읽는 field 경로.
|
|
63
|
+
* 슬래시 분리 후 숫자 인덱스는 `[n]`, 나머지는 dot 연결.
|
|
64
|
+
* 예: '/email' → 'email', '/items/0/name' → 'items[0].name'.
|
|
65
|
+
* @param {string} path
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function ajvPathToField(path) {
|
|
69
|
+
if (!path) return ''
|
|
70
|
+
const parts = path.split('/').filter(Boolean)
|
|
71
|
+
let out = ''
|
|
72
|
+
for (const p of parts) {
|
|
73
|
+
if (/^\d+$/.test(p)) {
|
|
74
|
+
out += `[${p}]`
|
|
75
|
+
} else {
|
|
76
|
+
out += out ? `.${p}` : p
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|