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