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,552 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaHubLink — bridge(=mega server) 측 hub 연결 클라이언트 (ADR-033/059/097/098).
4
+ *
5
+ * bridge 가 `mega ws-hub`(Hub) 에 **plaintext + Bearer** WebSocket 으로 붙어 12-타입 프로토콜
6
+ * (lib/hub-protocol.js)을 주고받는다. ASP 는 클라↔bridge 구간에서만 종단하므로(ADR-059) 이
7
+ * 링크엔 암호화가 없다 — 인증은 Bearer 토큰(핸드셰이크 헤더 + REGISTER payload).
8
+ *
9
+ * # 라이프사이클
10
+ * connect() → ws open → `hub.register` 송신 → hub 가 `hub.register_ok` 응답 → resolve.
11
+ * register 이후 heartbeat 루프 시작(`heartbeatMs` 주기, application-level, ADR-033). 수신
12
+ * 메시지는 type 별 핸들러로 디스패치({@link MegaHubLink#on}).
13
+ *
14
+ * # 재연결 (ADR-098)
15
+ * `retry` 옵션을 주면 (1) 최초 connect() 실패 시 지수 백오프 재시도, (2) **등록 후 연결이
16
+ * 끊기면**(hub 재시작·drain 4503·네트워크 단절) 자동으로 재연결을 시도한다({@link MegaRetry}).
17
+ * 재연결 성공 시 {@link MegaHubLink.EVENTS}.RECONNECTED 이벤트를 emit 하므로, 상위(MegaApp)가
18
+ * presence(JOIN)를 재동기화할 수 있다 — hub 는 절단 시점 presence 를 잃기 때문(at-most-once).
19
+ * `retry` 미지정 시 본래 동작 — 끊기면 통지만 하고 재연결하지 않는다.
20
+ *
21
+ * # fail-closed (ADR-084)
22
+ * register 전 hub.error / close → connect() reject. 수신 프레임이 hub 프로토콜 위반이면
23
+ * silent 무시 금지 — warn 로그 후 드롭(연결은 유지, 비치명적).
24
+ *
25
+ * @module core/hub-link
26
+ */
27
+ import { WebSocket } from 'ws'
28
+ import {
29
+ HUB_MESSAGE_TYPES,
30
+ CLOSE_CODE_DRAIN,
31
+ createHubMessage,
32
+ parseHubMessage,
33
+ } from '../lib/hub-protocol.js'
34
+ import { withRetry } from '../lib/mega-retry.js'
35
+ import { buildPerMessageDeflate } from './ws-compression.js'
36
+
37
+ const T = HUB_MESSAGE_TYPES
38
+
39
+ /**
40
+ * 재시도해도 결과가 같은 치명적 등록 실패(인증 거부 등) — `fatal` 마킹으로 백오프를 즉시 멈춘다.
41
+ * 일시적 실패(네트워크·타임아웃)는 일반 Error 라 재시도된다.
42
+ */
43
+ class HubRegistrationError extends Error {
44
+ /** @param {string} message */
45
+ constructor(message) {
46
+ super(message)
47
+ this.name = 'HubRegistrationError'
48
+ /** @type {boolean} shouldRetry 가 false 를 반환하게 하는 마커. */
49
+ this.fatal = true
50
+ }
51
+ }
52
+
53
+ /** withRetry 공통 shouldRetry — fatal(인증 거부 등) 이면 재시도 중단. @param {{error: any}} ctx */
54
+ const retryUnlessFatal = (ctx) => !ctx?.error?.fatal
55
+
56
+ export class MegaHubLink {
57
+ /**
58
+ * 링크 라이프사이클 이벤트 (wire 타입과 충돌하지 않도록 `link.*` reserved). {@link MegaHubLink#on}
59
+ * 으로 구독한다. **핸들러 인자는 이벤트마다 다르다**(L-2):
60
+ * - `RECONNECTED` → `{ hubId: string }` — 재연결 성공(새 등록 완료). hubId 는 새 hub 의 식별자.
61
+ * - `DISCONNECTED` → `{ code: number, reason: string, isDrain: boolean }` — 등록 후 절단.
62
+ * `isDrain` 은 close code 가 4503(DRAIN)인지(다른 hub 로 회전 신호, ADR-098).
63
+ * - `RECONNECT_FAILED`→ `{ error: Error }` — 재연결 재시도 소진(더 이상 자동 재시도 없음).
64
+ * @type {Readonly<{ RECONNECTED: string, DISCONNECTED: string, RECONNECT_FAILED: string }>}
65
+ */
66
+ static EVENTS = Object.freeze({
67
+ RECONNECTED: 'link.reconnected',
68
+ DISCONNECTED: 'link.disconnected',
69
+ RECONNECT_FAILED: 'link.reconnect_failed',
70
+ })
71
+
72
+ /**
73
+ * @param {Object} [opts]
74
+ * @param {string} [opts.url] - hub URL (예: 'ws://hub1.internal:19991/_hub'). 누락 시 throw.
75
+ * @param {string} [opts.token] - Bearer 토큰 (wsHub.acceptedTokens 중 하나). 누락 시 throw.
76
+ * @param {string} [opts.bridgeId] - 운영 식별자 (예: 'main-1'). 누락 시 throw.
77
+ * @param {string} [opts.instanceId] - REGISTER instanceId. 기본 bridgeId.
78
+ * @param {string[]} [opts.capabilities] - 선언 capability 목록. 기본 [].
79
+ * @param {import('../lib/mega-retry.js').MegaRetryOptions} [opts.retry] - 지정 시 재연결 활성(ADR-098).
80
+ * `{ retries, minTimeout, maxTimeout, factor, jitter }`. 미지정 → 재연결 끔(기본 동작).
81
+ * @param {number} [opts.connectTimeoutMs=10000] - register_ok 미수신 시 attempt reject 까지(M4).
82
+ * @param {import('./ws-compression.js').WsCompressionConfig} [opts.compression] - Bridge↔Hub link
83
+ * per-message deflate 압축(ADR-078). Global `wsHub.compression` 블록과 동일
84
+ * 스키마 — hub 서버와 양쪽이 협상해야 활성(RFC 7692). 디폴트 OFF. 잘못된 값은 즉시 throw.
85
+ * @param {{ warn?: Function, debug?: Function, info?: Function, error?: Function }} [opts.logger]
86
+ * @param {typeof import('ws').WebSocket} [opts.WebSocketCtor] - 테스트 주입용 ws 생성자.
87
+ */
88
+ constructor({ url, token, bridgeId, instanceId, capabilities, retry, connectTimeoutMs, compression, logger, WebSocketCtor } = {}) {
89
+ if (typeof url !== 'string' || url.length === 0) {
90
+ throw new Error('MegaHubLink: url is required (MegaBridgeHubConfig.url).')
91
+ }
92
+ if (typeof token !== 'string' || token.length === 0) {
93
+ throw new Error('MegaHubLink: token is required (Bearer, ADR-059).')
94
+ }
95
+ if (typeof bridgeId !== 'string' || bridgeId.length === 0) {
96
+ throw new Error('MegaHubLink: bridgeId is required.')
97
+ }
98
+ this._url = url
99
+ this._token = token
100
+ this._bridgeId = bridgeId
101
+ this._instanceId = typeof instanceId === 'string' && instanceId.length > 0 ? instanceId : bridgeId
102
+ this._capabilities = Array.isArray(capabilities) ? [...capabilities] : []
103
+ this._log = logger ?? null
104
+ this._WS = WebSocketCtor ?? WebSocket
105
+ /** 재연결 옵션 (없으면 재연결 비활성). @type {import('../lib/mega-retry.js').MegaRetryOptions | null} */
106
+ this._retry = retry && typeof retry === 'object' ? retry : null
107
+ /** 한 attempt 의 register_ok 대기 한도(M4). reconnect 에서도 동일 적용. @type {number} */
108
+ this._connectTimeoutMs = Number.isFinite(connectTimeoutMs) ? Number(connectTimeoutMs) : 10_000
109
+ // Bridge↔Hub link 압축(ADR-078). enabled=false → false. 잘못된 값은 생성자에서 즉시 throw.
110
+ /** @type {false | Object} ws 클라이언트 perMessageDeflate 로 전달(_connectOnce). */
111
+ this._perMessageDeflate = buildPerMessageDeflate(compression, 'wsHub.compression')
112
+
113
+ /** @type {import('ws').WebSocket | null} */
114
+ this._ws = null
115
+ /** @type {boolean} register_ok 수신 여부. */
116
+ this._isRegistered = false
117
+ /** @type {string | null} hub 가 부여한 hubId. */
118
+ this._hubId = null
119
+ /** @type {number} register_ok 의 heartbeatMs(0 이면 heartbeat 끔). */
120
+ this._heartbeatMs = 0
121
+ /** @type {ReturnType<typeof setInterval> | null} */
122
+ this._hbTimer = null
123
+ /** type(wire)/lifecycle event → 핸들러 집합. @type {Map<string, Set<Function>>} */
124
+ this._handlers = new Map()
125
+ /** close() 로 의도적으로 닫는 중인지 — true 면 자동 재연결하지 않는다. @type {boolean} */
126
+ this._isClosing = false
127
+ /** 재연결 진행 중 가드(중복 reconnect 방지). @type {boolean} */
128
+ this._isReconnecting = false
129
+ /** close() 가 진행 중인 재시도(백오프 대기)를 취소하기 위한 컨트롤러. @type {AbortController | null} */
130
+ this._abort = null
131
+ }
132
+
133
+ /** hub 가 부여한 hubId (register 전엔 null). */
134
+ get hubId() {
135
+ return this._hubId
136
+ }
137
+
138
+ /** register_ok 를 받아 사용 가능한 상태인지. */
139
+ get isRegistered() {
140
+ return this._isRegistered
141
+ }
142
+
143
+ /** 소켓이 OPEN(=1) 인지. */
144
+ get isOpen() {
145
+ return this._ws?.readyState === 1
146
+ }
147
+
148
+ /** 재연결이 활성(retry 옵션 지정)인지. */
149
+ get canReconnect() {
150
+ return this._retry !== null
151
+ }
152
+
153
+ /**
154
+ * 수신 메시지/라이프사이클 핸들러 등록. 같은 type 에 여러 개 등록 가능(모두 호출).
155
+ * @param {string} wireType - {@link HUB_MESSAGE_TYPES} 의 값(`hub.broadcast` 등) 또는
156
+ * {@link MegaHubLink.EVENTS} 의 라이프사이클 이벤트(`link.*`).
157
+ * @param {(msg: Object) => void} handler
158
+ * @returns {this}
159
+ */
160
+ on(wireType, handler) {
161
+ let set = this._handlers.get(wireType)
162
+ if (!set) {
163
+ set = new Set()
164
+ this._handlers.set(wireType, set)
165
+ }
166
+ set.add(handler)
167
+ return this
168
+ }
169
+
170
+ /**
171
+ * hub 연결 + REGISTER 핸드셰이크. register_ok 수신 시 resolve.
172
+ *
173
+ * `retry` 옵션이 있으면 최초 연결도 지수 백오프로 재시도한다(ADR-098). 없으면 단발 시도.
174
+ *
175
+ * @param {Object} [opts]
176
+ * @param {number} [opts.connectTimeoutMs] - register_ok 대기 한도 override(M4). **이 값은 인스턴스에
177
+ * 저장되어(`this._connectTimeoutMs`) 이후 자동 재연결 attempt 에도 동일하게 적용된다** — 이 한 번의
178
+ * connect() 에만 적용되는 것이 아니다(L-1). 재연결마다 같은 타임아웃을 쓰는 동작은 의도된 것이다.
179
+ * @returns {Promise<{ hubId: string, heartbeatMs: number }>}
180
+ * @throws register 전 에러/종료/타임아웃(재시도 소진 포함) 시 reject.
181
+ */
182
+ async connect({ connectTimeoutMs } = {}) {
183
+ this._isClosing = false
184
+ const timeout = Number.isFinite(connectTimeoutMs) ? Number(connectTimeoutMs) : this._connectTimeoutMs
185
+ this._connectTimeoutMs = timeout
186
+ if (!this._retry) {
187
+ return this._connectOnce({ connectTimeoutMs: timeout })
188
+ }
189
+ // retry 활성 — 최초 연결도 백오프 재시도. AbortError(인증 실패 등) 는 재시도하지 않는다.
190
+ this._abort = new AbortController()
191
+ return withRetry(() => this._connectOnce({ connectTimeoutMs: timeout }), {
192
+ ...this._retry,
193
+ signal: this._abort.signal,
194
+ shouldRetry: retryUnlessFatal,
195
+ onFailedAttempt: (ctx) => {
196
+ this._log?.warn?.(
197
+ { bridgeId: this._bridgeId, attempt: ctx.attemptNumber, retriesLeft: ctx.retriesLeft },
198
+ 'hub-link connect attempt failed — backing off',
199
+ )
200
+ },
201
+ })
202
+ }
203
+
204
+ /**
205
+ * 단일 연결 시도 — ws open → REGISTER → register_ok. 한 attempt 의 Promise 를 돌려준다.
206
+ * 재시도/재연결은 호출부({@link MegaHubLink#connect}/{@link MegaHubLink#_reconnect})가 감싼다.
207
+ *
208
+ * @param {{ connectTimeoutMs: number }} args
209
+ * @returns {Promise<{ hubId: string, heartbeatMs: number }>}
210
+ * @private
211
+ */
212
+ _connectOnce({ connectTimeoutMs }) {
213
+ return new Promise((resolve, reject) => {
214
+ // Bearer 는 핸드셰이크 Authorization 헤더 + REGISTER payload 양쪽에 싣는다(이중 안전).
215
+ // perMessageDeflate 는 ws 클라 기본이 true 이므로, 압축 OFF 면 명시적으로 false 를 넘겨
216
+ // 협상 자체를 끈다(ADR-078). enabled 면 build 한 옵션 객체를 그대로 전달.
217
+ const ws = new this._WS(this._url, /** @type {any} */ ({
218
+ headers: { authorization: `Bearer ${this._token}` },
219
+ perMessageDeflate: this._perMessageDeflate,
220
+ }))
221
+ this._ws = ws
222
+ let settled = false
223
+ /** 이 attempt 가 register_ok 로 resolve 됐는지 — 이후 close 가 "등록 후 절단"인지 판별. */
224
+ let didRegister = false
225
+ /** @type {ReturnType<typeof setTimeout> | null} */
226
+ let timer = null
227
+
228
+ /** register 결과 1회 확정 — settle 시 타임아웃 타이머도 정리(M4). @param {(v:any)=>void} done @param {any} val */
229
+ const finish = (done, val) => {
230
+ if (settled) return
231
+ settled = true
232
+ if (timer) clearTimeout(timer)
233
+ done(val)
234
+ }
235
+
236
+ // 등록 타임아웃 — hub 가 무응답이면 소켓 닫고 reject(M4). 프로세스 종료 막지 않게 unref.
237
+ if (connectTimeoutMs > 0) {
238
+ timer = setTimeout(() => {
239
+ this._log?.warn?.({ bridgeId: this._bridgeId, connectTimeoutMs }, 'hub-link register timeout')
240
+ // reject 먼저 확정(settled=true) — 이어지는 ws.close 의 동기 'close' 이벤트가 다른 사유로
241
+ // settle 하지 않게 한다. close 는 소켓 정리용(이미 settled 라 close 핸들러는 no-op).
242
+ finish(reject, new Error('hub.register_timeout'))
243
+ if (ws.readyState <= 1) ws.close(1000, 'register timeout')
244
+ }, connectTimeoutMs)
245
+ timer.unref?.()
246
+ }
247
+
248
+ ws.on('open', () => {
249
+ this._log?.debug?.({ bridgeId: this._bridgeId, url: this._url }, 'hub-link open → REGISTER')
250
+ try {
251
+ this._sendEnvelope(
252
+ createHubMessage({
253
+ type: T.REGISTER,
254
+ payload: {
255
+ instanceId: this._instanceId,
256
+ token: this._token,
257
+ capabilities: this._capabilities,
258
+ },
259
+ }),
260
+ )
261
+ } catch (err) {
262
+ // REGISTER 송신 실패 = 연결 불가. fail-closed reject.
263
+ finish(reject, err instanceof Error ? err : new Error(String(err)))
264
+ }
265
+ })
266
+
267
+ ws.on('message', (data) => {
268
+ const frame = data.toString('utf8')
269
+ let msg
270
+ try {
271
+ msg = parseHubMessage(frame)
272
+ } catch (err) {
273
+ // hub 프로토콜 위반 프레임 — silent 금지. 드롭하되 연결은 유지(비치명적).
274
+ this._log?.warn?.({ err, bridgeId: this._bridgeId }, 'hub-link invalid frame dropped')
275
+ return
276
+ }
277
+ // register 핸드셰이크 단계: register_ok → resolve, error → reject.
278
+ if (!this._isRegistered) {
279
+ if (msg.type === T.REGISTER_OK) {
280
+ this._isRegistered = true
281
+ didRegister = true
282
+ this._hubId = msg.payload.hubId
283
+ this._heartbeatMs = msg.payload.heartbeatMs
284
+ this._startHeartbeat()
285
+ this._log?.info?.({ bridgeId: this._bridgeId, hubId: this._hubId }, 'hub-link registered')
286
+ finish(resolve, { hubId: this._hubId, heartbeatMs: this._heartbeatMs })
287
+ return
288
+ }
289
+ if (msg.type === T.ERROR) {
290
+ const code = msg.error?.code ?? 'hub.error'
291
+ this._log?.warn?.({ bridgeId: this._bridgeId, code }, 'hub-link registration rejected')
292
+ // 인증 실패 등 등록 거부는 재시도해도 같은 결과 — fatal 마킹으로 백오프 중단(ADR-098).
293
+ finish(reject, new HubRegistrationError(`hub registration failed: ${code}`))
294
+ ws.close()
295
+ return
296
+ }
297
+ // register 전 다른 타입은 규약 위반 — 드롭(연결 유지). resolve/reject 는 register_ok/error 만.
298
+ this._log?.warn?.({ bridgeId: this._bridgeId, type: msg.type }, 'hub-link pre-register message ignored')
299
+ return
300
+ }
301
+ this._dispatch(msg)
302
+ })
303
+
304
+ ws.on('error', (err) => {
305
+ if (!settled) {
306
+ finish(reject, err instanceof Error ? err : new Error(String(err)))
307
+ } else {
308
+ // 등록 후 소켓 에러 — 비치명적, close 가 뒤따른다. 로그만.
309
+ this._log?.warn?.({ err, bridgeId: this._bridgeId }, 'hub-link socket error')
310
+ }
311
+ })
312
+
313
+ ws.on('close', (code, reasonBuf) => {
314
+ this._stopHeartbeat()
315
+ const reason = Buffer.isBuffer(reasonBuf) ? reasonBuf.toString('utf8') : String(reasonBuf ?? '')
316
+ this._log?.debug?.({ bridgeId: this._bridgeId, code, reason }, 'hub-link closed')
317
+ if (!settled) {
318
+ // register 전 절단 — 이 attempt 실패. 재시도는 connect()/_reconnect 의 withRetry 가 처리.
319
+ finish(reject, new Error(`hub link closed before register (code ${code})`))
320
+ return
321
+ }
322
+ // 이미 settled. register_ok 로 resolve 된 뒤의 close = "등록 후 연결 절단".
323
+ if (didRegister) {
324
+ this._isRegistered = false
325
+ this._handleEstablishedClose(code, reason)
326
+ }
327
+ })
328
+ })
329
+ }
330
+
331
+ /**
332
+ * 등록 후 연결이 끊겼을 때 처리 — DISCONNECTED emit + (retry 활성·의도적 close 아님) 자동 재연결.
333
+ * @param {number} code - WS close code (4503=drain 등).
334
+ * @param {string} reason
335
+ * @private
336
+ */
337
+ _handleEstablishedClose(code, reason) {
338
+ const isDrain = code === CLOSE_CODE_DRAIN
339
+ this._log?.debug?.({ bridgeId: this._bridgeId, code, reason, isDrain }, 'hub-link disconnected')
340
+ this._emit(MegaHubLink.EVENTS.DISCONNECTED, { code, reason, isDrain })
341
+ if (this._retry && !this._isClosing) {
342
+ // drain(4503) 은 "다른 hub 로 재연결" 신호 — 현재는 같은 url 재시도(fallbackUrls 회전은 OQ).
343
+ void this._reconnect()
344
+ }
345
+ }
346
+
347
+ /**
348
+ * 자동 재연결 — 백오프로 _connectOnce 반복. 성공 시 RECONNECTED, 소진 시 RECONNECT_FAILED emit.
349
+ * @returns {Promise<void>}
350
+ * @private
351
+ */
352
+ async _reconnect() {
353
+ if (this._isReconnecting) return
354
+ this._isReconnecting = true
355
+ this._abort = new AbortController()
356
+ try {
357
+ await withRetry(() => this._connectOnce({ connectTimeoutMs: this._connectTimeoutMs }), {
358
+ .../** @type {any} */ (this._retry),
359
+ signal: this._abort.signal,
360
+ shouldRetry: retryUnlessFatal,
361
+ onFailedAttempt: (ctx) => {
362
+ this._log?.warn?.(
363
+ { bridgeId: this._bridgeId, attempt: ctx.attemptNumber, retriesLeft: ctx.retriesLeft },
364
+ 'hub-link reconnect attempt failed — backing off',
365
+ )
366
+ },
367
+ })
368
+ this._log?.info?.({ bridgeId: this._bridgeId, hubId: this._hubId }, 'hub-link reconnected')
369
+ this._emit(MegaHubLink.EVENTS.RECONNECTED, { hubId: this._hubId })
370
+ } catch (err) {
371
+ // 의도적 close 로 인한 abort 는 실패가 아님(조용히 종료). 그 외엔 재시도 소진 — 통지.
372
+ if (this._isClosing) {
373
+ this._log?.debug?.({ bridgeId: this._bridgeId }, 'hub-link reconnect aborted (closing)')
374
+ } else {
375
+ this._log?.error?.({ err, bridgeId: this._bridgeId }, 'hub-link reconnect exhausted')
376
+ this._emit(MegaHubLink.EVENTS.RECONNECT_FAILED, { error: err })
377
+ }
378
+ } finally {
379
+ this._isReconnecting = false
380
+ }
381
+ }
382
+
383
+ /**
384
+ * 사용자 접속 알림 (JOIN). presence 등록.
385
+ * @param {{ userId: string, sessionId: string, channels: string[], metadata?: Object }} entry
386
+ * @returns {void}
387
+ */
388
+ join(entry) {
389
+ this._send(T.JOIN, entry)
390
+ }
391
+
392
+ /**
393
+ * 단일 세션 종료 (LEAVE).
394
+ * @param {string} sessionId
395
+ * @returns {void}
396
+ */
397
+ leave(sessionId) {
398
+ this._send(T.LEAVE, { sessionId })
399
+ }
400
+
401
+ /**
402
+ * 다수 세션 일괄 종료 (BULK_LEAVE) — 여러 세션의 presence LEAVE 를 한 프레임으로 보낸다.
403
+ *
404
+ * 공개 API 다(프레임워크 내부는 현재 hub 의 bridge-gone 자동 감지에 의존해 직접 호출하지 않음).
405
+ * 다음 같은 **대량 정리** 상황에서 애플리케이션이 직접 호출한다:
406
+ * - 대량 logout(한 사용자의 여러 세션을 한 번에 종료),
407
+ * - 클라이언트 풀/탭 정리(연결 다수를 묶어 종료),
408
+ * - bridge 가 자체 drain 으로 보유 세션을 능동적으로 비울 때(소켓 close 를 기다리지 않고 선통지).
409
+ * 세션 1건이면 {@link MegaHubLink#leave} 를 쓰는 편이 단순하다.
410
+ *
411
+ * @param {string[]} sessionIds
412
+ * @returns {void}
413
+ */
414
+ bulkLeave(sessionIds) {
415
+ this._send(T.BULK_LEAVE, { sessionIds })
416
+ }
417
+
418
+ /**
419
+ * 채널 전체 푸시 (BROADCAST). hub 가 가입 bridge 들에 fan-out.
420
+ * @param {{ ns: string, channel: string, message: Object, exceptSessionIds?: string[] }} payload
421
+ * @returns {void}
422
+ */
423
+ broadcast(payload) {
424
+ this._send(T.BROADCAST, payload)
425
+ }
426
+
427
+ /**
428
+ * 특정 사용자에게 직접 (DIRECT). hub 가 userId→sessionIds[] fan-out (ADR-035).
429
+ * @param {{ userId: string, message: Object }} payload
430
+ * @returns {void}
431
+ */
432
+ direct(payload) {
433
+ this._send(T.DIRECT, payload)
434
+ }
435
+
436
+ /**
437
+ * 세션 메타데이터 갱신 (METADATA) — 명시 필드만(ADR-059).
438
+ * @param {{ sessionId: string, metadata: Object }} payload
439
+ * @returns {void}
440
+ */
441
+ updateMetadata(payload) {
442
+ this._send(T.METADATA, payload)
443
+ }
444
+
445
+ /**
446
+ * 바이너리 메타 프레임 (BINARY). raw bytes 후속 프레임 전송은 본 Step 미포함(envelope 한정).
447
+ * @param {{ ref: string, mimeType: string, bytes: number }} payload
448
+ * @returns {void}
449
+ */
450
+ binary(payload) {
451
+ this._send(T.BINARY, payload)
452
+ }
453
+
454
+ /** 연결 종료 — 의도적 close 표시 + 진행 중 재연결 취소 + heartbeat 정리 후 소켓 close. */
455
+ close() {
456
+ this._isClosing = true
457
+ // 진행 중인 재시도(백오프 대기/최초 connect)를 취소한다(ADR-098). signal 은 다음 시도를 막는다.
458
+ this._abort?.abort(new Error('hub-link closing'))
459
+ this._stopHeartbeat()
460
+ if (this._ws && this._ws.readyState <= 1) this._ws.close(1000, 'bridge closing')
461
+ }
462
+
463
+ /**
464
+ * 타입별 송신 — register_ok 이후에만 허용. register 전 호출은 fail-closed throw.
465
+ * @param {string} type - wire type(`hub.*`).
466
+ * @param {Object} payload
467
+ * @returns {void}
468
+ * @private
469
+ */
470
+ _send(type, payload) {
471
+ if (!this._isRegistered) {
472
+ throw new Error(`MegaHubLink: cannot send '${type}' before register_ok.`)
473
+ }
474
+ this._sendEnvelope(createHubMessage({ type, payload }))
475
+ }
476
+
477
+ /**
478
+ * envelope 직렬화 후 소켓 송신.
479
+ * @param {Object} envelope
480
+ * @returns {void}
481
+ * @private
482
+ */
483
+ _sendEnvelope(envelope) {
484
+ if (this._ws?.readyState !== 1) {
485
+ throw new Error('MegaHubLink: socket is not open.')
486
+ }
487
+ this._ws.send(JSON.stringify(envelope))
488
+ }
489
+
490
+ /**
491
+ * 수신 메시지를 등록된 핸들러에 디스패치. 핸들러 throw 는 격리(다른 핸들러 보호).
492
+ * @param {import('../lib/hub-protocol.js').HubEnvelope} msg
493
+ * @returns {void}
494
+ * @private
495
+ */
496
+ _dispatch(msg) {
497
+ const set = this._handlers.get(msg.type)
498
+ if (!set || set.size === 0) {
499
+ this._log?.debug?.({ type: msg.type }, 'hub-link no handler for type')
500
+ return
501
+ }
502
+ for (const fn of set) {
503
+ try {
504
+ fn(msg)
505
+ } catch (err) {
506
+ // 한 핸들러 throw 가 나머지 디스패치를 막지 않도록 격리 + 로그.
507
+ this._log?.warn?.({ err, type: msg.type }, 'hub-link handler threw')
508
+ }
509
+ }
510
+ }
511
+
512
+ /**
513
+ * 라이프사이클 이벤트 emit (RECONNECTED 등). 디스패치와 같은 핸들러 맵을 쓰되 인자는 이벤트 데이터.
514
+ * @param {string} event - {@link MegaHubLink.EVENTS} 값.
515
+ * @param {Object} data
516
+ * @returns {void}
517
+ * @private
518
+ */
519
+ _emit(event, data) {
520
+ const set = this._handlers.get(event)
521
+ if (!set) return
522
+ for (const fn of set) {
523
+ try {
524
+ fn(data)
525
+ } catch (err) {
526
+ this._log?.warn?.({ err, event }, 'hub-link lifecycle handler threw')
527
+ }
528
+ }
529
+ }
530
+
531
+ /** heartbeat 루프 시작 (heartbeatMs>0 일 때만). 프로세스 종료 막지 않게 unref. @private */
532
+ _startHeartbeat() {
533
+ if (this._heartbeatMs <= 0) return
534
+ this._hbTimer = setInterval(() => {
535
+ try {
536
+ this._sendEnvelope(createHubMessage({ type: T.HEARTBEAT, payload: { at: Date.now() } }))
537
+ } catch (err) {
538
+ // 송신 실패 = 소켓 닫히는 중. 비치명적 — close 핸들러가 정리(이유+로그).
539
+ this._log?.debug?.({ err, bridgeId: this._bridgeId }, 'hub-link heartbeat send failed')
540
+ }
541
+ }, this._heartbeatMs)
542
+ this._hbTimer.unref?.()
543
+ }
544
+
545
+ /** heartbeat 루프 정지. @private */
546
+ _stopHeartbeat() {
547
+ if (this._hbTimer) {
548
+ clearInterval(this._hbTimer)
549
+ this._hbTimer = null
550
+ }
551
+ }
552
+ }