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,445 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* WS HTTP Upgrade 핸들오프 — embedded 모드.
|
|
4
|
+
*
|
|
5
|
+
* `router.ws`/`app.ws` 로 등록된 WS 라우트를 실 HTTP `'upgrade'` 이벤트에 연결한다.
|
|
6
|
+
* 토폴로지상 production 은 별도 hub 프로세스(02-architecture §5-3)지만, **single 모드**
|
|
7
|
+
* (`app.ws` + `app.listen`) 와 dev/통합 테스트는 서버 프로세스가 직접 WS 를 종단한다.
|
|
8
|
+
* 본 모듈은 그 embedded 종단 로직(코덱 선택 → 핸드셰이크 → 라이프사이클 구동)을 담는다.
|
|
9
|
+
*
|
|
10
|
+
* # 프레임 코덱 (ADR-083)
|
|
11
|
+
* - ASP 활성 namespace: {@link MegaAspTerminator} 경유 `E:`(암호)/`P:`(평문) prefix.
|
|
12
|
+
* - ASP 미설정 앱: prefix 없는 raw JSON (zero-config WS). WASM `MegaSocket` 정합은 후속 과제.
|
|
13
|
+
*
|
|
14
|
+
* # fail-closed (ADR-084)
|
|
15
|
+
* decrypt/포맷 실패 → silent fallback 금지. 평문 `asp.error` 통지 후 close 4500.
|
|
16
|
+
* 잘못된 envelope → 명시적 error envelope 응답(연결은 유지 — 비치명적).
|
|
17
|
+
*
|
|
18
|
+
* # close code 카탈로그 (ADR-096)
|
|
19
|
+
* - `1000` Normal closure (정상 종료)
|
|
20
|
+
* - `1001` Going away (graceful shutdown — MegaApp.close)
|
|
21
|
+
* - `1011` Internal error (onConnect 등 서버 내부 실패) — {@link CLOSE_CODE_INTERNAL_ERROR}
|
|
22
|
+
* - `4500` Decrypt failed (ASP 키 mismatch / 프레임 손상) — {@link CLOSE_CODE_DECRYPT_FAILED}
|
|
23
|
+
* 4500 은 복호화 실패 **전용**이다. 일반 핸들러 실패를 4500 으로 닫으면 클라이언트가 키
|
|
24
|
+
* mismatch 로 오해하므로, onConnect 등 서버 내부 오류는 RFC 6455 표준 `1011` 로 분리한다(M3).
|
|
25
|
+
*
|
|
26
|
+
* @module core/ws-upgrade
|
|
27
|
+
*/
|
|
28
|
+
import { createWsMessage, parseWsMessage, generateMessageId, WS_TYPE_PATTERN } from './ws-message.js'
|
|
29
|
+
import { MegaAspDecryptError } from '../lib/asp/errors.js'
|
|
30
|
+
import * as MegaTracing from '../lib/mega-tracing.js'
|
|
31
|
+
import * as MegaMetrics from '../lib/mega-metrics.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 라우팅 핸들러가 없는(=onMessage 폴백으로 가는) WS 메시지 type 을 접는 고정 라벨. HTTP 의
|
|
35
|
+
* `__unmatched__`(ADR-131 M-A)와 동일 정책 — 악의적 클라가 임의 type 을 무한히 보내 메트릭 라벨
|
|
36
|
+
* 카디널리티를 폭증시키는 풋건을 차단한다.
|
|
37
|
+
*/
|
|
38
|
+
export const UNHANDLED_WS_TYPE = '__unhandled__'
|
|
39
|
+
|
|
40
|
+
/** ASP decrypt 실패 close code (ADR-084, transport_ws.rs CLOSE_CODE_DECRYPT_FAILED 와 일치). */
|
|
41
|
+
export const CLOSE_CODE_DECRYPT_FAILED = 4500
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 서버 내부 오류 close code (RFC 6455 §7.4.1 표준 1011, M3).
|
|
45
|
+
* onConnect 실패 등 **복호화와 무관한** 서버측 오류에 사용한다 — 4500(decrypt) 과 의미 분리.
|
|
46
|
+
*/
|
|
47
|
+
export const CLOSE_CODE_INTERNAL_ERROR = 1011
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* WS 프레임 코덱 — 평문/암호 와이어 변환을 추상화한다.
|
|
51
|
+
* @typedef {Object} WsFrameCodec
|
|
52
|
+
* @property {(frame: string) => string} decode - 수신 프레임 → 평문 JSON 문자열 (실패 시 throw).
|
|
53
|
+
* @property {(json: string) => string} encode - 평문 JSON → 송신 프레임 (기본: ASP 활성 시 암호).
|
|
54
|
+
* @property {(json: string) => string} encodePlain - 평문 JSON → 평문 송신 프레임 (asp.error 통지용).
|
|
55
|
+
* @property {boolean} isEncrypted - 기본 송신이 암호화인지.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ASP 미설정 앱용 평문 코덱 — prefix 없는 raw JSON 그대로.
|
|
60
|
+
* @returns {WsFrameCodec}
|
|
61
|
+
*/
|
|
62
|
+
export function createPlainCodec() {
|
|
63
|
+
return {
|
|
64
|
+
decode: (frame) => frame,
|
|
65
|
+
encode: (json) => json,
|
|
66
|
+
encodePlain: (json) => json,
|
|
67
|
+
isEncrypted: false,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* ASP 활성/비활성 namespace 용 코덱 — {@link MegaAspTerminator} 위임 (`E:`/`P:` prefix).
|
|
73
|
+
* @param {import('../lib/asp/ws-terminator.js').MegaAspTerminator} terminator
|
|
74
|
+
* @returns {WsFrameCodec}
|
|
75
|
+
*/
|
|
76
|
+
export function createAspCodec(terminator) {
|
|
77
|
+
return {
|
|
78
|
+
decode: (frame) => terminator.decodeFrame(frame).plain,
|
|
79
|
+
encode: (json) => terminator.encodeFrame(json),
|
|
80
|
+
encodePlain: (json) => terminator.encodeFrame(json, { plain: true }),
|
|
81
|
+
isEncrypted: terminator.isEncrypted,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 채널 핸들러에 전달되는 소켓 래퍼 — envelope 송신을 강제한다.
|
|
87
|
+
*
|
|
88
|
+
* 사용자 채널은 `sock.send({ type, payload, ref })` 형태로 호출한다. 내부에서
|
|
89
|
+
* {@link createWsMessage} 로 v/id/ts 를 자동 채운 valid envelope 을 만든 뒤 코덱으로
|
|
90
|
+
* 인코딩해 와이어에 쓴다. raw `ws` 소켓은 {@link MegaWsConnection#raw} 로 노출 (escape hatch).
|
|
91
|
+
*/
|
|
92
|
+
export class MegaWsConnection {
|
|
93
|
+
/**
|
|
94
|
+
* @param {import('ws').WebSocket} rawSocket
|
|
95
|
+
* @param {WsFrameCodec} codec
|
|
96
|
+
* @param {{ id?: string, ns?: string, path: string }} meta
|
|
97
|
+
*/
|
|
98
|
+
constructor(rawSocket, codec, meta) {
|
|
99
|
+
/** @type {import('ws').WebSocket} */
|
|
100
|
+
this._raw = rawSocket
|
|
101
|
+
/** @type {WsFrameCodec} */
|
|
102
|
+
this._codec = codec
|
|
103
|
+
/** @type {string} 연결 식별자 (ULID). */
|
|
104
|
+
this.id = meta.id ?? generateMessageId()
|
|
105
|
+
/** @type {string|undefined} 채널 namespace. */
|
|
106
|
+
this.ns = meta.ns
|
|
107
|
+
/** @type {string} 연결 경로. */
|
|
108
|
+
this.path = meta.path
|
|
109
|
+
/** @type {string|undefined} joinSession 으로 매핑된 사용자 식별자 (DIRECT 타겟). */
|
|
110
|
+
this.userId = undefined
|
|
111
|
+
/** @type {string|undefined} joinSession 으로 매핑된 세션 식별자. */
|
|
112
|
+
this.sessionId = undefined
|
|
113
|
+
/** @type {Set<string>|null} joinSession 으로 가입한 채널 집합. */
|
|
114
|
+
this.channels = null
|
|
115
|
+
/** @type {Object|undefined} joinSession/updateMetadata 로 저장한 presence 메타(재연결 재동기화에 보존, M-1). */
|
|
116
|
+
this.metadata = undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 하위 raw `ws` WebSocket (escape hatch — 바이너리/직접 제어용). */
|
|
120
|
+
get raw() {
|
|
121
|
+
return this._raw
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 연결이 OPEN 상태인지 (`ws.OPEN` === 1). */
|
|
125
|
+
get isOpen() {
|
|
126
|
+
return this._raw.readyState === 1
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* envelope 송신. v/id/ts 자동 채움 (§6.2). ASP 활성 시 자동 암호화.
|
|
131
|
+
*
|
|
132
|
+
* `ns` 가 생략되면 연결의 namespace(`this.ns`)를 자동 주입한다(L1). 호출부가 명시한 `ns` 는
|
|
133
|
+
* 그대로 존중한다(다른 namespace 로 보내는 경우 등).
|
|
134
|
+
*
|
|
135
|
+
* @param {{ type: string, ns?: string, payload?: Object, error?: Object, ref?: string }} fields
|
|
136
|
+
* @returns {void}
|
|
137
|
+
*/
|
|
138
|
+
send(fields) {
|
|
139
|
+
// ns 자동 주입 (L1): 명시 안 됐고 연결 ns 가 있으면 채운다. 명시값은 덮어쓰지 않음.
|
|
140
|
+
const withNs = fields.ns === undefined && this.ns !== undefined ? { ...fields, ns: this.ns } : fields
|
|
141
|
+
const env = createWsMessage(withNs)
|
|
142
|
+
this._raw.send(this._codec.encode(JSON.stringify(env)))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* error envelope 송신 (§6.3). 핸들러 throw / 검증 실패 통지용.
|
|
147
|
+
* @param {{ code: string, message?: string, details?: unknown, ref?: string, type?: string }} opts
|
|
148
|
+
* @returns {void}
|
|
149
|
+
*/
|
|
150
|
+
sendError({ code, message, details, ref, type = 'mega.error' }) {
|
|
151
|
+
/** @type {Record<string, any>} */
|
|
152
|
+
const error = { code }
|
|
153
|
+
if (message !== undefined) error.message = message
|
|
154
|
+
if (details !== undefined) error.details = details
|
|
155
|
+
this.send({ type, error, ...(ref !== undefined ? { ref } : {}) })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 연결 종료.
|
|
160
|
+
* @param {number} [code]
|
|
161
|
+
* @param {string} [reason]
|
|
162
|
+
* @returns {void}
|
|
163
|
+
*/
|
|
164
|
+
close(code, reason) {
|
|
165
|
+
this._raw.close(code, reason)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 핸드셰이크 완료된 raw 소켓에 채널 라이프사이클을 구동한다.
|
|
171
|
+
*
|
|
172
|
+
* `onConnect` 1회 → 매 message `dispatch` (코덱 decode → envelope 검증 → type 디스패치) →
|
|
173
|
+
* close 시 `onDisconnect`. 모든 단계는 fail-closed (ADR-084) + 명시적 로그.
|
|
174
|
+
*
|
|
175
|
+
* @param {Object} args
|
|
176
|
+
* @param {import('ws').WebSocket} args.raw - 핸드셰이크된 ws 소켓.
|
|
177
|
+
* @param {import('node:http').IncomingMessage} args.req - upgrade 요청 (ctx 용).
|
|
178
|
+
* @param {{ path: string, ns?: string, ChannelClass: Function, opts: Object, schemaValidators?: Record<string, import('ajv').ValidateFunction> | null }} args.route
|
|
179
|
+
* @param {import('./mega-app.js').MegaApp} args.app
|
|
180
|
+
* @param {WsFrameCodec} args.codec
|
|
181
|
+
* @param {any} args.log - request 로거 (debug/warn/error).
|
|
182
|
+
* @param {any} [args.auth] - `before` 미들웨어가 인증 후 돌려준 신원(`{ userId, sessionId, ... }`).
|
|
183
|
+
* `ctx.auth` 로 노출 — onConnect 에서 `app.joinSession(sock, { userId: ctx.auth.userId, ... })` 에 쓴다.
|
|
184
|
+
* @returns {MegaWsConnection}
|
|
185
|
+
*/
|
|
186
|
+
export function driveWsConnection({ raw, req, route, app, codec, log, auth = null }) {
|
|
187
|
+
const conn = new MegaWsConnection(raw, codec, { ns: route.ns, path: route.path })
|
|
188
|
+
// ChannelClass 는 부팅 시 MegaWebSocketController 상속 검증됨 (router.ws). 여기선 인스턴스화만.
|
|
189
|
+
const channel = /** @type {import('./ws-controller.js').MegaWebSocketController} */ (
|
|
190
|
+
new (/** @type {any} */ (route.ChannelClass))()
|
|
191
|
+
)
|
|
192
|
+
// services DI(request-scoped, OQ-010) 는 후속. 현재는 app/log/auth 만 바인딩.
|
|
193
|
+
// auth = before 미들웨어가 채운 인증 신원(없으면 null). onConnect 가 joinSession 에 사용.
|
|
194
|
+
// db/cache/bus 접근자(ADR-102)를 HTTP ctx 와 동일 표면으로 WS ctx 에도 노출. mock app(단위 테스트)은
|
|
195
|
+
// 접근자가 없을 수 있어 옵셔널 spread.
|
|
196
|
+
/** @type {Record<string, any>} */
|
|
197
|
+
const ctx = {
|
|
198
|
+
app,
|
|
199
|
+
log,
|
|
200
|
+
services: null,
|
|
201
|
+
auth,
|
|
202
|
+
ns: route.ns,
|
|
203
|
+
path: route.path,
|
|
204
|
+
req,
|
|
205
|
+
connId: conn.id,
|
|
206
|
+
tracer: MegaTracing.tracer, // ctx.tracer.span(name, fn) — WS 핸들러에서도 사용자 직접 span(ADR-126).
|
|
207
|
+
...(app?.adapterAccessors ?? {}),
|
|
208
|
+
}
|
|
209
|
+
channel._bind({ ctx, app, log, services: null })
|
|
210
|
+
|
|
211
|
+
// 로컬 연결 레지스트리 등록 (hub broadcast fan-out 의 local 전달 대상). close 시 해제.
|
|
212
|
+
// 옵셔널 호출 — 단위 테스트의 mock app 은 추적 메서드가 없을 수 있다.
|
|
213
|
+
app._trackWsConn?.(conn)
|
|
214
|
+
|
|
215
|
+
log.debug?.({ connId: conn.id, path: route.path, ns: route.ns }, 'ws.connect enter')
|
|
216
|
+
|
|
217
|
+
// onConnect — 실패 시 fail-closed. 복호화와 무관한 서버 내부 오류이므로 1011 (M3). silent 금지.
|
|
218
|
+
Promise.resolve()
|
|
219
|
+
.then(() => channel.onConnect(conn, ctx))
|
|
220
|
+
.catch((err) => {
|
|
221
|
+
log.error?.({ err, connId: conn.id }, 'ws.onConnect threw — closing (1011)')
|
|
222
|
+
if (conn.isOpen) conn.close(CLOSE_CODE_INTERNAL_ERROR, 'onConnect failed')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// type 별 payload 스키마 검증 함수 맵 (M2, router.ws 에서 사전 컴파일). 없으면 검증 없음.
|
|
226
|
+
const schemaValidators = route.schemaValidators ?? null
|
|
227
|
+
|
|
228
|
+
raw.on('message', (data) => {
|
|
229
|
+
// ws 는 Buffer | ArrayBuffer | Buffer[] 를 줄 수 있음. 텍스트 프레임만 처리 (바이너리는 후속 BINARY 타입).
|
|
230
|
+
const frame = Array.isArray(data)
|
|
231
|
+
? Buffer.concat(data).toString('utf8')
|
|
232
|
+
: data.toString('utf8')
|
|
233
|
+
// 최외곽 가드 (L2): handleIncoming 은 내부에서 단계별 try/catch 하지만, 예기치 못한
|
|
234
|
+
// 동기 throw / reject 가 unhandledRejection 으로 새지 않도록 .catch 로 마무리한다.
|
|
235
|
+
handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaValidators, log }).catch((err) => {
|
|
236
|
+
log.warn?.({ err, connId: conn.id }, 'ws handleIncoming unexpected error')
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
raw.on('close', (code, reasonBuf) => {
|
|
241
|
+
app._untrackWsConn?.(conn)
|
|
242
|
+
const reason = Buffer.isBuffer(reasonBuf) ? reasonBuf.toString('utf8') : String(reasonBuf ?? '')
|
|
243
|
+
log.debug?.({ connId: conn.id, code, reason }, 'ws.disconnect')
|
|
244
|
+
Promise.resolve()
|
|
245
|
+
.then(() => channel.onDisconnect(conn, ctx))
|
|
246
|
+
.catch((err) => {
|
|
247
|
+
// onDisconnect 는 정리 단계 — throw 해도 연결은 이미 닫힘. warn 후 무시 (이유+로그).
|
|
248
|
+
log.warn?.({ err, connId: conn.id }, 'ws.onDisconnect threw — ignored (already closed)')
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
raw.on('error', (err) => {
|
|
253
|
+
// 소켓 레벨 에러(전송 실패 등) — 비치명적, 로그만. close 이벤트가 뒤따른다.
|
|
254
|
+
log.warn?.({ err, connId: conn.id }, 'ws socket error')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
return conn
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* AJV payload 검증 에러 → envelope details 배열 (M2, ADR-075 형식).
|
|
262
|
+
*
|
|
263
|
+
* HTTP 의 {@link import('./ajv-mapper.js')} 와 동일한 `{ field, rule, message }` shape 를 쓰되,
|
|
264
|
+
* WS payload 는 단순 값이라 PII 마스킹/value 노출은 하지 않는다(필드 경로·규칙·메시지만).
|
|
265
|
+
*
|
|
266
|
+
* @param {import('ajv').ErrorObject[] | null | undefined} errors - `validate.errors`.
|
|
267
|
+
* @returns {Array<{ field: string, rule: string, message: string }>}
|
|
268
|
+
* @private
|
|
269
|
+
*/
|
|
270
|
+
function wsPayloadErrorsToDetails(errors) {
|
|
271
|
+
if (!Array.isArray(errors)) return []
|
|
272
|
+
return errors.map((e) => ({
|
|
273
|
+
// instancePath 예: '/n' → 'n', '' → '(root)'. payload 루트 기준 경로.
|
|
274
|
+
field: e.instancePath ? e.instancePath.replace(/^\//, '').replace(/\//g, '.') : '(root)',
|
|
275
|
+
rule: e.keyword || 'unknown',
|
|
276
|
+
message: e.message || 'invalid',
|
|
277
|
+
}))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* WS 메시지 메트릭 라벨용 type 을 카디널리티-안전하게 접는다(ADR-132). 채널에 **전용
|
|
282
|
+
* 핸들러 메서드가 등록된 type** 만 그대로 라벨에 노출하고, 그 외(= {@link MegaWebSocketController#dispatch}
|
|
283
|
+
* 가 `onMessage` 폴백으로 보내는 임의 type)는 {@link UNHANDLED_WS_TYPE} 로 접는다. dispatch 의 핸들러 판정
|
|
284
|
+
* (`WS_TYPE_PATTERN` + 동명 메서드 존재)을 그대로 미러하므로 "라벨로 노출되는 type = 실제 라우팅된 type" 이
|
|
285
|
+
* 보장된다. HTTP 가 매칭된 라우트 패턴만 라벨에 쓰는 것(ADR-131)과 동일한 카디널리티 정책이다.
|
|
286
|
+
*
|
|
287
|
+
* **트레이싱 span(`ws.<type>`)은 raw type 을 그대로 쓴다** — span 은 요청마다 독립이라 무한 type 도 집계
|
|
288
|
+
* 폭증을 일으키지 않지만, 메트릭 라벨은 누적 집계라 반드시 bounded 해야 하기 때문이다(둘의 의도된 차이).
|
|
289
|
+
*
|
|
290
|
+
* @param {import('./ws-controller.js').MegaWebSocketController} channel - dispatch 대상 채널 인스턴스.
|
|
291
|
+
* @param {string} type - 검증된 envelope 의 메시지 type.
|
|
292
|
+
* @returns {string} 라벨에 안전한 type(등록 핸들러 있으면 원본, 없으면 `__unhandled__`).
|
|
293
|
+
*/
|
|
294
|
+
export function wsMetricType(channel, type) {
|
|
295
|
+
if (
|
|
296
|
+
typeof type === 'string' &&
|
|
297
|
+
WS_TYPE_PATTERN.test(type) &&
|
|
298
|
+
typeof (/** @type {any} */ (channel)[type]) === 'function'
|
|
299
|
+
) {
|
|
300
|
+
return type
|
|
301
|
+
}
|
|
302
|
+
return UNHANDLED_WS_TYPE
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 수신 프레임 1건 처리 — decode → 검증 → dispatch. 단계별 fail-closed.
|
|
307
|
+
*
|
|
308
|
+
* @param {Object} args
|
|
309
|
+
* @param {MegaWsConnection} args.conn
|
|
310
|
+
* @param {import('./ws-controller.js').MegaWebSocketController} args.channel
|
|
311
|
+
* @param {WsFrameCodec} args.codec
|
|
312
|
+
* @param {any} args.ctx
|
|
313
|
+
* @param {import('ws').WebSocket} args.raw
|
|
314
|
+
* @param {string} args.frame
|
|
315
|
+
* @param {Record<string, import('ajv').ValidateFunction> | null} args.schemaValidators
|
|
316
|
+
* type 별 payload 검증 함수 맵 (M2). null 이면 검증 생략.
|
|
317
|
+
* @param {any} args.log
|
|
318
|
+
* @returns {Promise<void>}
|
|
319
|
+
* @private
|
|
320
|
+
*/
|
|
321
|
+
async function handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaValidators, log }) {
|
|
322
|
+
// 1) 코덱 decode (ASP 복호화 포함). 실패 → fail-closed (ADR-084).
|
|
323
|
+
let plain
|
|
324
|
+
try {
|
|
325
|
+
plain = codec.decode(frame)
|
|
326
|
+
} catch (err) {
|
|
327
|
+
const rule = err instanceof MegaAspDecryptError ? err.rule : 'unknown'
|
|
328
|
+
log.warn?.({ err, rule, connId: conn.id }, 'ws frame decode failed (ADR-084)')
|
|
329
|
+
// 클라가 키를 못 만드는 상황 → 평문 asp.error 통지 후 close 4500.
|
|
330
|
+
try {
|
|
331
|
+
const notice = JSON.stringify(
|
|
332
|
+
createWsMessage({ type: 'asp.error', error: { code: 'asp.decrypt_failed', rule } }),
|
|
333
|
+
)
|
|
334
|
+
raw.send(codec.encodePlain(notice))
|
|
335
|
+
} catch (sendErr) {
|
|
336
|
+
// 송신 실패 = 소켓이 이미 닫히는 중. 비치명적 — close 로 마무리 (이유+로그).
|
|
337
|
+
log.debug?.({ err: sendErr, connId: conn.id }, 'ws asp.error notify failed (socket closing)')
|
|
338
|
+
}
|
|
339
|
+
if (conn.isOpen) raw.close(CLOSE_CODE_DECRYPT_FAILED, 'asp.decrypt_failed')
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 2) envelope 파싱 + 검증. 실패 → error envelope 응답(연결 유지 — 비치명적).
|
|
344
|
+
/** @type {{ type: string, id: string }} */
|
|
345
|
+
let msg
|
|
346
|
+
try {
|
|
347
|
+
msg = /** @type {{ type: string, id: string }} */ (parseWsMessage(plain))
|
|
348
|
+
} catch (err) {
|
|
349
|
+
log.warn?.({ err, connId: conn.id }, 'ws invalid envelope')
|
|
350
|
+
if (conn.isOpen) {
|
|
351
|
+
conn.sendError({
|
|
352
|
+
code: 'ws.invalid_message',
|
|
353
|
+
message: err instanceof Error ? err.message : String(err),
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 2.5) payload 스키마 검증 (M2). type 에 등록된 스키마가 있으면 payload 검증.
|
|
360
|
+
// 실패 → ws.invalid_payload error envelope 응답(연결 유지 — HTTP AJV 매핑과 동일 fail-closed,
|
|
361
|
+
// ADR-090). 스키마 없는 type 은 그대로 통과. fail-closed 표방인데 통과시키지 않기 위함.
|
|
362
|
+
const validate = schemaValidators ? schemaValidators[msg.type] : undefined
|
|
363
|
+
if (validate) {
|
|
364
|
+
/** @type {any} */
|
|
365
|
+
const payload = /** @type {any} */ (msg).payload
|
|
366
|
+
if (!validate(payload)) {
|
|
367
|
+
const details = wsPayloadErrorsToDetails(validate.errors)
|
|
368
|
+
log.warn?.({ connId: conn.id, type: msg.type, details }, 'ws payload schema validation failed')
|
|
369
|
+
if (conn.isOpen) {
|
|
370
|
+
conn.sendError({
|
|
371
|
+
code: 'ws.invalid_payload',
|
|
372
|
+
message: `payload failed schema validation for type '${msg.type}'`,
|
|
373
|
+
details,
|
|
374
|
+
ref: msg.id,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 3) type 자동 디스패치 (ADR-015). 핸들러 throw → error envelope (ref 매칭).
|
|
382
|
+
log.debug?.({ connId: conn.id, type: msg.type, ref: msg.id }, 'ws.message dispatch')
|
|
383
|
+
// WS 메시지 메트릭 (ADR-132) — 옵트인 OFF 면 isEnabled() false 라 0 비용. 트레이싱 span 과
|
|
384
|
+
// **동일 seam**(dispatch 1건 = 메트릭 1건): decode/검증 실패(위 early-return)는 애플리케이션 메시지가
|
|
385
|
+
// 아니라 프로토콜 오류라 집계 대상이 아니다. 성공·핸들러 throw 둘 다 1건으로 센다(HTTP onResponse 가
|
|
386
|
+
// 상태코드 무관하게 1건 세는 것과 정합 — wsMessages 인스트루먼트엔 성공/실패 라벨이 없음).
|
|
387
|
+
const metricsOn = MegaMetrics.isEnabled()
|
|
388
|
+
const startedAt = metricsOn ? performance.now() : 0
|
|
389
|
+
try {
|
|
390
|
+
// WS 메시지 span (ADR-126) — 메시지 1건당 루트 span `ws.<type>`. 각 message 콜백은
|
|
391
|
+
// 독립 async 흐름이라 run() 격리가 정확하고, 핸들러 안 ctx.tracer.span·어댑터 호출이 자식으로 중첩.
|
|
392
|
+
// 옵트인 OFF 면 span() 이 0 비용으로 dispatch 만 그대로 호출한다.
|
|
393
|
+
await MegaTracing.span(
|
|
394
|
+
`ws.${msg.type}`,
|
|
395
|
+
() => channel.dispatch(conn, msg, ctx),
|
|
396
|
+
{
|
|
397
|
+
kind: MegaTracing.SpanKind.SERVER,
|
|
398
|
+
attributes: { 'ws.type': msg.type, 'ws.ns': ctx.ns, 'ws.path': ctx.path, 'mega.conn_id': conn.id },
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
} catch (err) {
|
|
402
|
+
log.warn?.({ err, type: msg.type, connId: conn.id }, 'ws handler threw')
|
|
403
|
+
if (conn.isOpen) {
|
|
404
|
+
conn.sendError({
|
|
405
|
+
code: 'ws.handler_error',
|
|
406
|
+
message: err instanceof Error ? err.message : String(err),
|
|
407
|
+
ref: msg.id,
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
} finally {
|
|
411
|
+
// 성공·실패 무관하게 1건 기록(finally). type 은 wsMetricType 으로 카디널리티-안전하게 접는다(라우팅
|
|
412
|
+
// 핸들러 없는 임의 type → __unhandled__). app 이름은 ctx.app.name(mock app 대비 옵셔널).
|
|
413
|
+
if (metricsOn) {
|
|
414
|
+
MegaMetrics.recordWs({
|
|
415
|
+
type: wsMetricType(channel, msg.type),
|
|
416
|
+
ns: ctx.ns,
|
|
417
|
+
durationMs: performance.now() - startedAt,
|
|
418
|
+
app: ctx.app?.name,
|
|
419
|
+
})
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* upgrade 핸드셰이크 거부 — 401/404 등 HTTP 응답 후 소켓 파괴.
|
|
426
|
+
* (ws 핸드셰이크 전이므로 WS close frame 이 아니라 HTTP 응답으로 거부한다.)
|
|
427
|
+
*
|
|
428
|
+
* @param {import('node:stream').Duplex} socket
|
|
429
|
+
* @param {number} status - HTTP status code.
|
|
430
|
+
* @param {string} reason - status text.
|
|
431
|
+
* @param {any} [log] - 선택 로거 (write 실패 통지용).
|
|
432
|
+
* @returns {void}
|
|
433
|
+
*/
|
|
434
|
+
export function rejectUpgrade(socket, status, reason, log) {
|
|
435
|
+
// 이미 파괴된 소켓엔 write 하지 않는다(불필요한 'error' emit 회피).
|
|
436
|
+
if (!socket.destroyed) {
|
|
437
|
+
try {
|
|
438
|
+
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
|
439
|
+
} catch (err) {
|
|
440
|
+
// 닫히는 중인 소켓에 write 실패는 비치명적 — destroy 로 마무리 (이유+로그).
|
|
441
|
+
log?.debug?.({ err }, 'ws upgrade reject write failed (socket closing)')
|
|
442
|
+
}
|
|
443
|
+
socket.destroy()
|
|
444
|
+
}
|
|
445
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* config 검증 전용 에러.
|
|
4
|
+
*
|
|
5
|
+
* @module errors/config-error
|
|
6
|
+
*/
|
|
7
|
+
import { MegaError } from './mega-error.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 부팅 시 config 검증 실패 (3-스코프, 키 오타, 호스트 충돌, Shared-Reference 등).
|
|
11
|
+
* code = 'config.<reason>'.
|
|
12
|
+
*
|
|
13
|
+
* 부팅 시 throw 되어 프로세스를 중단시킨다 (fail-fast). silent catch 금지 —
|
|
14
|
+
* 이 에러를 잡아서 무시하면 잘못된 config 로 서버가 뜨므로 절대 삼키지 말 것.
|
|
15
|
+
*/
|
|
16
|
+
export class MegaConfigError extends MegaError {}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* HTTP 에러 클래스 계층 (ADR-025).
|
|
4
|
+
*
|
|
5
|
+
* 모든 HTTP 에러는 {@link MegaHttpError} 를 상속하고, status + envelope code 를 가진다.
|
|
6
|
+
* 글로벌 에러 핸들러(error-mapper.js)가 이 status/code/message/details 를
|
|
7
|
+
* envelope error (ADR-014) 로 변환한다.
|
|
8
|
+
*
|
|
9
|
+
* @module errors/http-errors
|
|
10
|
+
*/
|
|
11
|
+
import { MegaError } from './mega-error.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* HTTP 표준 status 와 envelope code 를 가진 베이스. 모든 HTTP 에러는 이걸 상속.
|
|
15
|
+
*
|
|
16
|
+
* envelope 변환 시 status → response status, code → error.code, message → error.message,
|
|
17
|
+
* details → error.details (object | array, ADR-075).
|
|
18
|
+
*
|
|
19
|
+
* 코드 컨벤션: `domain.error` (ADR-016). 예: 'user.not_found', 'auth.required'.
|
|
20
|
+
*/
|
|
21
|
+
export class MegaHttpError extends MegaError {
|
|
22
|
+
/**
|
|
23
|
+
* @param {number} status - HTTP status code (4xx/5xx).
|
|
24
|
+
* @param {string} code - 'domain.error' 형식 envelope 코드 (ADR-016).
|
|
25
|
+
* @param {string} message - 사람이 읽는 에러 메시지.
|
|
26
|
+
* @param {{ details?: object | unknown[], cause?: unknown }} [opts]
|
|
27
|
+
* - details: 구조화된 부가 정보 (object | array — ADR-075).
|
|
28
|
+
* - cause: 원인 에러 (전파용).
|
|
29
|
+
*/
|
|
30
|
+
constructor(status, code, message, opts = {}) {
|
|
31
|
+
super(code, message, opts)
|
|
32
|
+
this.status = status
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 400 — 클라이언트 검증 실패. AJV 자동 매핑 대상 (ADR-090). */
|
|
37
|
+
export class MegaValidationError extends MegaHttpError {
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} [code]
|
|
40
|
+
* @param {string} [message]
|
|
41
|
+
* @param {{ details?: object | unknown[], cause?: unknown }} [opts]
|
|
42
|
+
*/
|
|
43
|
+
constructor(code = 'validation.failed', message = 'Validation failed', opts = {}) {
|
|
44
|
+
super(400, code, message, opts)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 401 — 인증 누락 / 무효. */
|
|
49
|
+
export class MegaAuthError extends MegaHttpError {
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} [code]
|
|
52
|
+
* @param {string} [message]
|
|
53
|
+
* @param {{ details?: object | unknown[], cause?: unknown }} [opts]
|
|
54
|
+
*/
|
|
55
|
+
constructor(code = 'auth.required', message = 'Authentication required', opts = {}) {
|
|
56
|
+
super(401, code, message, opts)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 403 — 인증은 했으나 권한 없음. */
|
|
61
|
+
export class MegaForbiddenError extends MegaHttpError {
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} [code]
|
|
64
|
+
* @param {string} [message]
|
|
65
|
+
* @param {{ details?: object | unknown[], cause?: unknown }} [opts]
|
|
66
|
+
*/
|
|
67
|
+
constructor(code = 'auth.forbidden', message = 'Forbidden', opts = {}) {
|
|
68
|
+
super(403, code, message, opts)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 404 — 리소스 없음. */
|
|
73
|
+
export class MegaNotFoundError extends MegaHttpError {
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} [code]
|
|
76
|
+
* @param {string} [message]
|
|
77
|
+
* @param {{ details?: object | unknown[], cause?: unknown }} [opts]
|
|
78
|
+
*/
|
|
79
|
+
constructor(code = 'resource.not_found', message = 'Not found', opts = {}) {
|
|
80
|
+
super(404, code, message, opts)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 409 — 충돌 (예: email 중복). */
|
|
85
|
+
export class MegaConflictError extends MegaHttpError {
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} [code]
|
|
88
|
+
* @param {string} [message]
|
|
89
|
+
* @param {{ details?: object | unknown[], cause?: unknown }} [opts]
|
|
90
|
+
*/
|
|
91
|
+
constructor(code = 'resource.conflict', message = 'Conflict', opts = {}) {
|
|
92
|
+
super(409, code, message, opts)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 413 — 페이로드 과대 (업로드 크기·개수 초과). @fastify/multipart 네이티브 413 과 정합. */
|
|
97
|
+
export class MegaPayloadTooLargeError extends MegaHttpError {
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} [code]
|
|
100
|
+
* @param {string} [message]
|
|
101
|
+
* @param {{ details?: object | unknown[], cause?: unknown }} [opts]
|
|
102
|
+
*/
|
|
103
|
+
constructor(code = 'upload.too_large', message = 'Payload too large', opts = {}) {
|
|
104
|
+
super(413, code, message, opts)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 415 — 지원하지 않는 미디어 타입 (업로드 MIME 화이트리스트 위반). */
|
|
109
|
+
export class MegaUnsupportedMediaTypeError extends MegaHttpError {
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} [code]
|
|
112
|
+
* @param {string} [message]
|
|
113
|
+
* @param {{ details?: object | unknown[], cause?: unknown }} [opts]
|
|
114
|
+
*/
|
|
115
|
+
constructor(code = 'upload.unsupported_media_type', message = 'Unsupported media type', opts = {}) {
|
|
116
|
+
super(415, code, message, opts)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 500 — 내부 오류. 기본 code `server.internal` (H-6). */
|
|
121
|
+
export class MegaInternalError extends MegaHttpError {
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} [code]
|
|
124
|
+
* @param {string} [message]
|
|
125
|
+
* @param {{ details?: object | unknown[], cause?: unknown }} [opts]
|
|
126
|
+
*/
|
|
127
|
+
constructor(code = 'server.internal', message = 'Internal server error', opts = {}) {
|
|
128
|
+
super(500, code, message, opts)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* errors 배럴 — 모든 에러 클래스 re-export.
|
|
4
|
+
*
|
|
5
|
+
* @module errors
|
|
6
|
+
*/
|
|
7
|
+
export { MegaError } from './mega-error.js'
|
|
8
|
+
export { MegaConfigError } from './config-error.js'
|
|
9
|
+
export {
|
|
10
|
+
MegaHttpError,
|
|
11
|
+
MegaValidationError,
|
|
12
|
+
MegaAuthError,
|
|
13
|
+
MegaForbiddenError,
|
|
14
|
+
MegaNotFoundError,
|
|
15
|
+
MegaConflictError,
|
|
16
|
+
MegaPayloadTooLargeError,
|
|
17
|
+
MegaUnsupportedMediaTypeError,
|
|
18
|
+
MegaInternalError,
|
|
19
|
+
} from './http-errors.js'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MEGA-FRAMEWORK 모든 에러의 베이스.
|
|
4
|
+
* - code: 'domain.error' 컨벤션 (ADR-016)
|
|
5
|
+
* - details: object | array (ADR-075)
|
|
6
|
+
* - cause: Error 전파
|
|
7
|
+
*
|
|
8
|
+
* @module errors/mega-error
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 프레임워크 공통 에러 베이스 클래스.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* throw new MegaError('user.not_found', 'User 42 not found', {
|
|
16
|
+
* details: { userId: 42 },
|
|
17
|
+
* })
|
|
18
|
+
*/
|
|
19
|
+
export class MegaError extends Error {
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} code - 'domain.error' 형식 에러 코드 (ADR-016).
|
|
22
|
+
* @param {string} message - 사람이 읽는 에러 메시지.
|
|
23
|
+
* @param {{ details?: object | unknown[], cause?: unknown }} [options]
|
|
24
|
+
* - details: 구조화된 부가 정보 (object | array — ADR-075).
|
|
25
|
+
* - cause: 원인 에러 (전파용).
|
|
26
|
+
*/
|
|
27
|
+
constructor(code, message, { details, cause } = {}) {
|
|
28
|
+
super(message)
|
|
29
|
+
this.name = this.constructor.name
|
|
30
|
+
this.code = code
|
|
31
|
+
if (details !== undefined) this.details = details
|
|
32
|
+
if (cause !== undefined) this.cause = cause
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import noDirectModelImport from './no-direct-model-import.js'
|
|
3
|
+
|
|
4
|
+
/** @type {import('eslint').ESLint.Plugin} */
|
|
5
|
+
const megaPlugin = {
|
|
6
|
+
meta: {
|
|
7
|
+
name: 'eslint-plugin-mega',
|
|
8
|
+
version: '0.0.1',
|
|
9
|
+
},
|
|
10
|
+
rules: {
|
|
11
|
+
'no-direct-model-import': noDirectModelImport,
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default megaPlugin
|