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,1138 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import Fastify from 'fastify'
|
|
3
|
+
import { WebSocketServer } from 'ws'
|
|
4
|
+
import { wrapEnvelope, REPLY_START_SYMBOL } from './envelope.js'
|
|
5
|
+
import { buildErrorHandler } from './error-mapper.js'
|
|
6
|
+
import { Router } from './router.js'
|
|
7
|
+
import { driveWsConnection, createPlainCodec, createAspCodec, rejectUpgrade } from './ws-upgrade.js'
|
|
8
|
+
import { buildPerMessageDeflate } from './ws-compression.js'
|
|
9
|
+
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
|
+
import * as MegaHealth from '../lib/mega-health.js'
|
|
13
|
+
import { MegaShutdown } from '../lib/mega-shutdown.js'
|
|
14
|
+
import { buildAdapterAccessors, getHttpCtx } from './ctx-builder.js'
|
|
15
|
+
import { registerSecurityPlugins } from './security.js'
|
|
16
|
+
import { registerMultipart } from './multipart.js'
|
|
17
|
+
import { registerSession } from './session.js'
|
|
18
|
+
import { registerI18n } from './i18n.js'
|
|
19
|
+
import { registerTemplate } from './template.js'
|
|
20
|
+
import { registerFormbody } from './formbody.js'
|
|
21
|
+
import { registerStaticAssets } from './static-assets.js'
|
|
22
|
+
import { registerOpenapi } from './openapi.js'
|
|
23
|
+
import { createSessionStore } from './session-store.js'
|
|
24
|
+
import { MegaBruteForce } from '../lib/mega-brute-force.js'
|
|
25
|
+
import { MegaAspDecryptError } from '../lib/asp/errors.js'
|
|
26
|
+
import * as MegaTracing from '../lib/mega-tracing.js'
|
|
27
|
+
import * as MegaMetrics from '../lib/mega-metrics.js'
|
|
28
|
+
import { collectCluster } from './cluster-metrics.js'
|
|
29
|
+
import { MegaConfigError } from '../errors/config-error.js'
|
|
30
|
+
|
|
31
|
+
/** HTTP 루트 span 핸들을 reply 에 보관하는 전용 symbol(onResponse 가 꺼내 종료). */
|
|
32
|
+
const HTTP_SPAN_SYMBOL = Symbol('mega.httpSpan')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 브라우저↔Bridge WS 프레임 최대 크기 디폴트 (bytes, L-3 / ADR-099).
|
|
36
|
+
* 1 MiB — Hub(`DEFAULT_MAX_PAYLOAD_BYTES`, src/cli/ws-hub.js)와 대칭. 초과 프레임은 ws 가 1009 close.
|
|
37
|
+
* core→cli 역방향 import 를 피하려 값을 여기 별도 정의(두 곳 모두 1 MiB 로 동일).
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_WS_MAX_PAYLOAD_BYTES = 1_048_576
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* MegaApp — 한 도메인 집합(hosts)에 대응하는 Fastify 인스턴스 래퍼.
|
|
43
|
+
*
|
|
44
|
+
* baseline — Fastify 인스턴스 보유, listen/close 라이프사이클.
|
|
45
|
+
* 라우터 등록·미들웨어 통합.
|
|
46
|
+
* 자동 envelope (ADR-018) + 글로벌 에러 핸들러 (ADR-025/090).
|
|
47
|
+
*
|
|
48
|
+
* ADR-002 (Fastify), ADR-063 (앱당 Fastify 인스턴스 격리), ADR-004 (dev *.localhost).
|
|
49
|
+
*/
|
|
50
|
+
export class MegaApp {
|
|
51
|
+
/**
|
|
52
|
+
* @param {Object} opts
|
|
53
|
+
* @param {string} opts.name - 앱 이름 (apps/<name> 폴더와 일치, ADR-067 검증됨)
|
|
54
|
+
* @param {string[]} [opts.hosts] - 도메인 매핑 (scaffold 모드 필수, single 모드 옵셔널)
|
|
55
|
+
* @param {'scaffold'|'single'} [opts.mode='scaffold'] - 부팅 모드 (ADR-013)
|
|
56
|
+
* @param {Object} [opts.fastifyOptions] - Fastify 생성 옵션 (logger 등)
|
|
57
|
+
* @param {import('pino').Logger|false} [opts.logger] - pino 로거 인스턴스 (ADR-023/141). bootApp 이
|
|
58
|
+
* `global.logger`(MegaLoggerConfig)로 만들어 모든 앱에 주입한다. 미주입이면 `false`(무로그). Fastify 가
|
|
59
|
+
* 요청별 child(`req.log`, reqId 바인딩)를 만들고, `ctx.log` 가 이를 노출한다(trace_id 는 mixin 자동 주입).
|
|
60
|
+
* @param {boolean} [opts.exposeInternalDetails=false] - true 면 일반 Error 메시지를
|
|
61
|
+
* envelope 에 노출 (디버그용). 기본 false — 보안 (ADR-025).
|
|
62
|
+
* @param {Object|false} [opts.helmet] - fastify-helmet 옵션 (보안 헤더). false=미등록, undefined=디폴트 ON
|
|
63
|
+
* (ADR-047/127). 라우트 옵션 오버라이드는 완전 교체(ADR-073).
|
|
64
|
+
* @param {Object|false} [opts.cors] - fastify-cors 옵션. false=미등록, undefined=origin:false(교차출처 거부, 안전 디폴트).
|
|
65
|
+
* @param {Object|false} [opts.rateLimit] - fastify-rate-limit 옵션. false=미등록, undefined=디폴트(IP당 100/min, ADR-048).
|
|
66
|
+
* 멀티 인스턴스 분산 카운팅은 `caches.rate` 별명(Redis), 미선언 시 in-memory 폴백.
|
|
67
|
+
* @param {Object|false} [opts.csrf] - fastify-csrf-protection 옵션. false=미등록, undefined=디폴트 ON.
|
|
68
|
+
* ADR-051: JSON 요청은 토큰 면제+Origin 검증, 폼 요청만 토큰 검증.
|
|
69
|
+
* @param {{ masterSecret?: string, http?: { enabledPaths?: string[], driftMs?: number, headerSignal?: string, timestampHeader?: string, nonceCache?: any }, websocket?: { namespaces?: string[] } }} [opts.asp] -
|
|
70
|
+
* ASP 옵트인. `masterSecret` 있으면 모든 WS 프레임이 `E:`/`P:` prefix 코덱 경유;
|
|
71
|
+
* `websocket.namespaces` 에 포함된 경로만 기본 암호화(`E:`), 나머지는 평문(`P:`, ADR-083). `http.enabledPaths`
|
|
72
|
+
* 가 있으면 그 glob 경로에 HTTP ASP terminator 자동 등록(ADR-127). 미지정 시 raw JSON WS.
|
|
73
|
+
* @param {{ compression?: import('./ws-compression.js').WsCompressionConfig, maxPayloadBytes?: number }} [opts.websocket] -
|
|
74
|
+
* 브라우저↔Bridge WS 옵션 (ADR-078 / MegaWebsocketAppConfig). `compression`
|
|
75
|
+
* 블록으로 per-message deflate 옵트인. 디폴트 OFF. 부팅 시 threshold 음수 / serverMaxWindowBits
|
|
76
|
+
* 범위(9~15) 위반 throw. `maxPayloadBytes` 는 WS 프레임 최대 크기(L-3, 디폴트 1 MiB — Hub 와 대칭);
|
|
77
|
+
* 초과 프레임은 ws 가 1009 close. 양의 정수 아니면 디폴트로 폴백(부팅 검증은 config-validator 가 throw).
|
|
78
|
+
* @param {Record<string, string>} [opts.databases] - 어댑터 별명 맵 `{ alias: globalKey }` (ADR-102
|
|
79
|
+
* 글로벌 공유). `ctx.db(alias)` 가 globalKey 로 바꿔 전역 공유 인스턴스(MegaAdapterManager)를 반환.
|
|
80
|
+
* 실 인스턴스는 `mega.config.js` 의 `services.databases.<globalKey>` 에 정의되고 매니저가 소유·connect.
|
|
81
|
+
* @param {Record<string, string>} [opts.caches] - 캐시 별명 맵 `{ alias: globalKey }` (`ctx.cache(alias)`).
|
|
82
|
+
* @param {Record<string, string>} [opts.buses] - 버스 별명 맵 `{ alias: globalKey }` (`ctx.bus(alias)`).
|
|
83
|
+
* @param {Record<string, string>} [opts.locks] - 락 별명 맵 `{ alias: globalKey }` (`ctx.lock(alias)`, ADR-113).
|
|
84
|
+
* @param {{ maxFileSize?: number, maxFiles?: number, allowedMimeTypes?: string[] }} [opts.upload] -
|
|
85
|
+
* 파일 업로드 옵트인 (MegaUploadConfig, per ADR-133). 있으면 `@fastify/multipart` 를 자동 등록해
|
|
86
|
+
* `req.file()`/`req.files()`/`req.saveUploads(dir)` 를 제공한다(없으면 multipart 요청 415).
|
|
87
|
+
* `maxFileSize`(디폴트 10 MB)·`maxFiles`(미지정=무제한)·`allowedMimeTypes`(빈 배열=전부 허용, `image/*`
|
|
88
|
+
* 와일드카드 지원). MIME 화이트리스트 위반은 415, 크기·개수 초과는 413. CSRF(폼 토큰)는 보안 플러그인이,
|
|
89
|
+
* ASP(body 평문 통과)는 ASP terminator 가 자동 통합. 트레이싱 `mega.upload.*` + 메트릭 `mega_upload_*`.
|
|
90
|
+
* @param {false | { store: { driver: 'file'|'redis', [k: string]: any }, secret?: string, ttlMs?: number, rolling?: boolean, cookie?: object, csrf?: boolean }} [opts.session] -
|
|
91
|
+
* 세션 옵트인 (ADR-129/046). `store` = 백엔드(file/redis) inline config. `secret` =
|
|
92
|
+
* 쿠키 HMAC 시크릿(미지정 시 `opts.sessionSecret` — bootApp 이 global `server.sessionSecret` 주입).
|
|
93
|
+
* `ttlMs`(기본 24h)·`rolling`(기본 true)·`cookie`(httpOnly/secure/sameSite). `csrf:true` 면 CSRF 를
|
|
94
|
+
* 세션 모드로(시크릿을 세션 `_csrf` 키에 바인딩). `false`/미지정 → 세션 비활성.
|
|
95
|
+
* @param {string} [opts.sessionSecret] - 세션 쿠키 HMAC 시크릿(global `server.sessionSecret`). `session.secret` 미지정 시 사용.
|
|
96
|
+
* @param {{ default?: string, available?: string[], fallback?: string, cookieName?: string, autoComplete?: { enabled?: boolean, dir?: string, debounceMs?: number }, localesDir?: string, resources?: object, exposeTranslations?: boolean, translationsPath?: string }} [opts.i18n] -
|
|
97
|
+
* i18n 옵트인 (ADR-037/038/039/135). 있으면 앱 전용 `i18next` 인스턴스를 등록해
|
|
98
|
+
* `req.lang`/`req.t`/`req.setLocale`/`req.translations` + `ctx.lang`/`ctx.t` 를 제공한다(없으면 ctx.t 는
|
|
99
|
+
* passthrough). 언어 결정은 **쿠키만**(ADR-038, `cookieName` 디폴트 `mega.lang`). scope 분리(server/client,
|
|
100
|
+
* ADR-039) — `ctx.t()` 는 server scope, `GET /i18n/translations` 는 client scope 만 노출. dev 면 누락 키
|
|
101
|
+
* 자동 생성(saveMissing axion 패턴, `autoComplete`). 트레이싱 `mega.i18n.*` + 메트릭 `mega_i18n_*`.
|
|
102
|
+
* @param {{ dir?: string, layoutDir?: string, partialsDir?: string, defaultLayout?: string, cache?: boolean }} [opts.views] -
|
|
103
|
+
* 서버사이드 템플릿 옵트인 (ADR-011/136 — EJS + ejs-mate). `dir`(뷰 루트, 예
|
|
104
|
+
* `apps/<name>/views`)이 있을 때만 `reply.render(view, data, { layout })`(정본 res.render) + `ctx.render`
|
|
105
|
+
* 를 제공한다(없으면 미정의 → 호출 시 fail-fast). 렌더 data 에 요청별 i18n `t`/`lang` 자동 병합(다국어 뷰).
|
|
106
|
+
* XSS auto-escape(`<%=`) 기본 + 경로 탐색 차단. 트레이싱 `mega.template.*` + 메트릭 `mega_template_*`.
|
|
107
|
+
* @param {{ enabled?: boolean, path?: string, info?: { title?: string, version?: string, description?: string }, auth?: Function[], theme?: Object }} [opts.openapi] -
|
|
108
|
+
* OpenAPI/Swagger 옵트인 (MegaOpenApiAppConfig, ADR-070/140 — `@fastify/swagger` + `@fastify/swagger-ui`).
|
|
109
|
+
* `enabled:true` 일 때만 라우트 schema 를 OpenAPI 3.x 명세로 수집하고 `path`(디폴트 `/docs`)에 swagger-ui 를
|
|
110
|
+
* 등록한다(디폴트 OFF — 프로덕션 노출 방지). `info`={title,version,description}. `auth` 미들웨어 배열로 docs
|
|
111
|
+
* 접근 보호(빈 배열=공개, `(req, reply, ctx)` 시그니처). `theme`=swagger-ui uiConfig. 라우트 옵션
|
|
112
|
+
* `openapi:{tags,summary,description,deprecated}` 가 명세에 반영(router.js).
|
|
113
|
+
* @param {{ enabled?: boolean, dir?: string, prefix?: string, cacheControl?: string, dotfiles?: boolean }} [opts.staticAssets] -
|
|
114
|
+
* 정적 자산 옵트인 (MegaStaticAssetsAppConfig, ADR-071/139 — `@fastify/static`). `enabled:true` + 실존 `dir`
|
|
115
|
+
* (서빙 루트, 예 `apps/<name>/public`)일 때만 `${prefix}/<파일>`(prefix 디폴트 `/static`)로 디스크 파일을 서빙
|
|
116
|
+
* (디폴트 OFF). `cacheControl` = raw `Cache-Control` 헤더 문자열(예 `'public, max-age=3600'`). `dotfiles`
|
|
117
|
+
* 디폴트 false(`.git`/`.env` 차단). `enabled:true` 인데 `dir` 누락/미존재 시 프로덕션 부팅 throw / dev warn+skip.
|
|
118
|
+
* prefix 가 `health.metricsPath` 와 같으면 부팅 throw(ADR-072).
|
|
119
|
+
* @param {{ enabled?: boolean, paths?: { live?: string, ready?: string }, exposeMetrics?: boolean, metricsPath?: string, metricsAllowList?: string[] }} [opts.health] -
|
|
120
|
+
* 운영 관측성 config(Global-only, ADR-072/131). bootApp 이 global `health` 블록을 주입한다.
|
|
121
|
+
* `exposeMetrics:true` 면 `metricsPath`(디폴트 `/metrics`)에 Prometheus `/metrics` 라우트를 등록(보안 면제).
|
|
122
|
+
* `metricsAllowList` = 접근 허용 IP/CIDR(빈 배열이면 메인 포트 전체 노출, ADR-131). metricsPath 가 health
|
|
123
|
+
* 경로와 충돌하면 부팅 throw. SDK 초기화(MegaMetrics.init)는 bootApp/prepareRuntime 이 담당.
|
|
124
|
+
* @param {Array<{ plugin: Function, opts?: object }>} [opts.plugins] - 플러그인이 `mega.app.use(...)`
|
|
125
|
+
* 로 등록한 Fastify 플러그인 묶음(정본 §14 hook표 — "모든 앱 인스턴스에 등록"). 부팅 orchestrator
|
|
126
|
+
* (`bootApp`)가 `MegaPluginHost.fastifyPlugins` 를 모든 앱에 주입한다(ADR-123). 각 원소는
|
|
127
|
+
* `this.fastify.register(plugin, opts)` 로 등록.
|
|
128
|
+
* @param {Function[]} [opts.globalMiddlewares] - 플러그인이 `mega.middlewares.global(...)` 로 등록한
|
|
129
|
+
* 글로벌 미들웨어(모든 라우트 적용). orchestrator 가 주입한다. 계약: `async (req, reply, ctx) => void`
|
|
130
|
+
* — 라우트 핸들러와 동일한 canonical 시그니처(ADR-074/134). `ctx` 는 요청당 1회 만들어 핸들러와
|
|
131
|
+
* 공유하므로, 미들웨어가 `ctx` 에 심은 값을 핸들러가 본다. 기존 `(req, reply)` 미들웨어는 3번째
|
|
132
|
+
* 인자를 무시하므로 하위 호환.
|
|
133
|
+
*/
|
|
134
|
+
constructor(opts) {
|
|
135
|
+
if (!opts || typeof opts.name !== 'string' || opts.name.length === 0) {
|
|
136
|
+
throw new Error('MegaApp: name is required')
|
|
137
|
+
}
|
|
138
|
+
this.name = opts.name
|
|
139
|
+
this.hosts = Array.isArray(opts.hosts) ? [...opts.hosts] : []
|
|
140
|
+
this.mode = opts.mode === 'single' ? 'single' : 'scaffold'
|
|
141
|
+
|
|
142
|
+
// 어댑터 별명 맵 (app.config.js 의 databases/caches/buses: { alias → globalKey }, ADR-102 글로벌
|
|
143
|
+
// 공유). 별명→globalKey→전역 공유 인스턴스(MegaAdapterManager)로 해석하는 ctx.db/cache/bus 접근자를
|
|
144
|
+
// 앱당 한 번 만들어 둔다(요청과 무관하게 안정적). 인스턴스 자체는 전역 매니저가 소유·connect.
|
|
145
|
+
/** @type {import('./ctx-builder.js').AdapterAccessors} */
|
|
146
|
+
this._adapterAccessors = buildAdapterAccessors(
|
|
147
|
+
{ databases: opts.databases, caches: opts.caches, buses: opts.buses, locks: opts.locks },
|
|
148
|
+
opts.name,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
/** @type {MegaBruteForce|null} ctx.bruteForce 단축의 lazy 인스턴스(앱당 1개 재사용). */
|
|
152
|
+
this._bruteForce = null
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* name → 서비스 클래스. 부팅 시 services-loader 가 `apps/<name>/services/*.js` 를 스캔해 채운다
|
|
156
|
+
* (ADR-148). 요청별 `ctx.services.<name>` lazy DI 가 이 맵을 보고 인스턴스화한다. 기본은 빈 Map
|
|
157
|
+
* (서비스 없는 앱·standalone 테스트).
|
|
158
|
+
* @type {Map<string, Function>}
|
|
159
|
+
*/
|
|
160
|
+
this._serviceRegistry = new Map()
|
|
161
|
+
|
|
162
|
+
/** @type {string|null} OpenAPI 옵트인 시 swagger-ui 경로(ADR-140). envelope onRoute 가 이 경로를 제외. */
|
|
163
|
+
this._openapiPath = null
|
|
164
|
+
|
|
165
|
+
// WS ASP 옵트인 정규화. masterSecret 없으면 null = 평문 WS.
|
|
166
|
+
this._wsAsp = MegaApp._normalizeWsAsp(opts.asp)
|
|
167
|
+
// 브라우저↔Bridge WS 압축 (ADR-078). enabled=false → false (압축 OFF).
|
|
168
|
+
// 생성자에서 build → 잘못된 threshold/windowBits 면 즉시 throw(부팅 fail-fast).
|
|
169
|
+
/** @type {false | Object} _ensureWss 의 WebSocketServer perMessageDeflate 로 전달. */
|
|
170
|
+
this._wsPerMessageDeflate = buildPerMessageDeflate(opts.websocket?.compression, 'websocket.compression')
|
|
171
|
+
// WS 프레임 최대 크기 (L-3, ADR-099). Hub 와 동일하게 양의 정수만 인정, 그 외 디폴트 1 MiB 폴백.
|
|
172
|
+
// 부팅(config-validator)에서 잘못된 값은 이미 throw 되지만, 직접 생성 경로(single·테스트)는 폴백.
|
|
173
|
+
const maxPayloadRaw = opts.websocket?.maxPayloadBytes
|
|
174
|
+
/** @type {number} _ensureWss 의 WebSocketServer maxPayload 로 전달. */
|
|
175
|
+
this._wsMaxPayloadBytes =
|
|
176
|
+
Number.isInteger(maxPayloadRaw) && /** @type {number} */ (maxPayloadRaw) > 0
|
|
177
|
+
? /** @type {number} */ (maxPayloadRaw)
|
|
178
|
+
: DEFAULT_WS_MAX_PAYLOAD_BYTES
|
|
179
|
+
/** @type {Router|null} single 모드 직접 등록(app.ws)용 lazy 라우터. */
|
|
180
|
+
this._router = null
|
|
181
|
+
/** @type {WebSocketServer|null} noServer 모드 WS 핸드셰이커 (lazy). */
|
|
182
|
+
this._wss = null
|
|
183
|
+
/** @type {MegaHubLink|null} hub 연결 (scaffold 권장). */
|
|
184
|
+
this._hubLink = null
|
|
185
|
+
/** ns(=ws path) → 활성 로컬 연결 집합 (hub broadcast 의 local fan-out 용). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
186
|
+
this._wsConns = new Map()
|
|
187
|
+
/** userId → 활성 로컬 연결 집합 (DIRECT 타겟팅, H-latent guard). @type {Map<string, Set<import('./ws-upgrade.js').MegaWsConnection>>} */
|
|
188
|
+
this._userConns = new Map()
|
|
189
|
+
/** sessionId → 활성 로컬 연결 1개 (세션단위 JOIN/LEAVE). @type {Map<string, import('./ws-upgrade.js').MegaWsConnection>} */
|
|
190
|
+
this._sessionConns = new Map()
|
|
191
|
+
/** @type {string[]} connectHub 으로 구독한 채널. */
|
|
192
|
+
this._hubChannels = []
|
|
193
|
+
/** connectHub 의 bridgeId (재연결 재구독·세션 JOIN 에 재사용). @type {string|null} */
|
|
194
|
+
this._hubBridgeId = null
|
|
195
|
+
|
|
196
|
+
this.fastify = Fastify({
|
|
197
|
+
// pino 로거(ADR-023/141) — bootApp 이 global.logger 로 만든 **인스턴스**를 주입(opts.logger). Fastify v5 는
|
|
198
|
+
// 인스턴스를 `loggerInstance` 로 받는다(`logger` 는 config 객체/bool 전용). 미주입이면 logger:false(무로그,
|
|
199
|
+
// 기존 동작·테스트 호환). 인스턴스를 받으면 Fastify 가 요청별 child(reqId 바인딩)를 만든다.
|
|
200
|
+
...(opts.logger ? { loggerInstance: opts.logger } : { logger: false }),
|
|
201
|
+
// request id 자동 부여는 Fastify v5 기본 제공 (meta.request_id 용)
|
|
202
|
+
...opts.fastifyOptions,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// 1) 응답 시작 시각 기록 (meta.took_ms 용, ADR-014)
|
|
206
|
+
this.fastify.addHook('onRequest', async (req, reply) => {
|
|
207
|
+
// REPLY_START_SYMBOL 은 Fastify 타입에 없는 우리 전용 symbol 키 — 부착 시 cast.
|
|
208
|
+
;(/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[REPLY_START_SYMBOL] =
|
|
209
|
+
Date.now()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// 1b) HTTP 루트 span (ADR-126) — 옵트인 OFF 면 isEnabled() false 라 즉시 return(0 비용).
|
|
213
|
+
// onRequest 에서 span 시작 + 활성 컨텍스트 진입(enterWith) → 핸들러의 ctx.tracer.span·어댑터 호출이
|
|
214
|
+
// 이 span 의 자식으로 중첩됨. onResponse 에서 status_code 기록 후 종료. 각 HTTP 요청은 독립 async
|
|
215
|
+
// context 라 enterWith 가 요청 간 누수 없이 격리된다(실측 확인 — ADR-126).
|
|
216
|
+
this.fastify.addHook('onRequest', async (req, reply) => {
|
|
217
|
+
if (!MegaTracing.isEnabled()) return
|
|
218
|
+
const route = /** @type {any} */ (req).routeOptions?.url ?? req.url
|
|
219
|
+
const host = String(req.headers.host ?? '').split(':')[0]
|
|
220
|
+
const handle = MegaTracing.enterHttpSpan({
|
|
221
|
+
method: req.method,
|
|
222
|
+
route,
|
|
223
|
+
path: req.url,
|
|
224
|
+
host,
|
|
225
|
+
app: this.name,
|
|
226
|
+
})
|
|
227
|
+
;(/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL] = handle
|
|
228
|
+
})
|
|
229
|
+
// onError 는 핸들러/직렬화 throw 시 호출 — 예외를 span 에 기록(상태 ERROR). 종료는 onResponse 담당.
|
|
230
|
+
this.fastify.addHook('onError', async (req, reply, err) => {
|
|
231
|
+
const handle = (/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL]
|
|
232
|
+
handle?.setError(err)
|
|
233
|
+
// 보안 거부(ASP 복호/drift/replay/signal)도 활성 span 에 사유 기록(ADR-127).
|
|
234
|
+
// CSRF/rate-limit 은 security.js 가 직접 박는다(throw 전·onExceeded). ASP 는 기존 코드 무변경 위해 여기서.
|
|
235
|
+
if (err instanceof MegaAspDecryptError) {
|
|
236
|
+
MegaTracing.tracer.activeSpan()?.setAttributes({ 'mega.security.reason': `asp.${err.rule}` })
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
// onResponse 는 성공·에러 응답 모두에서 마지막에 호출 — 상태코드 기록 후 span 종료(단일 종료 지점).
|
|
240
|
+
this.fastify.addHook('onResponse', async (req, reply) => {
|
|
241
|
+
const handle = (/** @type {Record<symbol, any>} */ (/** @type {unknown} */ (reply)))[HTTP_SPAN_SYMBOL]
|
|
242
|
+
handle?.finish(reply.statusCode)
|
|
243
|
+
// HTTP 요청 메트릭 (ADR-131) — 옵트인 OFF 면 isEnabled() false 라 즉시 return(0 비용).
|
|
244
|
+
// route 는 **매칭된 패턴**(routeOptions.url)만 — 매칭 안 되면(404) recordHttp 가 __unmatched__ 로 접어
|
|
245
|
+
// 카디널리티 폭증 차단. reply.elapsedTime = onRequest~onResponse ms(Fastify 제공).
|
|
246
|
+
if (MegaMetrics.isEnabled()) {
|
|
247
|
+
MegaMetrics.recordHttp({
|
|
248
|
+
method: req.method,
|
|
249
|
+
route: /** @type {any} */ (req).routeOptions?.url,
|
|
250
|
+
statusCode: reply.statusCode,
|
|
251
|
+
durationMs: reply.elapsedTime,
|
|
252
|
+
app: this.name,
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// 2) 자동 envelope (ADR-018, ADR-076).
|
|
258
|
+
// onRoute 훅으로 각 라우트의 preSerialization 체인 "맨 끝" 에 wrapEnvelope 를 붙인다.
|
|
259
|
+
// 이유: Fastify 는 글로벌 preSerialization 을 라우트 레벨보다 "먼저" 실행한다
|
|
260
|
+
// (encapsulation: outer → inner). 그래서 글로벌 addHook 으로 wrap 을 등록하면
|
|
261
|
+
// 라우트별 transform(ADR-091) 보다 wrap 이 먼저 돌아 순서가 뒤집힌다.
|
|
262
|
+
// onRoute 로 wrap 을 라우트 preSerialization 의 마지막 함수로 append 하면
|
|
263
|
+
// transform → wrap 순서가 보장된다 (ADR-076/091 정합). 검증: test/integration.
|
|
264
|
+
// 주의: onRoute 는 등록 "이후" 라우트만 잡으므로 /health 보다 먼저 등록해야 한다.
|
|
265
|
+
this.fastify.addHook('onRoute', (routeOptions) => {
|
|
266
|
+
// OpenAPI/swagger-ui 라우트(`${openapi.path}` 하위)는 envelope 제외 — swagger-ui 는 raw OpenAPI JSON
|
|
267
|
+
// 을 기대하므로 `{ok,data,meta}` 로 감싸면 UI 가 깨진다(ADR-140). 옵트인 OFF(_openapiPath=null)면 무영향.
|
|
268
|
+
const url = /** @type {string} */ (routeOptions.url)
|
|
269
|
+
if (this._openapiPath && (url === this._openapiPath || url.startsWith(`${this._openapiPath}/`))) return
|
|
270
|
+
const existing = routeOptions.preSerialization
|
|
271
|
+
const chain = existing ? (Array.isArray(existing) ? [...existing] : [existing]) : []
|
|
272
|
+
// async 어댑터로 감싼다: Fastify 는 done 콜백 없는 preSerialization 훅을
|
|
273
|
+
// "Promise 반환(async)" 으로만 인식한다. wrapEnvelope 는 순수 동기 함수(단위 테스트·
|
|
274
|
+
// 재사용 용이)라 그대로 넣으면 Fastify 가 콜백 스타일로 오인해 done 을 영원히 기다린다.
|
|
275
|
+
chain.push(async (req, reply, payload) => wrapEnvelope(req, reply, payload))
|
|
276
|
+
routeOptions.preSerialization = chain
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// 3) 글로벌 에러 핸들러 (AJV → MegaValidationError, MegaError → envelope, ADR-090).
|
|
280
|
+
this.fastify.setErrorHandler(
|
|
281
|
+
buildErrorHandler({ exposeInternalDetails: opts.exposeInternalDetails === true }),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
// 3b) 보안 플러그인 자동 등록 (ADR-127) — helmet/cors/rate-limit/csrf + ASP HTTP.
|
|
285
|
+
// **반드시 /health 라우트 등록 "이전"** — @fastify/rate-limit 의 onRoute 가 health 의
|
|
286
|
+
// config.rateLimit:false 면제를 보려면 플러그인이 먼저 등록돼야 한다(security.js 주석).
|
|
287
|
+
// mega-app.js:183 TODO 해소.
|
|
288
|
+
// 세션 config 정규화 (ADR-129). session:false/undefined → 비활성.
|
|
289
|
+
// store 어댑터는 생성자에서 만들되 connect 는 onReady hook 에서(테스트 inject·listen·boot 공통 경로).
|
|
290
|
+
/** @type {import('../adapters/mega-session-adapter.js').MegaSessionAdapter | null} */
|
|
291
|
+
this._sessionStore = null
|
|
292
|
+
const sessionCfg = opts.session && opts.session !== /** @type {any} */ (false) ? opts.session : null
|
|
293
|
+
|
|
294
|
+
// CSRF 세션 모드(ADR-051/129) — session.csrf:true 면 @fastify/csrf-protection 을 sessionPlugin 모드로
|
|
295
|
+
// 등록해 시크릿을 세션(_csrf 키)에 바인딩한다. 기본은 ADR-051 쿠키 double-submit 유지(csrf 옵션 무변경).
|
|
296
|
+
let effectiveCsrf = opts.csrf
|
|
297
|
+
if (sessionCfg && sessionCfg.csrf === true && opts.csrf !== false) {
|
|
298
|
+
effectiveCsrf = { ...(typeof opts.csrf === 'object' ? opts.csrf : {}), sessionPlugin: '@fastify/session' }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 3b) 보안 플러그인 자동 등록 (ADR-127).
|
|
302
|
+
registerSecurityPlugins(this.fastify, {
|
|
303
|
+
appName: this.name,
|
|
304
|
+
hosts: this.hosts,
|
|
305
|
+
helmet: opts.helmet,
|
|
306
|
+
cors: opts.cors,
|
|
307
|
+
rateLimit: opts.rateLimit,
|
|
308
|
+
csrf: effectiveCsrf,
|
|
309
|
+
asp: opts.asp,
|
|
310
|
+
resolveRateStore: this._buildRateStoreResolver(opts.caches),
|
|
311
|
+
logger: this.fastify.log,
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
// 3b-2) 파일 업로드 자동 등록 (per ADR-133) — @fastify/multipart + MIME 화이트리스트 + 경로 탐색 차단 +
|
|
315
|
+
// 트레이싱/메트릭. opts.upload 없으면 미등록(옵트인). CSRF 는 security.js 가 multipart/form-data 를
|
|
316
|
+
// 폼으로 분류해 이미 토큰 검증(per ADR-051) — 별도 배선 불필요.
|
|
317
|
+
registerMultipart(this.fastify, {
|
|
318
|
+
upload: opts.upload,
|
|
319
|
+
appName: this.name,
|
|
320
|
+
logger: this.fastify.log,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
// 3c) 세션 미들웨어 (ADR-129/046) — store + 쿠키 HMAC + ctx.session.
|
|
324
|
+
// onRequest hook 이라 csrf preHandler 보다 먼저 실행돼 세션 모드 CSRF 가 req.session 을 본다.
|
|
325
|
+
if (sessionCfg) {
|
|
326
|
+
const secret = sessionCfg.secret ?? opts.sessionSecret
|
|
327
|
+
const store = createSessionStore(sessionCfg.store, { ttlMs: sessionCfg.ttlMs })
|
|
328
|
+
this._sessionStore = store
|
|
329
|
+
registerSession(this.fastify, {
|
|
330
|
+
store,
|
|
331
|
+
secret,
|
|
332
|
+
ttlMs: sessionCfg.ttlMs,
|
|
333
|
+
rolling: sessionCfg.rolling,
|
|
334
|
+
cookie: sessionCfg.cookie,
|
|
335
|
+
driver: sessionCfg.store?.driver,
|
|
336
|
+
logger: this.fastify.log,
|
|
337
|
+
})
|
|
338
|
+
// store connect 는 onReady(테스트 inject·single listen·scaffold boot 모두 fastify.ready 경유).
|
|
339
|
+
this.fastify.addHook('onReady', async () => {
|
|
340
|
+
await store.connect()
|
|
341
|
+
})
|
|
342
|
+
// graceful shutdown 시 store disconnect (LIFO — fastify close 보다 나중 등록 = 먼저 정리되지 않도록).
|
|
343
|
+
MegaShutdown.register(`mega-session:${this.name}`, async () => {
|
|
344
|
+
await store.disconnect().catch((err) => this.fastify.log.warn({ err }, 'session store disconnect failed'))
|
|
345
|
+
})
|
|
346
|
+
this.fastify.log.debug?.({ app: this.name, driver: sessionCfg.store?.driver, csrf: sessionCfg.csrf === true }, 'session.registered')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 3d) i18n 자동 등록 (ADR-037/038/039/135) — i18next + 쿠키 locale + scope 분리.
|
|
350
|
+
// opts.i18n 없으면 미등록(옵트인 — ctx.t 는 ctx-builder passthrough 폴백). 보안 플러그인 뒤에 등록하나
|
|
351
|
+
// onRequest hook + request 부착이라 라우트/hook 순서와 무관. locale 쿠키는 보안 토큰 아님(CSRF/ASP 무관).
|
|
352
|
+
registerI18n(this.fastify, {
|
|
353
|
+
i18n: opts.i18n,
|
|
354
|
+
appName: this.name,
|
|
355
|
+
logger: this.fastify.log,
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
// 3e) 템플릿 자동 등록 (ADR-011/136) — EJS + ejs-mate 서버사이드 렌더.
|
|
359
|
+
// opts.views(.dir) 없으면 미등록(옵트인 — reply.render/ctx.render 미정의 → 호출 시 fail-fast).
|
|
360
|
+
// i18n 뒤에 등록해 reply.render 가 요청별 req.t/req.lang 을 data 에 자동 병합(다국어 뷰).
|
|
361
|
+
registerTemplate(this.fastify, {
|
|
362
|
+
views: opts.views,
|
|
363
|
+
appName: this.name,
|
|
364
|
+
logger: this.fastify.log,
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
// 3e-2) HTML 폼 바디 파싱 자동 등록 (ADR-151) — @fastify/formbody. 서버사이드 뷰(opts.views.dir)를 켠
|
|
368
|
+
// 앱만 등록(MPA 폼 제출 = urlencoded). JSON 전용 앱은 미등록(기존 JSON/text 파싱 유지).
|
|
369
|
+
registerFormbody(this.fastify, {
|
|
370
|
+
views: opts.views,
|
|
371
|
+
appName: this.name,
|
|
372
|
+
logger: this.fastify.log,
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// 3f) 정적 자산 자동 등록 (ADR-071/139) — @fastify/static 옵트인.
|
|
376
|
+
// opts.staticAssets.enabled:true + dir 있을 때만 등록(디폴트 OFF). 라우트 등록이라 보안 onRoute hook
|
|
377
|
+
// 뒤·/health 앞에 둔다. enabled:true 인데 dir 없으면 프로덕션 throw / dev warn+skip(static-assets.js).
|
|
378
|
+
registerStaticAssets(this.fastify, {
|
|
379
|
+
staticAssets: opts.staticAssets,
|
|
380
|
+
appName: this.name,
|
|
381
|
+
logger: this.fastify.log,
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// 3g) OpenAPI/Swagger 자동 등록 (ADR-070/140) — @fastify/swagger + swagger-ui 옵트인.
|
|
385
|
+
// opts.openapi.enabled:true 일 때만 등록(디폴트 OFF). swagger 는 라우트 schema 를 onRoute 로 수집하므로
|
|
386
|
+
// 사용자 라우트 등록(부팅 시 loadRoutes/single 직접)보다 먼저 등록돼야 한다 — 생성자 시점이 그 지점.
|
|
387
|
+
// docs auth 미들웨어엔 요청당 ctx 를 만들어 넘긴다(getHttpCtx, globalMiddleware 패턴 정합, ADR-134).
|
|
388
|
+
const openapiSummary = registerOpenapi(this.fastify, {
|
|
389
|
+
openapi: opts.openapi,
|
|
390
|
+
appName: this.name,
|
|
391
|
+
buildCtx: (/** @type {any} */ req, /** @type {any} */ reply) => getHttpCtx({ app: this, req, reply }),
|
|
392
|
+
logger: this.fastify.log,
|
|
393
|
+
})
|
|
394
|
+
// envelope onRoute 가 docs 경로를 제외하도록 path 저장(미옵트인이면 null 유지).
|
|
395
|
+
this._openapiPath = openapiSummary.path
|
|
396
|
+
|
|
397
|
+
// MegaHealth 통합 — /health (liveness) + /health/ready (readiness).
|
|
398
|
+
// onRoute 훅 등록 이후라 자동 envelope 적용됨. config.skip{Asp,Csrf,RateLimit} 3종이 각 보안 hook 의
|
|
399
|
+
// 면제 신호다(ADR-072 면제 실효, ADR-127): ASP onRequest·CSRF preHandler·rate-limit allowList 가 검사.
|
|
400
|
+
const HEALTH_EXEMPT = { skipAsp: true, skipCsrf: true, skipRateLimit: true }
|
|
401
|
+
|
|
402
|
+
// /health — liveness (항상 200)
|
|
403
|
+
this.fastify.get('/health', { config: HEALTH_EXEMPT }, async () => ({
|
|
404
|
+
status: 'ok',
|
|
405
|
+
app: this.name,
|
|
406
|
+
uptime_ms: Math.floor(process.uptime() * 1000),
|
|
407
|
+
ts: Date.now(),
|
|
408
|
+
}))
|
|
409
|
+
|
|
410
|
+
// /health/ready — readiness (checkAll 후 200 or 503)
|
|
411
|
+
this.fastify.get('/health/ready', { config: HEALTH_EXEMPT }, async (req, reply) => {
|
|
412
|
+
const snapshot = await MegaHealth.checkAll()
|
|
413
|
+
if (!snapshot.ok) reply.code(503)
|
|
414
|
+
return {
|
|
415
|
+
app: this.name,
|
|
416
|
+
...snapshot,
|
|
417
|
+
uptime_ms: Math.floor(process.uptime() * 1000),
|
|
418
|
+
ts: Date.now(),
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
// /metrics — Prometheus 옵트인 (ADR-072/131). exposeMetrics:true 일 때만 등록 →
|
|
423
|
+
// 디폴트(미등록)는 Fastify 가 404(roadmap 검증 기준). /health 와 동일 보안 면제(HEALTH_EXEMPT).
|
|
424
|
+
// 접근 제어 = IP allowList(ADR-131) — 빈 list 면 메인 포트 전체 노출(운영자 결정).
|
|
425
|
+
const healthCfg = opts.health && typeof opts.health === 'object' ? opts.health : {}
|
|
426
|
+
if (healthCfg.exposeMetrics === true) {
|
|
427
|
+
const metricsPath = typeof healthCfg.metricsPath === 'string' && healthCfg.metricsPath.length > 0 ? healthCfg.metricsPath : '/metrics'
|
|
428
|
+
// metricsPath 충돌 검증(ADR-072/04-data-models) — health 경로와 겹치면 부팅 throw(fail-fast).
|
|
429
|
+
const livePath = healthCfg.paths?.live ?? '/health'
|
|
430
|
+
const readyPath = healthCfg.paths?.ready ?? '/health/ready'
|
|
431
|
+
if (metricsPath === livePath || metricsPath === readyPath) {
|
|
432
|
+
throw new MegaConfigError(
|
|
433
|
+
'health.metrics_path_conflict',
|
|
434
|
+
`health.metricsPath='${metricsPath}' conflicts with a health path (live='${livePath}', ready='${readyPath}'). Choose a distinct path.`,
|
|
435
|
+
{ details: { metricsPath, livePath, readyPath } },
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
const allowList = Array.isArray(healthCfg.metricsAllowList) ? healthCfg.metricsAllowList : []
|
|
439
|
+
this.fastify.get(metricsPath, { config: HEALTH_EXEMPT }, async (req, reply) => {
|
|
440
|
+
if (!MegaMetrics.isIpAllowed(req.ip, allowList)) {
|
|
441
|
+
this.fastify.log.debug?.({ ip: req.ip, route: metricsPath }, 'metrics.access denied (allowList)')
|
|
442
|
+
// 문자열 payload → Fastify 가 preSerialization(envelope)을 건너뜀(raw 전송). 거부도 raw 텍스트.
|
|
443
|
+
return reply.code(403).type('text/plain').send('forbidden\n')
|
|
444
|
+
}
|
|
445
|
+
// Prometheus 텍스트(문자열) 반환 → Fastify 가 string payload 라 preSerialization(wrapEnvelope)을
|
|
446
|
+
// 건너뛰어 raw 로 전송(envelope JSON 으로 안 감싸짐). onResponse 는 정상 발화 → 트레이싱 span 종료.
|
|
447
|
+
// 클러스터(ADR-154)면 collectCluster 가 마스터를 통해 전 워커 합산을 돌려준다(ADR-163) — 단일
|
|
448
|
+
// 프로세스면 로컬 collect() 와 동일. 워커별로 흩어진 카운터가 한 응답에 합산돼 스크레이프가 일관된다.
|
|
449
|
+
const body = await collectCluster()
|
|
450
|
+
reply.type(MegaMetrics.PROM_CONTENT_TYPE)
|
|
451
|
+
return body
|
|
452
|
+
})
|
|
453
|
+
this.fastify.log.debug?.({ app: this.name, metricsPath, allowList: allowList.length }, 'metrics.endpoint registered')
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// 플러그인 hook 소비 (ADR-079 §14 hook표, ADR-123). orchestrator(bootApp)가
|
|
457
|
+
// MegaPluginHost 수집물(fastifyPlugins / globalMiddlewares)을 모든 앱에 주입한다. health
|
|
458
|
+
// 라우트보다 뒤에 걸지만 Fastify 글로벌 hook/register 는 scope 내 전 라우트에 적용된다.
|
|
459
|
+
if (Array.isArray(opts.plugins)) {
|
|
460
|
+
for (const entry of opts.plugins) {
|
|
461
|
+
if (!entry || typeof entry.plugin !== 'function') {
|
|
462
|
+
throw new TypeError(`MegaApp('${this.name}'): plugins[] entry must be { plugin: function, opts? }.`)
|
|
463
|
+
}
|
|
464
|
+
this.fastify.register(/** @type {any} */ (entry.plugin), entry.opts)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (Array.isArray(opts.globalMiddlewares)) {
|
|
468
|
+
const self = this
|
|
469
|
+
for (const mw of opts.globalMiddlewares) {
|
|
470
|
+
if (typeof mw !== 'function') {
|
|
471
|
+
throw new TypeError(`MegaApp('${this.name}'): globalMiddlewares[] entry must be a function.`)
|
|
472
|
+
}
|
|
473
|
+
// 래퍼 arity 2(`(req, reply)`)라 Fastify 가 async preHandler 로 인식한다(arity 3 면 done 콜백 모드로
|
|
474
|
+
// 오인). 래퍼 안에서 ctx 를 만들어 미들웨어를 `(req, reply, ctx)` 로 호출한다(ADR-134). getHttpCtx 가
|
|
475
|
+
// 요청당 캐싱이라 같은 요청의 라우트 핸들러가 동일 ctx 를 이어받는다.
|
|
476
|
+
this.fastify.addHook('preHandler', async (req, reply) => {
|
|
477
|
+
const ctx = getHttpCtx({ app: self, req, reply })
|
|
478
|
+
return /** @type {any} */ (mw)(req, reply, ctx)
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// MegaShutdown 에 자기 Fastify close 등록 (마지막에 등록 — LIFO 로 가장 먼저 실행)
|
|
484
|
+
MegaShutdown.register(`mega-app:${this.name}`, async () => {
|
|
485
|
+
await this.fastify.close()
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* rate-limit Redis 백엔드 해석기를 만든다 (ADR-048). 앱이 `caches.rate` 별명을 선언했고 그 어댑터가
|
|
491
|
+
* Redis(`MegaRedisAdapter`)면 ioredis raw handle 을 반환하는 함수를, 아니면 `undefined` 를 돌려준다
|
|
492
|
+
* (= in-memory 폴백, 단일 인스턴스 dev). 해석은 보안 등록 시점(생성자)에 1회 호출되며, 어댑터는
|
|
493
|
+
* 그 전에 `MegaAdapterManager` 가 connect 해 둔 상태여야 한다(부팅 orchestrator 가 보장).
|
|
494
|
+
*
|
|
495
|
+
* Redis 가 아닌 캐시를 `rate` 로 매핑한 misconfiguration 은 silent 무시하지 않고 warn 후 in-memory
|
|
496
|
+
* 폴백한다(ADR-048 — rate-limit 은 ioredis 전용, 폴백은 비치명적·문서화된 디폴트).
|
|
497
|
+
*
|
|
498
|
+
* @param {Record<string, string>} [caches] - 앱 캐시 별명 맵 `{ alias: globalKey }`.
|
|
499
|
+
* @returns {(() => import('ioredis').Redis | null) | undefined}
|
|
500
|
+
* @private
|
|
501
|
+
*/
|
|
502
|
+
_buildRateStoreResolver(caches) {
|
|
503
|
+
if (!caches || typeof caches.rate !== 'string') return undefined
|
|
504
|
+
return () => {
|
|
505
|
+
const adapter = this._adapterAccessors.cache('rate') // 미선언/미등록이면 throw(fail-fast).
|
|
506
|
+
if (adapter?.constructor?.name !== 'MegaRedisAdapter') {
|
|
507
|
+
this.fastify.log.warn(
|
|
508
|
+
{ app: this.name, adapter: adapter?.constructor?.name },
|
|
509
|
+
'security.rate_limit: caches.rate is not a Redis adapter — falling back to in-memory store (ADR-048).',
|
|
510
|
+
)
|
|
511
|
+
return null
|
|
512
|
+
}
|
|
513
|
+
return /** @type {import('ioredis').Redis} */ (adapter.native)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Single 모드 WS 채널 등록. `router.ws` 와 동일 검증 위임.
|
|
519
|
+
*
|
|
520
|
+
* 단일 파일 모드에서 `routes/` 스캔 없이 직접 채널을 등록한다 (02-architecture §3). 등록은
|
|
521
|
+
* `fastify._megaWsRoutes` 에 보관되며 {@link MegaApp#listen} 시 upgrade 핸들오프가 배선된다.
|
|
522
|
+
*
|
|
523
|
+
* @param {string} path - WS 경로 (= namespace).
|
|
524
|
+
* @param {Function} ChannelClass - MegaWebSocketController 상속.
|
|
525
|
+
* @param {{ before?: Function[], schemas?: Object }} [opts]
|
|
526
|
+
* @returns {this} 체이닝용.
|
|
527
|
+
* @throws {import('./router.js').MegaRouteError} 형식/상속 위반.
|
|
528
|
+
*/
|
|
529
|
+
ws(path, ChannelClass, opts = {}) {
|
|
530
|
+
if (!this._router) this._router = new Router({ fastify: this.fastify, appName: this.name, app: this })
|
|
531
|
+
this._router.ws(path, ChannelClass, opts)
|
|
532
|
+
return this
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* 등록된 WS 라우트 목록 (router.ws + app.ws 통합). 비어 있으면 빈 배열.
|
|
537
|
+
* @returns {Array<{ path: string, ns: string, ChannelClass: Function, opts: { before?: Function[], schemas?: Object }, schemaValidators?: Record<string, import('ajv').ValidateFunction> | null }>}
|
|
538
|
+
*/
|
|
539
|
+
get wsRoutes() {
|
|
540
|
+
const f = /** @type {any} */ (this.fastify)
|
|
541
|
+
return Array.isArray(f._megaWsRoutes) ? f._megaWsRoutes : []
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 현재 연결된 hub link (미연결 시 null). */
|
|
545
|
+
get hubLink() {
|
|
546
|
+
return this._hubLink
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 이 앱의 `ctx.db/cache/bus` 접근자 3종 (ADR-102). 요청 ctx 빌더(HTTP·WS)가 spread 해서 노출한다.
|
|
551
|
+
* 별명→globalKey→전역 공유 어댑터로 해석하며, 미선언 별명·미등록 키는 호출 시 throw.
|
|
552
|
+
* @returns {import('./ctx-builder.js').AdapterAccessors}
|
|
553
|
+
*/
|
|
554
|
+
get adapterAccessors() {
|
|
555
|
+
return this._adapterAccessors
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 이 앱의 서비스 레지스트리(name → 클래스, ADR-148). 요청 ctx 빌더가 `ctx.services.<name>` lazy DI 의
|
|
560
|
+
* 클래스 lookup 출처로 읽는다. 부팅 orchestrator 가 `setServiceRegistry` 로 채운다.
|
|
561
|
+
* @returns {Map<string, Function>}
|
|
562
|
+
*/
|
|
563
|
+
get serviceRegistry() {
|
|
564
|
+
return this._serviceRegistry
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* 서비스 레지스트리 주입 (부팅 시 1회, ADR-148). services-loader 가 만든 name→Class 맵으로 교체한다.
|
|
569
|
+
* @param {Map<string, Function>} registry - name → 서비스 클래스.
|
|
570
|
+
* @returns {void}
|
|
571
|
+
* @throws {TypeError} Map 이 아니면.
|
|
572
|
+
*/
|
|
573
|
+
setServiceRegistry(registry) {
|
|
574
|
+
if (!(registry instanceof Map)) {
|
|
575
|
+
throw new TypeError(`MegaApp('${this.name}').setServiceRegistry: registry must be a Map.`)
|
|
576
|
+
}
|
|
577
|
+
this._serviceRegistry = registry
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* `ctx.bruteForce` 단축이 반환하는 MegaBruteForce 인스턴스(03-api-spec §10/§786, ADR-049/130).
|
|
582
|
+
* **lazy** — 처음 접근할 때만 만들고 앱당 재사용한다. 백엔드는 `caches.rate` 별명(ADR-049 디폴트)을
|
|
583
|
+
* 해석한 redis 어댑터. rate 캐시를 선언 안 한 앱이 이 getter 를 건드리면 cache 접근자가 fail-fast
|
|
584
|
+
* (`adapter.not_registered`) — brute-force 를 안 쓰는 앱은 이 getter 를 호출하지 않으므로 무해하다.
|
|
585
|
+
* 세분 정책(도메인별 maxAttempts 등)은 `new MegaBruteForce({ cache: ctx.cache('alias'), key, ... })`
|
|
586
|
+
* 로 직접 구성한다(별도 config 키는 향후 Step, ADR-130).
|
|
587
|
+
* @returns {MegaBruteForce}
|
|
588
|
+
*/
|
|
589
|
+
get bruteForce() {
|
|
590
|
+
if (this._bruteForce === null) {
|
|
591
|
+
const cache = /** @type {any} */ (this._adapterAccessors.cache('rate')) // 미선언/미등록이면 throw.
|
|
592
|
+
this._bruteForce = new MegaBruteForce({ cache, key: 'default' })
|
|
593
|
+
}
|
|
594
|
+
return this._bruteForce
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* 이 bridge 를 hub 에 연결한다 (ADR-033/059). REGISTER 핸드셰이크 완료 후
|
|
599
|
+
* 선언 채널을 구독(bridge-subscriber JOIN)하고, hub 의 BROADCAST/DIRECT 를 로컬 소켓에 전달한다.
|
|
600
|
+
*
|
|
601
|
+
* single 모드는 embedded 종단이라 hub 가 필수는 아니지만(02-architecture §3), 멀티 인스턴스
|
|
602
|
+
* fan-out 이 필요하면 single 에서도 사용 가능하다. scaffold(멀티앱/클러스터)에서 권장.
|
|
603
|
+
*
|
|
604
|
+
* @param {Object} config - MegaBridgeHubConfig (§2.1) + 구독 채널.
|
|
605
|
+
* @param {string} config.url - hub URL.
|
|
606
|
+
* @param {string} config.token - Bearer 토큰.
|
|
607
|
+
* @param {string} config.bridgeId - 운영 식별자.
|
|
608
|
+
* @param {string} [config.instanceId]
|
|
609
|
+
* @param {string[]} [config.capabilities]
|
|
610
|
+
* @param {string[]} [config.channels] - 자동 구독할 채널 목록.
|
|
611
|
+
* @param {import('../lib/mega-retry.js').MegaRetryOptions} [config.retry] - 지정 시 재연결 활성(ADR-098).
|
|
612
|
+
* hub 재시작·drain(4503)·네트워크 단절 시 지수 백오프로 재연결하고, 성공하면 presence(채널·세션
|
|
613
|
+
* JOIN)를 자동 재동기화한다(hub 는 절단 시점 presence 를 잃으므로).
|
|
614
|
+
* @param {import('./ws-compression.js').WsCompressionConfig} [config.compression] - Bridge↔Hub
|
|
615
|
+
* link 압축(ADR-078 / MegaWsHubCompressionConfig). Global `wsHub.compression`
|
|
616
|
+
* 블록을 그대로 전달한다 — hub 서버와 같은 스키마. 디폴트 OFF. 잘못된 threshold/windowBits 면
|
|
617
|
+
* 즉시 throw(부팅 fail-fast).
|
|
618
|
+
* @returns {Promise<MegaHubLink>} 등록 완료된 link.
|
|
619
|
+
*/
|
|
620
|
+
async connectHub(config = /** @type {any} */ ({})) {
|
|
621
|
+
const link = new MegaHubLink({
|
|
622
|
+
url: config.url,
|
|
623
|
+
token: config.token,
|
|
624
|
+
bridgeId: config.bridgeId,
|
|
625
|
+
instanceId: config.instanceId,
|
|
626
|
+
capabilities: config.capabilities,
|
|
627
|
+
retry: config.retry,
|
|
628
|
+
compression: config.compression,
|
|
629
|
+
logger: this.fastify.log,
|
|
630
|
+
})
|
|
631
|
+
this._hubLink = link
|
|
632
|
+
this._hubBridgeId = config.bridgeId
|
|
633
|
+
this._hubChannels = Array.isArray(config.channels) ? [...config.channels] : []
|
|
634
|
+
// hub → bridge 푸시를 로컬 소켓에 전달.
|
|
635
|
+
link.on(HUB_MESSAGE_TYPES.BROADCAST, (msg) => this._deliverBroadcast(/** @type {any} */ (msg).payload))
|
|
636
|
+
link.on(HUB_MESSAGE_TYPES.DIRECT, (msg) => this._deliverDirect(/** @type {any} */ (msg).payload))
|
|
637
|
+
// 재연결 성공 시 presence 재동기화(ADR-098) — hub 는 절단 시점 presence 를 잃으므로 다시 JOIN.
|
|
638
|
+
link.on(MegaHubLink.EVENTS.RECONNECTED, () => this._resyncPresence())
|
|
639
|
+
await link.connect()
|
|
640
|
+
|
|
641
|
+
// 채널 구독 — bridge-subscriber 세션 JOIN(zero-config 브로드캐스트 수신용) + 이미 붙어 있는 실
|
|
642
|
+
// 사용자 세션 재JOIN(예: 첫 connect 전에 클라가 연결됐을 수 있음).
|
|
643
|
+
this._resyncPresence()
|
|
644
|
+
|
|
645
|
+
// shutdown hook 누수 방지(L1) — 재연결 등으로 connectHub 가 다시 불려도 hook 은 항상 1개.
|
|
646
|
+
const hookName = `mega-hublink:${this.name}`
|
|
647
|
+
MegaShutdown.unregister(hookName)
|
|
648
|
+
MegaShutdown.register(hookName, async () => link.close())
|
|
649
|
+
return link
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* hub presence 재동기화 — bridge-subscriber 채널 JOIN + 활성 사용자 세션 JOIN 을 모두 다시 보낸다.
|
|
654
|
+
* 최초 등록 직후와 재연결(RECONNECTED) 직후에 호출된다. hub 의 JOIN 처리는 멱등(같은 sessionId 덮어씀).
|
|
655
|
+
* @returns {void}
|
|
656
|
+
* @private
|
|
657
|
+
*/
|
|
658
|
+
_resyncPresence() {
|
|
659
|
+
const link = this._hubLink
|
|
660
|
+
if (!link?.isRegistered) return
|
|
661
|
+
const bridgeId = this._hubBridgeId ?? this.name
|
|
662
|
+
// 1) bridge-subscriber JOIN — bridge 가 채널 멤버가 되어 zero-config 브로드캐스트를 받게 한다.
|
|
663
|
+
for (const ch of this._hubChannels) {
|
|
664
|
+
link.join({
|
|
665
|
+
userId: `bridge:${bridgeId}`,
|
|
666
|
+
sessionId: `bridge:${bridgeId}#${ch}`,
|
|
667
|
+
channels: [ch],
|
|
668
|
+
})
|
|
669
|
+
}
|
|
670
|
+
// 2) 실 사용자 세션 JOIN — joinSession 으로 매핑된 활성 세션을 다시 등록(DIRECT 타겟 복구).
|
|
671
|
+
// 채널 + metadata 까지 재동기화한다(M-1) — hub 는 절단 시점 presence 를 통째로 잃으므로,
|
|
672
|
+
// metadata 를 빠뜨리면 재연결 후 hub presence 의 메타가 silent 사라진다.
|
|
673
|
+
for (const [sessionId, conn] of this._sessionConns) {
|
|
674
|
+
if (!conn.isOpen) continue
|
|
675
|
+
link.join({
|
|
676
|
+
userId: /** @type {string} */ (conn.userId),
|
|
677
|
+
sessionId,
|
|
678
|
+
channels: conn.channels ? [...conn.channels] : [],
|
|
679
|
+
...(conn.metadata ? { metadata: conn.metadata } : {}),
|
|
680
|
+
})
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* 실 사용자 세션을 hub presence 에 등록하고 로컬 매핑을 만든다 (OQ-010/ADR-098).
|
|
686
|
+
*
|
|
687
|
+
* 표준 패턴: WS upgrade 의 `before` 미들웨어가 인증 후 신원을 `ctx.auth` 로 싣고(ADR-091 DI),
|
|
688
|
+
* 채널의 `onConnect(sock, ctx)` 에서 `ctx.app.joinSession(sock, { userId: ctx.auth.userId, ... })`
|
|
689
|
+
* 를 호출한다. 이 매핑이 있어야 DIRECT 가 **해당 userId 세션에만** 전달된다(cross-user flood 방지,
|
|
690
|
+
* H-latent guard). 매핑 없는 연결은 DIRECT 대상에서 제외된다.
|
|
691
|
+
*
|
|
692
|
+
* @param {import('./ws-upgrade.js').MegaWsConnection} conn - onConnect 가 받은 소켓 래퍼.
|
|
693
|
+
* @param {Object} entry
|
|
694
|
+
* @param {string} entry.userId - 인증된 사용자 식별자(비어 있으면 throw).
|
|
695
|
+
* @param {string} entry.sessionId - 세션 식별자(비어 있으면 throw). 전역 유일 권장.
|
|
696
|
+
* @param {string[]} [entry.channels] - 가입 채널 목록.
|
|
697
|
+
* @param {Object} [entry.metadata] - presence 메타데이터(명시 필드만, ADR-059).
|
|
698
|
+
* @returns {this}
|
|
699
|
+
* @throws {Error} conn/userId/sessionId 누락 시 — 잘못된 매핑을 silent 통과시키지 않는다.
|
|
700
|
+
*/
|
|
701
|
+
joinSession(conn, { userId, sessionId, channels = [], metadata } = /** @type {any} */ ({})) {
|
|
702
|
+
if (!conn || typeof conn.send !== 'function') {
|
|
703
|
+
throw new Error('MegaApp.joinSession: conn (MegaWsConnection) is required.')
|
|
704
|
+
}
|
|
705
|
+
if (typeof userId !== 'string' || userId.length === 0) {
|
|
706
|
+
throw new Error('MegaApp.joinSession: userId (non-empty string) is required.')
|
|
707
|
+
}
|
|
708
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
709
|
+
throw new Error('MegaApp.joinSession: sessionId (non-empty string) is required.')
|
|
710
|
+
}
|
|
711
|
+
const chans = Array.isArray(channels) ? [...channels] : []
|
|
712
|
+
|
|
713
|
+
// L-4: 같은 sessionId 가 다른 conn 으로 다시 join 되면(전역 유일 계약 위반) 옛 conn 을 인덱스에서
|
|
714
|
+
// 떼어 dangling 을 막는다 — 단 소켓 자체는 닫지 않는다(클라가 정리). 옛 conn 의 신원도 비워,
|
|
715
|
+
// 이후 옛 conn 의 close(_untrackWsConn)가 새 conn 이 차지한 sessionId 로 LEAVE 를 잘못 보내지
|
|
716
|
+
// 않게 한다(그대로 두면 새 세션의 hub presence 가 silent 제거됨).
|
|
717
|
+
const prior = this._sessionConns.get(sessionId)
|
|
718
|
+
if (prior && prior !== conn) {
|
|
719
|
+
this.fastify.log?.warn?.(
|
|
720
|
+
{ app: this.name, sessionId, priorUserId: prior.userId, userId },
|
|
721
|
+
'ws.joinSession duplicate sessionId — prior conn left dangling (detached, not closed)',
|
|
722
|
+
)
|
|
723
|
+
if (prior.userId !== undefined) {
|
|
724
|
+
const pset = this._userConns.get(prior.userId)
|
|
725
|
+
if (pset) {
|
|
726
|
+
pset.delete(prior)
|
|
727
|
+
if (pset.size === 0) this._userConns.delete(prior.userId)
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (prior.ns !== undefined) {
|
|
731
|
+
const nsset = this._wsConns.get(prior.ns)
|
|
732
|
+
if (nsset) {
|
|
733
|
+
nsset.delete(prior)
|
|
734
|
+
if (nsset.size === 0) this._wsConns.delete(prior.ns)
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
prior.userId = undefined
|
|
738
|
+
prior.sessionId = undefined
|
|
739
|
+
prior.channels = null
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// 연결에 신원 부착(매핑 키). _untrackWsConn 이 close 시 이 값으로 인덱스를 정리한다.
|
|
743
|
+
conn.userId = userId
|
|
744
|
+
conn.sessionId = sessionId
|
|
745
|
+
conn.channels = new Set(chans)
|
|
746
|
+
conn.metadata = metadata // M-1: 재연결 재동기화(_resyncPresence)가 보존할 수 있게 저장.
|
|
747
|
+
|
|
748
|
+
let uset = this._userConns.get(userId)
|
|
749
|
+
if (!uset) {
|
|
750
|
+
uset = new Set()
|
|
751
|
+
this._userConns.set(userId, uset)
|
|
752
|
+
}
|
|
753
|
+
uset.add(conn)
|
|
754
|
+
this._sessionConns.set(sessionId, conn)
|
|
755
|
+
|
|
756
|
+
this.fastify.log?.debug?.({ app: this.name, userId, sessionId, channels: chans }, 'ws.joinSession')
|
|
757
|
+
// hub presence 등록 — 등록 상태일 때만(미연결/재연결 중이면 _resyncPresence 가 나중에 복구).
|
|
758
|
+
if (this._hubLink?.isRegistered) {
|
|
759
|
+
this._hubLink.join({ userId, sessionId, channels: chans, ...(metadata ? { metadata } : {}) })
|
|
760
|
+
}
|
|
761
|
+
return this
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* 채널 broadcast — 로컬 ns 소켓에 즉시 전달 + (hub 연결 시) 클러스터 fan-out.
|
|
766
|
+
*
|
|
767
|
+
* @param {{ ns: string, channel: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }} args
|
|
768
|
+
* @returns {void}
|
|
769
|
+
* @throws {Error} message.type(string) 누락 시 — 호출부 입력 오류를 silent drop 하지 않고 즉시 알린다(L6).
|
|
770
|
+
*/
|
|
771
|
+
broadcast({ ns, channel, message, exceptSessionIds }) {
|
|
772
|
+
// 입력 검증을 한곳에서(L6) — 로컬은 받고 hub 는 message.type 없이 전송하던 비대칭을 제거.
|
|
773
|
+
if (!message || typeof message.type !== 'string') {
|
|
774
|
+
throw new Error('MegaApp.broadcast: message.type (string) is required')
|
|
775
|
+
}
|
|
776
|
+
this._deliverBroadcast({ ns, channel, message, exceptSessionIds })
|
|
777
|
+
if (this._hubLink?.isRegistered) {
|
|
778
|
+
// L-7: 빈 배열도 truthy 라 `exceptSessionIds: []` 가 wire 로 새던 비대칭 제거 — 비어 있으면 생략.
|
|
779
|
+
const hasExcept = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0
|
|
780
|
+
try {
|
|
781
|
+
this._hubLink.broadcast({ ns, channel, message, ...(hasExcept ? { exceptSessionIds } : {}) })
|
|
782
|
+
} catch (err) {
|
|
783
|
+
// 로컬 전달은 이미 성공. 클러스터 fan-out 은 best-effort(at-most-once, ADR-033)이며 소켓이
|
|
784
|
+
// 닫히는 중이면 비치명적 — 재연결 시 presence 가 재동기화된다. warn 후 호출자 보호.
|
|
785
|
+
const log = /** @type {any} */ (this.fastify.log)
|
|
786
|
+
log?.warn?.({ err, ns, channel, app: this.name }, 'app.broadcast hub fan-out failed (local delivered)')
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* 특정 사용자에게 직접 전송 (directToUser, ADR-035) — 로컬에서 그 userId 로 매핑된 연결에만 전달
|
|
793
|
+
* (H-latent guard) + (hub 연결 시) 클러스터 fan-out(다른 bridge 의 같은 userId 세션까지).
|
|
794
|
+
*
|
|
795
|
+
* {@link MegaApp#joinSession} 으로 매핑된 연결만 대상이다 — 매핑 없는 userId 면 로컬 no-op.
|
|
796
|
+
*
|
|
797
|
+
* @param {string} userId - 대상 사용자.
|
|
798
|
+
* @param {{ type: string, payload?: Object }} message - 내부 envelope `{ type, payload }`.
|
|
799
|
+
* @returns {void}
|
|
800
|
+
* @throws {Error} userId/message.type 누락 시(broadcast 와 동일한 입력 보호, L6).
|
|
801
|
+
*/
|
|
802
|
+
directToUser(userId, message) {
|
|
803
|
+
if (typeof userId !== 'string' || userId.length === 0) {
|
|
804
|
+
throw new Error('MegaApp.directToUser: userId (non-empty string) is required')
|
|
805
|
+
}
|
|
806
|
+
if (!message || typeof message.type !== 'string') {
|
|
807
|
+
throw new Error('MegaApp.directToUser: message.type (string) is required')
|
|
808
|
+
}
|
|
809
|
+
this._deliverDirect({ userId, message })
|
|
810
|
+
if (this._hubLink?.isRegistered) {
|
|
811
|
+
try {
|
|
812
|
+
this._hubLink.direct({ userId, message })
|
|
813
|
+
} catch (err) {
|
|
814
|
+
// 로컬 전달은 이미 성공. 클러스터 fan-out 은 best-effort(at-most-once, ADR-033). warn 후 보호.
|
|
815
|
+
const log = /** @type {any} */ (this.fastify.log)
|
|
816
|
+
log?.warn?.({ err, userId, app: this.name }, 'app.directToUser hub fan-out failed (local delivered)')
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* 세션 presence 메타데이터 갱신 (METADATA, ADR-059) — 로컬 conn 에 저장 + (hub 연결 시) 전파.
|
|
823
|
+
*
|
|
824
|
+
* 로컬 conn 의 `metadata` 를 갱신해 두면 이후 재연결 시 {@link MegaApp#_resyncPresence} 가 최신
|
|
825
|
+
* 메타까지 복구한다(M-1). 매핑 없는 sessionId 면 no-op(로컬 저장 없이 hub 전파만은 하지 않음 —
|
|
826
|
+
* 재연결 보존 대상이 없으므로). broadcast/directToUser 와 같은 best-effort fan-out.
|
|
827
|
+
*
|
|
828
|
+
* @param {string} sessionId - 대상 세션(joinSession 으로 매핑된 것).
|
|
829
|
+
* @param {Object} metadata - 갱신할 메타데이터(명시 필드만).
|
|
830
|
+
* @returns {this}
|
|
831
|
+
* @throws {Error} sessionId/metadata 누락 시(입력 보호, L6 와 동일 원칙).
|
|
832
|
+
*/
|
|
833
|
+
updateMetadata(sessionId, metadata) {
|
|
834
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
835
|
+
throw new Error('MegaApp.updateMetadata: sessionId (non-empty string) is required')
|
|
836
|
+
}
|
|
837
|
+
if (!metadata || typeof metadata !== 'object') {
|
|
838
|
+
throw new Error('MegaApp.updateMetadata: metadata (object) is required')
|
|
839
|
+
}
|
|
840
|
+
const conn = this._sessionConns.get(sessionId)
|
|
841
|
+
if (!conn) {
|
|
842
|
+
// 매핑 없는 세션 — 재연결로 보존할 로컬 대상이 없으므로 no-op(다른 bridge 세션은 그쪽이 관리).
|
|
843
|
+
this.fastify.log?.debug?.({ app: this.name, sessionId }, 'ws.updateMetadata — no local session (no-op)')
|
|
844
|
+
return this
|
|
845
|
+
}
|
|
846
|
+
conn.metadata = metadata // 재연결 재동기화가 최신 메타를 복구하도록 저장(M-1).
|
|
847
|
+
if (this._hubLink?.isRegistered) {
|
|
848
|
+
try {
|
|
849
|
+
this._hubLink.updateMetadata({ sessionId, metadata })
|
|
850
|
+
} catch (err) {
|
|
851
|
+
// hub 전파 실패는 비치명적 — 로컬 저장은 됐고 재연결 시 _resyncPresence 가 복구.
|
|
852
|
+
const log = /** @type {any} */ (this.fastify.log)
|
|
853
|
+
log?.warn?.({ err, sessionId, app: this.name }, 'app.updateMetadata hub propagate failed (local stored)')
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return this
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* broadcast payload 를 로컬 ns 소켓에 전달한다. message 는 `{ type, payload }` 내부 envelope.
|
|
861
|
+
*
|
|
862
|
+
* `exceptSessionIds` 에 든 sessionId 로 매핑된 연결은 제외한다(ADR-098). 세션 매핑이 없는
|
|
863
|
+
* 연결(zero-config·미JOIN)은 sessionId 가 없어 제외 대상에 걸리지 않으므로 그대로 받는다.
|
|
864
|
+
*
|
|
865
|
+
* @param {{ ns: string, channel?: string, message: { type: string, payload?: Object }, exceptSessionIds?: string[] }} payload
|
|
866
|
+
* @returns {void}
|
|
867
|
+
* @private
|
|
868
|
+
*/
|
|
869
|
+
_deliverBroadcast({ ns, message, exceptSessionIds }) {
|
|
870
|
+
const set = this._wsConns.get(ns)
|
|
871
|
+
if (!set || !message || typeof message.type !== 'string') return
|
|
872
|
+
const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
|
|
873
|
+
for (const conn of set) {
|
|
874
|
+
if (!conn.isOpen) continue
|
|
875
|
+
if (except && conn.sessionId !== undefined && except.has(conn.sessionId)) continue
|
|
876
|
+
conn.send({ type: message.type, ns, payload: message.payload })
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* direct payload 를 **해당 userId 로 매핑된 로컬 연결에만** 전달한다 (H-latent guard).
|
|
882
|
+
*
|
|
883
|
+
* 초기에는 매핑이 없어 모든 연결에 flood 됐다(cross-user 누출). {@link MegaApp#joinSession}
|
|
884
|
+
* 으로 만든 `userId → 연결` 매핑을 통해 대상 사용자에게만 보낸다. 매핑이 없는 userId 면 no-op
|
|
885
|
+
* (다른 사용자에게 새지 않음).
|
|
886
|
+
*
|
|
887
|
+
* @param {{ userId: string, message: { type: string, payload?: Object } }} payload
|
|
888
|
+
* @returns {void}
|
|
889
|
+
* @private
|
|
890
|
+
*/
|
|
891
|
+
_deliverDirect({ userId, message }) {
|
|
892
|
+
if (!message || typeof message.type !== 'string') return
|
|
893
|
+
if (typeof userId !== 'string' || userId.length === 0) return
|
|
894
|
+
const set = this._userConns.get(userId)
|
|
895
|
+
if (!set) return
|
|
896
|
+
for (const conn of set) {
|
|
897
|
+
if (conn.isOpen) conn.send({ type: message.type, payload: message.payload })
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* 로컬 WS 연결 등록 (driveWsConnection 이 호출 — framework-internal). hub broadcast 의 local 전달 대상.
|
|
903
|
+
* @param {import('./ws-upgrade.js').MegaWsConnection} conn
|
|
904
|
+
* @returns {void}
|
|
905
|
+
*/
|
|
906
|
+
_trackWsConn(conn) {
|
|
907
|
+
if (!conn.ns) return
|
|
908
|
+
let set = this._wsConns.get(conn.ns)
|
|
909
|
+
if (!set) {
|
|
910
|
+
set = new Set()
|
|
911
|
+
this._wsConns.set(conn.ns, set)
|
|
912
|
+
}
|
|
913
|
+
set.add(conn)
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* 로컬 WS 연결 해제 (close 시 driveWsConnection 이 호출 — framework-internal).
|
|
918
|
+
* @param {import('./ws-upgrade.js').MegaWsConnection} conn
|
|
919
|
+
* @returns {void}
|
|
920
|
+
*/
|
|
921
|
+
_untrackWsConn(conn) {
|
|
922
|
+
const set = conn.ns ? this._wsConns.get(conn.ns) : undefined
|
|
923
|
+
if (set) {
|
|
924
|
+
set.delete(conn)
|
|
925
|
+
if (set.size === 0) this._wsConns.delete(conn.ns)
|
|
926
|
+
}
|
|
927
|
+
// 세션·유저 매핑 정리 + hub presence LEAVE (joinSession 으로 매핑된 연결만).
|
|
928
|
+
if (conn.userId !== undefined) {
|
|
929
|
+
const uset = this._userConns.get(conn.userId)
|
|
930
|
+
if (uset) {
|
|
931
|
+
uset.delete(conn)
|
|
932
|
+
if (uset.size === 0) this._userConns.delete(conn.userId)
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (conn.sessionId !== undefined) {
|
|
936
|
+
// 같은 sessionId 가 다른(새) 연결로 교체된 경우엔 이 연결만 지운다(오래된 연결의 close 가
|
|
937
|
+
// 새 매핑을 지우지 않게 동일성 확인).
|
|
938
|
+
if (this._sessionConns.get(conn.sessionId) === conn) this._sessionConns.delete(conn.sessionId)
|
|
939
|
+
if (this._hubLink?.isRegistered) {
|
|
940
|
+
try {
|
|
941
|
+
this._hubLink.leave(conn.sessionId)
|
|
942
|
+
} catch (err) {
|
|
943
|
+
// 소켓이 닫히는 중이면 LEAVE 송신 실패 — 비치명적. hub 의 bridge-gone 정리가 보강한다.
|
|
944
|
+
this.fastify.log?.debug?.({ err, sessionId: conn.sessionId, app: this.name }, 'ws.leave send failed')
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* HTTP `'upgrade'` 이벤트 처리 — 경로 매칭 → 인증(before) → 핸드셰이크 → 채널 라이프사이클.
|
|
952
|
+
*
|
|
953
|
+
* single 모드는 {@link MegaApp#listen} 이, scaffold 모드는 MegaServer 가 호스트 분기 후 호출한다.
|
|
954
|
+
* 매칭 WS 라우트가 없으면 404 로 거부 (HTTP 핸드셰이크 단계라 WS close 아님).
|
|
955
|
+
*
|
|
956
|
+
* @param {import('node:http').IncomingMessage} req
|
|
957
|
+
* @param {import('node:stream').Duplex} socket
|
|
958
|
+
* @param {Buffer} head
|
|
959
|
+
* @returns {void}
|
|
960
|
+
*/
|
|
961
|
+
handleUpgrade(req, socket, head) {
|
|
962
|
+
const log = this.fastify.log
|
|
963
|
+
|
|
964
|
+
// H1: raw 소켓 error 가드. ws 패키지는 wss.handleUpgrade 가 WebSocket 을 만든 "후"에야
|
|
965
|
+
// 소켓에 'error' 리스너를 붙인다. 그 전(특히 비동기 before await 윈도우)에 클라이언트가
|
|
966
|
+
// ECONNRESET 등을 내면 listener 없는 EventEmitter 가 uncaught exception 으로 프로세스를
|
|
967
|
+
// 죽인다. 진입 즉시 임시 핸들러를 달고, 핸드셰이크 종단(reject 또는 WebSocket 생성) 시 뗀다.
|
|
968
|
+
const onSocketError = (/** @type {Error} */ err) => {
|
|
969
|
+
log.warn?.({ err, app: this.name }, 'raw socket error during upgrade handoff')
|
|
970
|
+
}
|
|
971
|
+
socket.on('error', onSocketError)
|
|
972
|
+
/** 임시 error 가드 해제 (각 종단 직전 호출 — 중복 호출 안전). */
|
|
973
|
+
const detachSocketGuard = () => socket.removeListener('error', onSocketError)
|
|
974
|
+
|
|
975
|
+
const path = String(req.url ?? '/').split('?')[0]
|
|
976
|
+
const route = this.wsRoutes.find((r) => r.path === path)
|
|
977
|
+
if (!route) {
|
|
978
|
+
log.debug?.({ path, app: this.name }, 'ws upgrade — no matching route (404)')
|
|
979
|
+
detachSocketGuard()
|
|
980
|
+
rejectUpgrade(socket, 404, 'Not Found', log)
|
|
981
|
+
return
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// before = upgrade 인증 (ADR-091). throw 또는 false 반환 → 401 거부. 객체 반환 → 인증 신원(ctx.auth,
|
|
985
|
+
// DI): 마지막으로 객체를 돌려준 before 의 값이 신원이 된다. true/undefined 반환은 "허용,
|
|
986
|
+
// 신원 없음". onConnect 가 ctx.auth 로 받아 app.joinSession(...) 에 쓴다.
|
|
987
|
+
const before = Array.isArray(route.opts?.before) ? route.opts.before : []
|
|
988
|
+
Promise.resolve()
|
|
989
|
+
.then(async () => {
|
|
990
|
+
/** @type {any} */
|
|
991
|
+
let auth = null
|
|
992
|
+
for (const fn of before) {
|
|
993
|
+
const result = await fn(req)
|
|
994
|
+
if (result === false) return { allowed: false, auth: null }
|
|
995
|
+
if (result && typeof result === 'object') auth = result
|
|
996
|
+
}
|
|
997
|
+
return { allowed: true, auth }
|
|
998
|
+
})
|
|
999
|
+
.then(({ allowed, auth }) => {
|
|
1000
|
+
if (allowed === false) {
|
|
1001
|
+
log.debug?.({ path, app: this.name }, 'ws upgrade — before denied (401)')
|
|
1002
|
+
detachSocketGuard()
|
|
1003
|
+
rejectUpgrade(socket, 401, 'Unauthorized', log)
|
|
1004
|
+
return
|
|
1005
|
+
}
|
|
1006
|
+
const wss = this._ensureWss()
|
|
1007
|
+
wss.handleUpgrade(req, /** @type {any} */ (socket), head, (/** @type {import('ws').WebSocket} */ raw) => {
|
|
1008
|
+
// WebSocket 인스턴스 생성됨 → ws 가 소켓 error 를 인수한다. 임시 가드 해제.
|
|
1009
|
+
detachSocketGuard()
|
|
1010
|
+
const codec = this._buildWsCodec(route, req)
|
|
1011
|
+
driveWsConnection({ raw, req, route, app: this, codec, log, auth })
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
1014
|
+
.catch((err) => {
|
|
1015
|
+
// before 미들웨어 throw = 인증 실패. fail-closed 401 (silent 금지).
|
|
1016
|
+
log.warn?.({ err, path, app: this.name }, 'ws upgrade — before threw (401)')
|
|
1017
|
+
detachSocketGuard()
|
|
1018
|
+
rejectUpgrade(socket, 401, 'Unauthorized', log)
|
|
1019
|
+
})
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* 연결별 프레임 코덱 선택 (ADR-083). ASP 설정 시 namespace 활성 여부로 E:/P: 기본 결정.
|
|
1024
|
+
* @param {{ path: string }} route
|
|
1025
|
+
* @param {import('node:http').IncomingMessage} req
|
|
1026
|
+
* @returns {import('./ws-upgrade.js').WsFrameCodec}
|
|
1027
|
+
* @private
|
|
1028
|
+
*/
|
|
1029
|
+
_buildWsCodec(route, req) {
|
|
1030
|
+
const asp = this._wsAsp
|
|
1031
|
+
if (!asp) return createPlainCodec()
|
|
1032
|
+
const domain = String(req.headers.host ?? '').split(':')[0]
|
|
1033
|
+
const ua = String(req.headers['user-agent'] ?? '')
|
|
1034
|
+
// 활성 namespace = 기본 암호화(E:). 비활성 = 평문(P:). 둘 다 prefix 권위로 양방향 수신.
|
|
1035
|
+
const encrypt = asp.namespaces.includes(route.path)
|
|
1036
|
+
const terminator = new MegaAspTerminator({
|
|
1037
|
+
masterSecret: asp.masterSecret,
|
|
1038
|
+
domain,
|
|
1039
|
+
wsPath: route.path,
|
|
1040
|
+
ua,
|
|
1041
|
+
encrypt,
|
|
1042
|
+
})
|
|
1043
|
+
return createAspCodec(terminator)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* noServer 모드 WebSocketServer lazy 생성 (WS 라우트가 있을 때만).
|
|
1048
|
+
* @returns {WebSocketServer}
|
|
1049
|
+
* @private
|
|
1050
|
+
*/
|
|
1051
|
+
_ensureWss() {
|
|
1052
|
+
// perMessageDeflate 는 noServer 모드에서도 handleUpgrade → completeUpgrade 시 negotiate 된다
|
|
1053
|
+
// (ws/lib/websocket-server.js completeUpgrade). false 면 ws 기본(압축 OFF)과 동일. ADR-078.
|
|
1054
|
+
// maxPayload 명시(L-3, ADR-099) — 미지정 시 ws 기본 100 MiB 라 Hub(1 MiB)와 비대칭이 되므로 대칭화.
|
|
1055
|
+
if (!this._wss) {
|
|
1056
|
+
this._wss = new WebSocketServer({
|
|
1057
|
+
noServer: true,
|
|
1058
|
+
perMessageDeflate: this._wsPerMessageDeflate,
|
|
1059
|
+
maxPayload: this._wsMaxPayloadBytes,
|
|
1060
|
+
})
|
|
1061
|
+
}
|
|
1062
|
+
return this._wss
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* 등록된 WS 라우트가 있으면 httpServer 의 'upgrade' 이벤트를 자기 handleUpgrade 에 배선.
|
|
1067
|
+
* single 모드 listen 에서 호출. WS 라우트가 없으면 no-op (불필요한 리스너 회피).
|
|
1068
|
+
* @param {import('node:http').Server} httpServer
|
|
1069
|
+
* @returns {void}
|
|
1070
|
+
*/
|
|
1071
|
+
_attachWsUpgrade(httpServer) {
|
|
1072
|
+
if (this.wsRoutes.length === 0) return
|
|
1073
|
+
this._ensureWss()
|
|
1074
|
+
httpServer.on('upgrade', (req, socket, head) => this.handleUpgrade(req, socket, head))
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* WS ASP 옵트인 정규화. masterSecret 없으면 null (평문 WS).
|
|
1079
|
+
* @param {{ masterSecret?: string, websocket?: { namespaces?: string[] } }} [asp]
|
|
1080
|
+
* @returns {{ masterSecret: string, namespaces: string[] } | null}
|
|
1081
|
+
* @private
|
|
1082
|
+
*/
|
|
1083
|
+
static _normalizeWsAsp(asp) {
|
|
1084
|
+
if (!asp || typeof asp.masterSecret !== 'string' || asp.masterSecret.length === 0) {
|
|
1085
|
+
return null
|
|
1086
|
+
}
|
|
1087
|
+
const namespaces = Array.isArray(asp.websocket?.namespaces) ? asp.websocket.namespaces : []
|
|
1088
|
+
return { masterSecret: asp.masterSecret, namespaces }
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Single 모드 전용 listen — MegaServer 거치지 않고 직접 listen.
|
|
1093
|
+
* Scaffold 모드는 MegaServer.mount() + MegaServer.listen() 사용.
|
|
1094
|
+
* @param {{ port?: number, host?: string }} [opts]
|
|
1095
|
+
*/
|
|
1096
|
+
async listen(opts = {}) {
|
|
1097
|
+
if (this.mode !== 'single') {
|
|
1098
|
+
throw new Error(
|
|
1099
|
+
`MegaApp '${this.name}' is in scaffold mode — use MegaServer.mount() + MegaServer.listen() instead.`,
|
|
1100
|
+
)
|
|
1101
|
+
}
|
|
1102
|
+
const addr = await this.fastify.listen({ port: opts.port ?? 3000, host: opts.host ?? '0.0.0.0' })
|
|
1103
|
+
// fastify.server 는 listen 후의 raw http.Server. WS 라우트가 있으면 upgrade 배선.
|
|
1104
|
+
this._attachWsUpgrade(this.fastify.server)
|
|
1105
|
+
return addr
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** 인스턴스 close — hub link · WS 연결 정리 후 진행 중 요청 grace 후 종료. */
|
|
1109
|
+
async close() {
|
|
1110
|
+
// hub link 먼저 끊는다 (더 이상 fan-out 수신 불필요). shutdown hook 도 함께 떼어 누수 방지(L1).
|
|
1111
|
+
if (this._hubLink) {
|
|
1112
|
+
this._hubLink.close()
|
|
1113
|
+
this._hubLink = null
|
|
1114
|
+
this._hubBridgeId = null
|
|
1115
|
+
MegaShutdown.unregister(`mega-hublink:${this.name}`)
|
|
1116
|
+
}
|
|
1117
|
+
this._wsConns.clear()
|
|
1118
|
+
this._userConns.clear()
|
|
1119
|
+
this._sessionConns.clear()
|
|
1120
|
+
// 활성 WS 연결을 먼저 정리 (1001 going away). noServer wss 는 clientTracking 기본 on.
|
|
1121
|
+
if (this._wss) {
|
|
1122
|
+
for (const client of this._wss.clients) client.close(1001, 'server shutting down')
|
|
1123
|
+
await new Promise((resolve) => this._wss?.close(() => resolve(undefined)))
|
|
1124
|
+
this._wss = null
|
|
1125
|
+
}
|
|
1126
|
+
await this.fastify.close()
|
|
1127
|
+
// 세션 store 정리 (있으면) + shutdown hook 해제(누수 방지, hublink 패턴 정합).
|
|
1128
|
+
if (this._sessionStore) {
|
|
1129
|
+
MegaShutdown.unregister(`mega-session:${this.name}`)
|
|
1130
|
+
await this._sessionStore.disconnect().catch((err) => this.fastify.log.warn({ err }, 'session store disconnect failed (close)'))
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** 이 앱의 세션 스토어 어댑터 (세션 미활성 시 null). 테스트·헬스용. */
|
|
1135
|
+
get sessionStore() {
|
|
1136
|
+
return this._sessionStore
|
|
1137
|
+
}
|
|
1138
|
+
}
|