mega-framework 0.1.6 → 0.1.8

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 (248) hide show
  1. package/README.md +9 -0
  2. package/bin/mega-ws-hub.js +2 -2
  3. package/package.json +33 -9
  4. package/sample/crud/.env +10 -1
  5. package/sample/crud/.env.example +10 -1
  6. package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
  7. package/sample/crud/.mega/journal/snapshot.json +261 -0
  8. package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
  9. package/sample/crud/apps/main/controllers/web-controller.js +7 -5
  10. package/sample/crud/apps/main/locales/server/en.json +12 -1
  11. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  12. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
  13. package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
  14. package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
  15. package/sample/crud/apps/main/models/log-partition-model.js +105 -0
  16. package/sample/crud/apps/main/models/note-model.js +79 -0
  17. package/sample/crud/apps/main/models/user-level-model.js +24 -0
  18. package/sample/crud/apps/main/models/user-model.js +146 -0
  19. package/sample/crud/apps/main/models/user-type-model.js +21 -0
  20. package/sample/crud/apps/main/models/wallet-model.js +24 -0
  21. package/sample/crud/apps/main/routes/users.js +55 -10
  22. package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
  23. package/sample/crud/apps/main/services/auth-service.js +39 -24
  24. package/sample/crud/apps/main/services/log-partition-service.js +101 -0
  25. package/sample/crud/apps/main/services/note-service.js +6 -6
  26. package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
  27. package/sample/crud/apps/main/services/user-service.js +62 -21
  28. package/sample/crud/apps/main/views/auth/login.ejs +6 -6
  29. package/sample/crud/apps/main/views/auth/register.ejs +46 -5
  30. package/sample/crud/apps/main/views/users/edit.ejs +42 -5
  31. package/sample/crud/apps/main/views/users/list.ejs +6 -2
  32. package/sample/crud/apps/main/views/users/new.ejs +56 -4
  33. package/sample/crud/docs/log_partition_design.mm.md +23 -0
  34. package/sample/crud/mega.config.js +10 -2
  35. package/sample/crud/package.json +3 -3
  36. package/sample/crud/scripts/start-ws-hub.sh +20 -6
  37. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  38. package/sample/simple/package.json +2 -2
  39. package/src/adapters/adapter-manager.js +2 -1
  40. package/src/adapters/adapter-options.js +44 -3
  41. package/src/adapters/file-adapter.js +9 -5
  42. package/src/adapters/file-session-adapter.js +4 -3
  43. package/src/adapters/maria-adapter.js +33 -7
  44. package/src/adapters/mega-cache-adapter.js +83 -6
  45. package/src/adapters/mega-db-adapter.js +10 -1
  46. package/src/adapters/mongo-adapter.js +40 -8
  47. package/src/adapters/postgres-adapter.js +33 -6
  48. package/src/adapters/redis-adapter.js +7 -3
  49. package/src/adapters/sqlite-adapter.js +26 -3
  50. package/src/cli/commands/console-cmd.js +3 -1
  51. package/src/cli/commands/new.js +13 -3
  52. package/src/cli/commands/scaffold.js +173 -33
  53. package/src/cli/generators/index.js +140 -3
  54. package/src/cli/index.js +437 -155
  55. package/src/cli/watch.js +188 -0
  56. package/src/core/ajv-mapper.js +30 -3
  57. package/src/core/boot.js +464 -245
  58. package/src/core/cluster-metrics.js +13 -4
  59. package/src/core/ctx-builder.js +65 -3
  60. package/src/core/envelope.js +119 -12
  61. package/src/core/hub-link.js +89 -18
  62. package/src/core/i18n.js +11 -1
  63. package/src/core/index.js +7 -3
  64. package/src/core/mega-app.js +253 -505
  65. package/src/core/mega-cluster.js +4 -1
  66. package/src/core/mega-server.js +40 -9
  67. package/src/core/migration/dialect-registry.js +107 -0
  68. package/src/core/migration/dialects/README.md +62 -0
  69. package/src/core/migration/dialects/maria.js +496 -0
  70. package/src/core/migration/dialects/mongo.js +824 -0
  71. package/src/core/migration/dialects/postgres.js +563 -0
  72. package/src/core/migration/dialects/sqlite.js +476 -0
  73. package/src/core/migration/differ.js +456 -0
  74. package/src/core/migration/generate.js +508 -0
  75. package/src/core/migration/journal.js +167 -0
  76. package/src/core/migration/model-scan.js +84 -0
  77. package/src/core/migration/mongo-migration-db.js +97 -0
  78. package/src/core/migration/schema-builder.js +400 -0
  79. package/src/core/migration/schema-validator.js +315 -0
  80. package/src/core/migration-lock.js +205 -0
  81. package/src/core/migration-runner.js +166 -38
  82. package/src/core/multipart.js +28 -5
  83. package/src/core/pipeline.js +131 -0
  84. package/src/core/router.js +70 -65
  85. package/src/core/scope-registry.js +1 -0
  86. package/src/core/security.js +70 -12
  87. package/src/core/session-store.js +14 -1
  88. package/src/core/workers-manager.js +12 -1
  89. package/src/core/ws-cluster.js +10 -3
  90. package/src/core/ws-message.js +48 -4
  91. package/src/core/ws-presence.js +636 -0
  92. package/src/core/ws-roster.js +50 -8
  93. package/src/core/ws-upgrade.js +223 -12
  94. package/src/index.js +1 -1
  95. package/src/lib/hub-protocol.js +29 -0
  96. package/src/lib/mega-circuit-breaker.js +5 -3
  97. package/src/lib/mega-health.js +35 -4
  98. package/src/lib/mega-job-queue.js +151 -34
  99. package/src/lib/mega-job.js +37 -1
  100. package/src/lib/mega-metrics.js +31 -13
  101. package/src/lib/mega-plugin.js +34 -3
  102. package/src/lib/mega-schedule.js +40 -22
  103. package/src/lib/mega-shutdown.js +114 -39
  104. package/src/lib/mega-tracing.js +66 -19
  105. package/src/lib/mega-worker.js +33 -6
  106. package/src/lib/otel-resource.js +36 -0
  107. package/src/{cli → lib}/ws-hub.js +139 -15
  108. package/src/models/crud-sql-builder.js +133 -0
  109. package/src/models/mega-model.js +82 -2
  110. package/src/models/model-crud.js +483 -0
  111. package/src/models/mongo-crud.js +285 -0
  112. package/templates/adr/code.tpl +23 -0
  113. package/templates/model/code-mongo.tpl +35 -0
  114. package/templates/model/code.tpl +15 -1
  115. package/templates/model/test-mongo.tpl +38 -0
  116. package/templates/model/test.tpl +4 -0
  117. package/types/adapters/adapter-manager.d.ts +95 -0
  118. package/types/adapters/adapter-options.d.ts +93 -0
  119. package/types/adapters/file-adapter.d.ts +105 -0
  120. package/types/adapters/file-session-adapter.d.ts +103 -0
  121. package/types/adapters/index.d.ts +20 -0
  122. package/types/adapters/maria-adapter.d.ts +117 -0
  123. package/types/adapters/mega-adapter.d.ts +215 -0
  124. package/types/adapters/mega-bus-adapter.d.ts +45 -0
  125. package/types/adapters/mega-cache-adapter.d.ts +73 -0
  126. package/types/adapters/mega-db-adapter.d.ts +50 -0
  127. package/types/adapters/mega-lock-adapter.d.ts +62 -0
  128. package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
  129. package/types/adapters/mega-session-adapter.d.ts +32 -0
  130. package/types/adapters/mongo-adapter.d.ts +150 -0
  131. package/types/adapters/nats-adapter.d.ts +108 -0
  132. package/types/adapters/postgres-adapter.d.ts +141 -0
  133. package/types/adapters/redis-adapter.d.ts +78 -0
  134. package/types/adapters/redis-session-adapter.d.ts +82 -0
  135. package/types/adapters/redlock-adapter.d.ts +149 -0
  136. package/types/adapters/registry.d.ts +46 -0
  137. package/types/adapters/sqlite-adapter.d.ts +112 -0
  138. package/types/auth/index.d.ts +24 -0
  139. package/types/cli/commands/console-cmd.d.ts +37 -0
  140. package/types/cli/commands/new.d.ts +16 -0
  141. package/types/cli/commands/routes.d.ts +36 -0
  142. package/types/cli/commands/scaffold.d.ts +78 -0
  143. package/types/cli/commands/test-cmd.d.ts +14 -0
  144. package/types/cli/generators/index.d.ts +122 -0
  145. package/types/cli/index.d.ts +234 -0
  146. package/types/cli/template-engine.d.ts +40 -0
  147. package/types/cli/watch.d.ts +59 -0
  148. package/types/core/ajv-mapper.d.ts +27 -0
  149. package/types/core/boot.d.ts +233 -0
  150. package/types/core/cluster-metrics.d.ts +52 -0
  151. package/types/core/config-loader.d.ts +13 -0
  152. package/types/core/config-validator.d.ts +30 -0
  153. package/types/core/ctx-builder.d.ts +103 -0
  154. package/types/core/envelope.d.ts +79 -0
  155. package/types/core/error-mapper.d.ts +17 -0
  156. package/types/core/formbody.d.ts +41 -0
  157. package/types/core/hub-link.d.ts +266 -0
  158. package/types/core/i18n.d.ts +178 -0
  159. package/types/core/index.d.ts +28 -0
  160. package/types/core/mega-app.d.ts +529 -0
  161. package/types/core/mega-cluster.d.ts +104 -0
  162. package/types/core/mega-server.d.ts +91 -0
  163. package/types/core/mega-service.d.ts +31 -0
  164. package/types/core/migration/dialect-registry.d.ts +22 -0
  165. package/types/core/migration/dialects/maria.d.ts +99 -0
  166. package/types/core/migration/dialects/mongo.d.ts +89 -0
  167. package/types/core/migration/dialects/postgres.d.ts +117 -0
  168. package/types/core/migration/dialects/sqlite.d.ts +111 -0
  169. package/types/core/migration/differ.d.ts +47 -0
  170. package/types/core/migration/generate.d.ts +56 -0
  171. package/types/core/migration/journal.d.ts +52 -0
  172. package/types/core/migration/model-scan.d.ts +19 -0
  173. package/types/core/migration/mongo-migration-db.d.ts +7 -0
  174. package/types/core/migration/schema-builder.d.ts +197 -0
  175. package/types/core/migration/schema-validator.d.ts +20 -0
  176. package/types/core/migration-lock.d.ts +33 -0
  177. package/types/core/migration-runner.d.ts +101 -0
  178. package/types/core/multipart.d.ts +86 -0
  179. package/types/core/openapi.d.ts +62 -0
  180. package/types/core/pipeline.d.ts +93 -0
  181. package/types/core/router.d.ts +159 -0
  182. package/types/core/routes-loader.d.ts +21 -0
  183. package/types/core/scope-registry.d.ts +14 -0
  184. package/types/core/security.d.ts +77 -0
  185. package/types/core/services-loader.d.ts +27 -0
  186. package/types/core/session-cleanup-schedule.d.ts +19 -0
  187. package/types/core/session-store.d.ts +25 -0
  188. package/types/core/session.d.ts +77 -0
  189. package/types/core/static-assets.d.ts +73 -0
  190. package/types/core/template.d.ts +106 -0
  191. package/types/core/workers-manager.d.ts +79 -0
  192. package/types/core/ws-cluster.d.ts +208 -0
  193. package/types/core/ws-compression.d.ts +112 -0
  194. package/types/core/ws-controller.d.ts +65 -0
  195. package/types/core/ws-message.d.ts +106 -0
  196. package/types/core/ws-presence.d.ts +273 -0
  197. package/types/core/ws-roster.d.ts +108 -0
  198. package/types/core/ws-upgrade.d.ts +260 -0
  199. package/types/errors/config-error.d.ts +10 -0
  200. package/types/errors/http-errors.d.ts +120 -0
  201. package/types/errors/index.d.ts +3 -0
  202. package/types/errors/mega-error.d.ts +32 -0
  203. package/types/index.d.ts +39 -0
  204. package/types/lib/asp/config.d.ts +49 -0
  205. package/types/lib/asp/crypto.d.ts +43 -0
  206. package/types/lib/asp/errors.d.ts +30 -0
  207. package/types/lib/asp/nonce-cache.d.ts +52 -0
  208. package/types/lib/asp/plugin.d.ts +30 -0
  209. package/types/lib/asp/ws-terminator.d.ts +45 -0
  210. package/types/lib/env-mapper.d.ts +14 -0
  211. package/types/lib/hub-protocol.d.ts +106 -0
  212. package/types/lib/index.d.ts +22 -0
  213. package/types/lib/logger/telegram-core.d.ts +104 -0
  214. package/types/lib/logger/telegram-transport.d.ts +45 -0
  215. package/types/lib/mega-brute-force.d.ts +66 -0
  216. package/types/lib/mega-circuit-breaker.d.ts +243 -0
  217. package/types/lib/mega-cron.d.ts +66 -0
  218. package/types/lib/mega-hash.d.ts +32 -0
  219. package/types/lib/mega-health.d.ts +48 -0
  220. package/types/lib/mega-job-queue.d.ts +188 -0
  221. package/types/lib/mega-job-worker.d.ts +130 -0
  222. package/types/lib/mega-job.d.ts +145 -0
  223. package/types/lib/mega-logger.d.ts +45 -0
  224. package/types/lib/mega-metrics.d.ts +285 -0
  225. package/types/lib/mega-plugin.d.ts +245 -0
  226. package/types/lib/mega-retry.d.ts +85 -0
  227. package/types/lib/mega-schedule.d.ts +260 -0
  228. package/types/lib/mega-shutdown.d.ts +135 -0
  229. package/types/lib/mega-tracing.d.ts +224 -0
  230. package/types/lib/mega-worker.d.ts +129 -0
  231. package/types/lib/otel-resource.d.ts +16 -0
  232. package/types/lib/worker-runner/process-entry.d.ts +1 -0
  233. package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
  234. package/types/lib/worker-runner/thread-entry.d.ts +1 -0
  235. package/types/lib/ws-hub.d.ts +259 -0
  236. package/types/models/crud-sql-builder.d.ts +48 -0
  237. package/types/models/index.d.ts +1 -0
  238. package/types/models/mega-model.d.ts +138 -0
  239. package/types/models/model-crud.d.ts +82 -0
  240. package/types/models/mongo-crud.d.ts +59 -0
  241. package/types/test/index.d.ts +84 -0
  242. package/.env +0 -127
  243. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
  244. package/sample/crud/apps/main/models/note.js +0 -71
  245. package/sample/crud/apps/main/models/user.js +0 -86
  246. package/sample/crud/package-lock.json +0 -5665
  247. package/sample/crud/yarn.lock +0 -2142
  248. package/sample/simple/package-lock.json +0 -1851
@@ -74,9 +74,25 @@ export class MegaWsRedisRoster {
74
74
  if (typeof channel !== 'string' || typeof sessionId !== 'string') return
75
75
  const key = this._key(channel)
76
76
  const value = JSON.stringify({ userId: member?.userId, ...(member?.metadata ? { metadata: member.metadata } : {}), expiresAt: Date.now() + this._ttlMs })
77
- await this._redis.hset(key, sessionId, value)
78
- // 채널 HASH 전체 TTL 모든 멤버가 stale 돼도 키가 영구히 남지 않게(멤버 TTL 의 2배 여유).
79
- await this._redis.pexpire(key, this._ttlMs * 2)
77
+ // HSET + 채널 HASH 키 TTL(멤버 TTL 2배 — 전원 stale 돼도 키가 영구히 안 남게)을 pipeline 1 RT 로.
78
+ // 직렬 await 2회는 joinSession(채널 수만큼 add)·heartbeat 경로에서 왕복 수가 비용을 지배했다.
79
+ const results = await this._redis.pipeline().hset(key, sessionId, value).pexpire(key, this._ttlMs * 2).exec()
80
+ this._throwFirstPipelineError(results)
81
+ }
82
+
83
+ /**
84
+ * ioredis pipeline.exec() 결과(`[err, res][]`)에서 첫 명령 오류를 throw 한다 — pipeline 은 명령별
85
+ * 오류를 reject 가 아니라 결과 배열에 싣기 때문에, 묵히면 호출부의 기존 실패 처리(catch+warn)가
86
+ * 더는 동작하지 않는다(직렬 await 시절의 throw 계약 유지).
87
+ * @param {Array<[Error | null, any]> | null} results
88
+ * @returns {void}
89
+ * @private
90
+ */
91
+ _throwFirstPipelineError(results) {
92
+ if (!Array.isArray(results)) return
93
+ for (const [err] of results) {
94
+ if (err) throw err
95
+ }
80
96
  }
81
97
 
82
98
  /**
@@ -139,13 +155,29 @@ export class MegaWsRedisRoster {
139
155
  }
140
156
 
141
157
  /**
142
- * 로컬 멤버 전체를 다시 add(=expiresAt 갱신). @returns {Promise<void>} @private
158
+ * 로컬 멤버 전체의 `expiresAt` 을 한 pipeline 으로 일괄 갱신. 직렬 add 루프(멤버당 2 RT) 멤버
159
+ * 1,000 에 주기당 수백 ms — 갱신 지연이 TTL 윈도를 침식해 살아있는 세션이 lazy 만료될 수 있었다.
160
+ * pipeline 이면 단일 왕복 수준(실측 ~200×). 채널 키 PEXPIRE 는 채널당 1회만 싣는다.
161
+ * @returns {Promise<void>} @private
143
162
  */
144
163
  async _refreshLocal() {
145
164
  const members = this._getLocalMembers()
165
+ if (members.length === 0) return
166
+ const expiresAt = Date.now() + this._ttlMs
167
+ const pipeline = this._redis.pipeline()
168
+ /** @type {Set<string>} PEXPIRE 를 이미 실은 채널(중복 제거). */
169
+ const touchedChannels = new Set()
146
170
  for (const { channel, sessionId, member } of members) {
147
- await this.add(channel, sessionId, member)
171
+ if (typeof channel !== 'string' || typeof sessionId !== 'string') continue
172
+ const key = this._key(channel)
173
+ const value = JSON.stringify({ userId: member?.userId, ...(member?.metadata ? { metadata: member.metadata } : {}), expiresAt })
174
+ pipeline.hset(key, sessionId, value)
175
+ if (!touchedChannels.has(channel)) {
176
+ touchedChannels.add(channel)
177
+ pipeline.pexpire(key, this._ttlMs * 2)
178
+ }
148
179
  }
180
+ this._throwFirstPipelineError(await pipeline.exec())
149
181
  }
150
182
 
151
183
  /**
@@ -155,9 +187,19 @@ export class MegaWsRedisRoster {
155
187
  async stop() {
156
188
  if (this._hbTimer) clearInterval(this._hbTimer)
157
189
  this._hbTimer = null
158
- // graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리).
159
- for (const { channel, sessionId } of this._getLocalMembers()) {
160
- await this.remove(channel, sessionId).catch(() => {})
190
+ // graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리) — 한 pipeline 으로.
191
+ // 실패해도 종료는 계속(lazy 만료가 정리) 명단에 TTL 까지 남으므로 묵히지 않고 알린다.
192
+ const members = this._getLocalMembers()
193
+ if (members.length === 0) return
194
+ const pipeline = this._redis.pipeline()
195
+ for (const { channel, sessionId } of members) {
196
+ if (typeof channel !== 'string' || typeof sessionId !== 'string') continue
197
+ pipeline.hdel(this._key(channel), sessionId)
198
+ }
199
+ try {
200
+ this._throwFirstPipelineError(await pipeline.exec())
201
+ } catch (err) {
202
+ this._log?.warn?.({ err }, 'ws-roster graceful remove failed (stale until TTL)')
161
203
  }
162
204
  }
163
205
  }
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * @module core/ws-upgrade
27
27
  */
28
- import { createWsMessage, parseWsMessage, generateMessageId, WS_TYPE_PATTERN } from './ws-message.js'
28
+ import { createWsMessage, parseWsMessage, generateMessageId, WS_TYPE_PATTERN, WS_PROTOCOL_VERSION } from './ws-message.js'
29
29
  import { MegaAspDecryptError } from '../lib/asp/errors.js'
30
30
  import * as MegaTracing from '../lib/mega-tracing.js'
31
31
  import * as MegaMetrics from '../lib/mega-metrics.js'
@@ -49,9 +49,148 @@ export const CLOSE_CODE_INTERNAL_ERROR = 1011
49
49
  /** 느린 소비자 백프레셔 close code (RFC 6455 §7.4.1 표준 1013 "Try Again Later", Med). */
50
50
  export const CLOSE_CODE_SLOW_CONSUMER = 1013
51
51
 
52
+ /**
53
+ * 재배치(requeue) close code — 4503. "이 워커 말고 다른 곳으로 즉시 재연결하라"는 신호로,
54
+ * bridge↔hub 의 drain(4503, `hub-protocol.js` CLOSE_CODE_DRAIN)과 같은 코드를 써서 클라이언트가
55
+ * 한 가지 규약("4503 = 세션 유지한 채 재연결")으로 두 링크를 모두 처리한다. admin-kick 의
56
+ * `requeue: true`(hub.disconnect) 가 사용 — kick(1008, 돌아오지 마라)과 의미가 반대다.
57
+ * HTTP 세션은 보존되므로 재연결 시 `before` 인증이 세션 쿠키로 자연 통과한다(transparent re-route).
58
+ */
59
+ export const CLOSE_CODE_REQUEUE = 4503
60
+
52
61
  /** send 버퍼(bufferedAmount) 기본 상한(바이트). 초과 = 소비자가 ack 를 못 따라옴 → 연결 종료(서버 OOM 방지). */
53
62
  export const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024
54
63
 
64
+ /**
65
+ * 프로세스 **합산** send 버퍼 기본 budget(바이트, ADR-215 — G5 audit M-6).
66
+ *
67
+ * per-conn 상한(위 16MiB)은 연결 1개의 OOM 방어일 뿐이라, 느린 소비자 N 개가 각자 cap 직전까지
68
+ * 쌓으면 합산은 무제한이었다(이론상 1,000개 × 16MiB = 16GB). 합산이 본 budget 을 넘으면 **가장 큰
69
+ * 송신 큐를 보유한 연결부터** 1013(slow consumer)으로 종료해 budget 아래로 회수한다.
70
+ * `configureWsSendBudget({ maxTotalBufferedBytes: 0 })` 으로 무제한 옵트아웃.
71
+ */
72
+ export const DEFAULT_MAX_TOTAL_BUFFERED_BYTES = 256 * 1024 * 1024
73
+
74
+ /** 합산 budget 스윕 최소 간격(ms) — 매 send 마다 전 연결 O(N) 합산을 돌지 않게 하는 비용 상한. */
75
+ const BUDGET_SWEEP_INTERVAL_MS = 250
76
+
77
+ /** budget 추적 대상 연결 — driveWsConnection 경유 실연결만(직접 생성한 테스트 더블은 미추적). @type {Set<MegaWsConnection>} */
78
+ const budgetConns = new Set()
79
+
80
+ /** @type {number} 현재 합산 budget(바이트). Infinity = 옵트아웃. */
81
+ let maxTotalBufferedBytes = DEFAULT_MAX_TOTAL_BUFFERED_BYTES
82
+
83
+ /** @type {number} 마지막 budget 스윕 시각(epoch ms). */
84
+ let lastBudgetSweepAt = 0
85
+
86
+ /**
87
+ * 프로세스 합산 send budget 을 조정한다(ADR-215). 부팅/운영 튜닝 진입점.
88
+ * @param {{ maxTotalBufferedBytes?: number }} [opts] - 바이트 budget. `0` = 무제한(옵트아웃).
89
+ * @returns {{ maxTotalBufferedBytes: number }} 적용된 현재 값.
90
+ * @throws {TypeError} 음수/비숫자 — 설정 실수 fail-fast.
91
+ */
92
+ export function configureWsSendBudget({ maxTotalBufferedBytes: max } = {}) {
93
+ if (max !== undefined) {
94
+ if (typeof max !== 'number' || Number.isNaN(max) || max < 0) {
95
+ throw new TypeError(`configureWsSendBudget: maxTotalBufferedBytes must be a non-negative number (0 = unlimited). Got ${max}`)
96
+ }
97
+ maxTotalBufferedBytes = max === 0 ? Infinity : max
98
+ }
99
+ return { maxTotalBufferedBytes }
100
+ }
101
+
102
+ /**
103
+ * 합산 budget 스윕 — 추적 중 전 연결의 `bufferedAmount` 를 합산해 budget 초과면 가장 큰 큐 보유
104
+ * 연결부터 종료한다. 스로틀({@link BUDGET_SWEEP_INTERVAL_MS}) 안쪽 재호출은 no-op(send 핫패스 보호).
105
+ * @param {number} [now] - epoch ms(테스트 주입용).
106
+ * @returns {void}
107
+ */
108
+ function sweepWsSendBudget(now = Date.now()) {
109
+ if (now - lastBudgetSweepAt < BUDGET_SWEEP_INTERVAL_MS) return
110
+ lastBudgetSweepAt = now
111
+ if (budgetConns.size === 0 || maxTotalBufferedBytes === Infinity) return
112
+ let total = 0
113
+ for (const c of budgetConns) total += c._raw.bufferedAmount ?? 0
114
+ while (total > maxTotalBufferedBytes) {
115
+ /** @type {MegaWsConnection | null} */
116
+ let worst = null
117
+ for (const c of budgetConns) {
118
+ if (worst === null || (c._raw.bufferedAmount ?? 0) > (worst._raw.bufferedAmount ?? 0)) worst = c
119
+ }
120
+ const worstBytes = worst === null ? 0 : (worst._raw.bufferedAmount ?? 0)
121
+ if (worst === null || worstBytes === 0) break // 잔여가 전부 0 이면 더 회수할 게 없음(무한루프 차단).
122
+ budgetConns.delete(worst) // close 이벤트 전에 즉시 제외 — 같은 스윕 내 재선정 방지.
123
+ total -= worstBytes
124
+ try {
125
+ worst._raw.close(CLOSE_CODE_SLOW_CONSUMER, 'backpressure: process-wide send budget exceeded')
126
+ } catch {
127
+ // 이미 닫히는 중이면 close 는 무의미 — per-conn 가드와 동일 의미(비치명적). Set 에선 이미 제거됨.
128
+ }
129
+ }
130
+ }
131
+
132
+ /** budget 추적 등록(driveWsConnection 전용). @param {MegaWsConnection} conn @returns {void} */
133
+ function trackWsSendBudget(conn) {
134
+ budgetConns.add(conn)
135
+ }
136
+
137
+ /** budget 추적 해제(연결 close 시). @param {MegaWsConnection} conn @returns {void} */
138
+ function untrackWsSendBudget(conn) {
139
+ budgetConns.delete(conn)
140
+ }
141
+
142
+ /**
143
+ * 테스트 격리용 — budget 상태 초기화(추적 Set·스로틀·budget 디폴트 복원).
144
+ * @returns {void}
145
+ */
146
+ export function _resetWsSendBudget() {
147
+ budgetConns.clear()
148
+ lastBudgetSweepAt = 0
149
+ maxTotalBufferedBytes = DEFAULT_MAX_TOTAL_BUFFERED_BYTES
150
+ }
151
+
152
+ /** 테스트용 — 연결을 budget 추적에 등록. @param {MegaWsConnection} conn @returns {void} */
153
+ export function _trackWsSendBudget(conn) {
154
+ trackWsSendBudget(conn)
155
+ }
156
+
157
+ /** 테스트용 — 스로틀 우회 가능한 스윕 직접 호출. @param {number} [now] @returns {void} */
158
+ export function _sweepWsSendBudget(now) {
159
+ sweepWsSendBudget(now)
160
+ }
161
+
162
+ /**
163
+ * 클라↔bridge ping/pong liveness 기본 주기(ms) — 30초. 주기마다 ping 을 보내고 직전 주기의 pong 이
164
+ * 없으면 half-open(상대 사망·네트워크 단절) 으로 보고 terminate 한다 — 좀비 연결이 OS TCP 타임아웃까지
165
+ * `_wsConns`/roster 에 잔존하는 것을 막는다. 라우트 `opts.heartbeatMs` 로 조정, `0` = 끔.
166
+ */
167
+ export const DEFAULT_WS_HEARTBEAT_MS = 30_000
168
+
169
+ /**
170
+ * onConnect 완료 전 도착한 프레임의 대기 큐 상한 — 초과 시 연결 종료(1013). onConnect 가 느릴 때
171
+ * 악의적/과속 클라이언트가 큐로 메모리를 채우는 것을 막는다.
172
+ */
173
+ export const MAX_PENDING_FRAMES_BEFORE_CONNECT = 256
174
+
175
+ /**
176
+ * 라우트 opts 의 `heartbeatMs` 를 검증해 반환한다. 미지정 → 기본 30초, `0` = liveness 끔.
177
+ * 무효값(음수/비정수/비숫자)은 운영 실수 — 조용히 보정하지 않고 warn 로그 후 기본값을 쓴다
178
+ * (연결 구동 시점이라 throw 하면 핸드셰이크 콜백 밖으로 새므로 로그+기본값이 안전한 fail-safe).
179
+ *
180
+ * @param {{ heartbeatMs?: number } | undefined} opts - WS 라우트 opts.
181
+ * @param {any} [log] - 로거(warn).
182
+ * @returns {number} 적용할 주기(ms). 0 = 끔.
183
+ */
184
+ export function resolveWsHeartbeatMs(opts, log) {
185
+ const v = opts?.heartbeatMs
186
+ if (v === undefined || v === null) return DEFAULT_WS_HEARTBEAT_MS
187
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 0) {
188
+ log?.warn?.({ heartbeatMs: v }, `ws route opts.heartbeatMs is invalid (integer >= 0 expected) — using default ${DEFAULT_WS_HEARTBEAT_MS}ms`)
189
+ return DEFAULT_WS_HEARTBEAT_MS
190
+ }
191
+ return v
192
+ }
193
+
55
194
  /**
56
195
  * WS 프레임 코덱 — 평문/암호 와이어 변환을 추상화한다.
57
196
  * @typedef {Object} WsFrameCodec
@@ -122,6 +261,8 @@ export class MegaWsConnection {
122
261
  this.channels = null
123
262
  /** @type {Object|undefined} joinSession/updateMetadata 로 저장한 presence 메타(재연결 재동기화에 보존, M-1). */
124
263
  this.metadata = undefined
264
+ /** @type {number} 협상된 envelope 프로토콜 버전(기본 v1) — driveWsConnection 이 핸드셰이크 결과로 설정. */
265
+ this.protocolVersion = 1
125
266
  }
126
267
 
127
268
  /** 하위 raw `ws` WebSocket (escape hatch — 바이너리/직접 제어용). */
@@ -154,6 +295,10 @@ export class MegaWsConnection {
154
295
  }
155
296
  return
156
297
  }
298
+ // 프로세스 합산 budget 스윕(ADR-215) — per-conn cap 아래의 "다수의 느린 소비자" 합산 OOM 방어.
299
+ // 스로틀이 있어 핫패스 비용은 주기당 O(N) 1회. 스윕이 이 연결을 닫았으면(가장 큰 큐) 송신 생략.
300
+ sweepWsSendBudget()
301
+ if (this._raw.readyState !== undefined && this._raw.readyState !== 1) return
157
302
  // ns 자동 주입 (L1): 명시 안 됐고 연결 ns 가 있으면 채운다. 명시값은 덮어쓰지 않음.
158
303
  const withNs = fields.ns === undefined && this.ns !== undefined ? { ...fields, ns: this.ns } : fields
159
304
  const env = createWsMessage(withNs)
@@ -234,10 +379,17 @@ function buildWsPresence(app, conn, ns) {
234
379
  * @param {any} args.log - request 로거 (debug/warn/error).
235
380
  * @param {any} [args.auth] - `before` 미들웨어가 인증 후 돌려준 신원(`{ userId, sessionId, ... }`).
236
381
  * `ctx.auth` 로 노출 — onConnect 에서 `app.joinSession(sock, { userId: ctx.auth.userId, ... })` 에 쓴다.
382
+ * @param {number} [args.protocolVersion] - 핸드셰이크에서 협상된 envelope 버전(`mega.v<N>` subprotocol,
383
+ * {@link import('./ws-message.js').negotiateWsProtocol}). 미지정 = v1(레거시). 이 연결의 envelope
384
+ * 검증 기준이 되고 `conn.protocolVersion`/`ctx.protocolVersion` 으로 노출된다.
237
385
  * @returns {MegaWsConnection}
238
386
  */
239
- export function driveWsConnection({ raw, req, route, app, codec, log, auth = null }) {
387
+ export function driveWsConnection({ raw, req, route, app, codec, log, auth = null, protocolVersion = WS_PROTOCOL_VERSION }) {
240
388
  const conn = new MegaWsConnection(raw, codec, { ns: route.ns, path: route.path })
389
+ // 프로세스 합산 send budget 추적(ADR-215) — 실연결만 등록, close 에서 해제(아래 'close' 핸들러).
390
+ trackWsSendBudget(conn)
391
+ // 협상된 envelope 버전 — 이 연결의 검증 기준이자 v2 도입 시 코덱/검증 분기의 기준점.
392
+ conn.protocolVersion = protocolVersion
241
393
  // ChannelClass 는 부팅 시 MegaWebSocketController 상속 검증됨 (router.ws). 여기선 인스턴스화만.
242
394
  const channel = /** @type {import('./ws-controller.js').MegaWebSocketController} */ (
243
395
  new (/** @type {any} */ (route.ChannelClass))()
@@ -256,6 +408,7 @@ export function driveWsConnection({ raw, req, route, app, codec, log, auth = nul
256
408
  path: route.path,
257
409
  req,
258
410
  connId: conn.id,
411
+ protocolVersion, // 협상된 envelope 버전(v1 기본) — 채널이 버전별 동작을 분기할 때 사용.
259
412
  tracer: MegaTracing.tracer, // ctx.tracer.span(name, fn) — WS 핸들러에서도 사용자 직접 span(ADR-126).
260
413
  // presence 단축 API (ADR-176) — list(클러스터 roster)/join/directToUser/broadcast 를 ns·conn 바인딩으로
261
414
  // 노출. 클러스터 동기화는 wsCluster 가 처리하므로 채널은 비즈니스 로직만 쓴다. mock app 이면 null.
@@ -270,30 +423,88 @@ export function driveWsConnection({ raw, req, route, app, codec, log, auth = nul
270
423
 
271
424
  log.debug?.({ connId: conn.id, path: route.path, ns: route.ns }, 'ws.connect enter')
272
425
 
426
+ // type 별 payload 스키마 검증 함수 맵 (M2, router.ws 에서 사전 컴파일). 없으면 검증 없음.
427
+ const schemaValidators = route.schemaValidators ?? null
428
+
429
+ /** 프레임 1건 처리 시작 — fire-and-forget(각 메시지 독립 async 흐름). @param {string} frame */
430
+ const processFrame = (frame) => {
431
+ // 최외곽 가드 (L2): handleIncoming 은 내부에서 단계별 try/catch 하지만, 예기치 못한
432
+ // 동기 throw / reject 가 unhandledRejection 으로 새지 않도록 .catch 로 마무리한다.
433
+ handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaValidators, log }).catch((err) => {
434
+ log.warn?.({ err, connId: conn.id }, 'ws handleIncoming unexpected error')
435
+ })
436
+ }
437
+
438
+ // onConnect 완료 전 도착한 프레임은 큐에 보관했다가 완료 후 도착 순서대로 처리한다 — 'message' 리스너는
439
+ // 동기 부착되고 onConnect 는 비동기라, 빠른 클라이언트의 첫 메시지가 채널 초기화(joinSession 등) 전에
440
+ // 디스패치되는 race 를 막는다. onConnect 실패 시 연결이 닫히므로 큐는 버린다.
441
+ let isConnectSettled = false
442
+ /** @type {string[]} */
443
+ let pendingFrames = []
444
+
273
445
  // onConnect — 실패 시 fail-closed. 복호화와 무관한 서버 내부 오류이므로 1011 (M3). silent 금지.
274
446
  Promise.resolve()
275
447
  .then(() => channel.onConnect(conn, ctx))
448
+ .then(() => {
449
+ isConnectSettled = true
450
+ const queued = pendingFrames
451
+ pendingFrames = []
452
+ for (const frame of queued) processFrame(frame)
453
+ })
276
454
  .catch((err) => {
277
455
  log.error?.({ err, connId: conn.id }, 'ws.onConnect threw — closing (1011)')
456
+ pendingFrames = [] // 연결이 닫히므로 대기 프레임은 처리하지 않는다.
278
457
  if (conn.isOpen) conn.close(CLOSE_CODE_INTERNAL_ERROR, 'onConnect failed')
279
458
  })
280
459
 
281
- // type 별 payload 스키마 검증 함수 맵 (M2, router.ws 에서 사전 컴파일). 없으면 검증 없음.
282
- const schemaValidators = route.schemaValidators ?? null
283
-
284
460
  raw.on('message', (data) => {
285
461
  // ws 는 Buffer | ArrayBuffer | Buffer[] 를 줄 수 있음. 텍스트 프레임만 처리 (바이너리는 후속 BINARY 타입).
286
462
  const frame = Array.isArray(data)
287
463
  ? Buffer.concat(data).toString('utf8')
288
464
  : data.toString('utf8')
289
- // 최외곽 가드 (L2): handleIncoming 은 내부에서 단계별 try/catch 하지만, 예기치 못한
290
- // 동기 throw / reject 가 unhandledRejection 으로 새지 않도록 .catch 로 마무리한다.
291
- handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaValidators, log }).catch((err) => {
292
- log.warn?.({ err, connId: conn.id }, 'ws handleIncoming unexpected error')
293
- })
465
+ if (!isConnectSettled) {
466
+ if (pendingFrames.length >= MAX_PENDING_FRAMES_BEFORE_CONNECT) {
467
+ // onConnect 끝나기 전에 상한 도달 더 쌓으면 메모리 abuse 라 연결을 닫는다(1013).
468
+ log.warn?.({ connId: conn.id, queued: pendingFrames.length }, 'ws pre-connect frame queue overflow — closing (1013)')
469
+ pendingFrames = []
470
+ if (conn.isOpen) conn.close(CLOSE_CODE_SLOW_CONSUMER, 'pre-connect queue overflow')
471
+ return
472
+ }
473
+ pendingFrames.push(frame)
474
+ return
475
+ }
476
+ processFrame(frame)
294
477
  })
295
478
 
479
+ // liveness(ping/pong): 주기마다 ping 을 보내고 직전 주기의 pong 이 없으면 half-open 으로 보고
480
+ // terminate 한다 — 'close' 이벤트가 onDisconnect/untrack 정리를 그대로 트리거한다. opts.heartbeatMs=0 으로 끔.
481
+ const heartbeatMs = resolveWsHeartbeatMs(/** @type {any} */ (route.opts), log)
482
+ if (heartbeatMs > 0) {
483
+ let isAlive = true
484
+ raw.on('pong', () => {
485
+ isAlive = true
486
+ })
487
+ const pingTimer = setInterval(() => {
488
+ if (raw.readyState !== 1) return // 닫히는 중 — 'close' 가 곧 타이머를 정리한다.
489
+ if (!isAlive) {
490
+ log.warn?.({ connId: conn.id, heartbeatMs }, 'ws heartbeat timeout — terminating half-open connection')
491
+ raw.terminate()
492
+ return
493
+ }
494
+ isAlive = false
495
+ try {
496
+ raw.ping()
497
+ } catch (err) {
498
+ // 소켓이 닫히는 중이면 ping 실패 — 비치명적, close 가 뒤따른다 (이유+로그).
499
+ log.debug?.({ err, connId: conn.id }, 'ws ping send failed (socket closing)')
500
+ }
501
+ }, heartbeatMs)
502
+ if (typeof pingTimer.unref === 'function') pingTimer.unref()
503
+ raw.on('close', () => clearInterval(pingTimer))
504
+ }
505
+
296
506
  raw.on('close', (code, reasonBuf) => {
507
+ untrackWsSendBudget(conn)
297
508
  app._untrackWsConn?.(conn)
298
509
  const reason = Buffer.isBuffer(reasonBuf) ? reasonBuf.toString('utf8') : String(reasonBuf ?? '')
299
510
  log.debug?.({ connId: conn.id, code, reason }, 'ws.disconnect')
@@ -396,11 +607,11 @@ async function handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaVal
396
607
  return
397
608
  }
398
609
 
399
- // 2) envelope 파싱 + 검증. 실패 → error envelope 응답(연결 유지 — 비치명적).
610
+ // 2) envelope 파싱 + 검증 — 이 연결에서 협상된 버전 기준. 실패 → error envelope 응답(연결 유지 — 비치명적).
400
611
  /** @type {{ type: string, id: string }} */
401
612
  let msg
402
613
  try {
403
- msg = /** @type {{ type: string, id: string }} */ (parseWsMessage(plain))
614
+ msg = /** @type {{ type: string, id: string }} */ (parseWsMessage(plain, { version: conn.protocolVersion }))
404
615
  } catch (err) {
405
616
  log.warn?.({ err, connId: conn.id }, 'ws invalid envelope')
406
617
  if (conn.isOpen) {
package/src/index.js CHANGED
@@ -115,7 +115,7 @@ export { MegaAspDecryptError, ASP_RULES } from './lib/asp/errors.js'
115
115
  export { normalizeAspConfig } from './lib/asp/config.js'
116
116
 
117
117
  // Bridge ↔ Hub 12-타입 프로토콜 (ADR-033/059/097)
118
- export { MegaWsHub, DEFAULT_HEARTBEAT_MS, runWsHubCli } from './cli/ws-hub.js'
118
+ export { MegaWsHub, DEFAULT_HEARTBEAT_MS, runWsHubCli } from './lib/ws-hub.js'
119
119
  export { MegaHubLink } from './core/hub-link.js'
120
120
  // WS per-message deflate 압축 (ADR-078)
121
121
  export { buildPerMessageDeflate, checkCompressionConfig, COMPRESSION_DEFAULTS } from './core/ws-compression.js'
@@ -61,6 +61,30 @@ export const HUB_MESSAGE_TYPES = Object.freeze({
61
61
  /** 12 개 wire `type` 문자열 집합 (빠른 소속 판별용). @type {ReadonlySet<string>} */
62
62
  export const HUB_TYPE_SET = new Set(Object.values(HUB_MESSAGE_TYPES))
63
63
 
64
+ /** bridge↔hub 프로토콜 현 버전. REGISTER/REGISTER_OK 의 `protocolVersion` 협상 기준(부재 = v1). */
65
+ export const HUB_PROTOCOL_VERSION = 1
66
+
67
+ /**
68
+ * 이 측이 지원하는 bridge↔hub 프로토콜 버전 목록. 버전은 선형 누적 계약 — vN 지원 = v1..vN 전부 지원.
69
+ * @type {ReadonlyArray<number>}
70
+ */
71
+ export const SUPPORTED_HUB_PROTOCOL_VERSIONS = Object.freeze([HUB_PROTOCOL_VERSION])
72
+
73
+ /**
74
+ * bridge 가 REGISTER 로 보낸 최대 지원 버전과 hub 지원 버전의 상호 최고 버전을 고른다.
75
+ * 선형 누적 계약(vN 지원 = v1..vN 지원)이라 `min(bridgeMax, hubMax)` 가 항상 유효한 상호 버전이다
76
+ * (v1 이 바닥이라 협상이 실패할 수 없다 — 부재/무효 입력은 v1).
77
+ *
78
+ * @param {unknown} requestedMax - bridge 의 `protocolVersion`(최대 지원 버전).
79
+ * @param {ReadonlyArray<number>} [supported] - 이 측 지원 버전 목록.
80
+ * @returns {number} 협상된 버전(>= 1).
81
+ */
82
+ export function negotiateHubProtocolVersion(requestedMax, supported = SUPPORTED_HUB_PROTOCOL_VERSIONS) {
83
+ const ourMax = Math.max(...supported)
84
+ if (!Number.isInteger(requestedMax) || /** @type {number} */ (requestedMax) < 1) return HUB_PROTOCOL_VERSION
85
+ return Math.min(/** @type {number} */ (requestedMax), ourMax)
86
+ }
87
+
64
88
  /**
65
89
  * bridge↔hub WebSocket close code 카탈로그 (ADR-098).
66
90
  *
@@ -107,6 +131,8 @@ export const HUB_PAYLOAD_SCHEMAS = Object.freeze({
107
131
  instanceId: { type: 'string', minLength: 1 },
108
132
  token: { type: 'string', minLength: 1 },
109
133
  capabilities: { type: 'array', items: { type: 'string' } },
134
+ // 버전 협상(옵션): bridge 의 최대 지원 버전. 부재 = v1(레거시 bridge — 협상 없이 v1 고정).
135
+ protocolVersion: { type: 'integer', minimum: 1 },
110
136
  },
111
137
  additionalProperties: false,
112
138
  },
@@ -117,6 +143,9 @@ export const HUB_PAYLOAD_SCHEMAS = Object.freeze({
117
143
  hubId: { type: 'string', minLength: 1 },
118
144
  acceptedAt: { type: 'integer' },
119
145
  heartbeatMs: { type: 'integer', minimum: 1 },
146
+ // 버전 협상 결과(옵션): hub 가 채택한 버전. bridge 가 REGISTER 에 protocolVersion 을 실었을
147
+ // 때만 회신(echo-on-request) — 레거시 bridge(strict 스키마)가 모르는 필드를 받지 않게 한다.
148
+ protocolVersion: { type: 'integer', minimum: 1 },
120
149
  },
121
150
  additionalProperties: false,
122
151
  },
@@ -72,8 +72,10 @@ export const CAPACITY_ERROR_CODE = 'ESEMLOCKED'
72
72
  * @property {number} [resetTimeout=30000] - open 상태 유지 시간(ms). 경과 후 halfOpen 으로 1회 프로빙.
73
73
  * @property {number} [rollingCountTimeout=10000] - 실패율 집계 롤링 윈도우 길이(ms).
74
74
  * @property {number} [rollingCountBuckets=10] - 롤링 윈도우를 나누는 버킷 수.
75
- * @property {number} [volumeThreshold=0] - 이 횟수만큼 호출이 쌓이기 전엔 실패율이 높아도 open 안 함
76
- * (표본 부족으로 인한 조기 trip 방지). 0=비활성.
75
+ * @property {number} [volumeThreshold=5] - 롤링 윈도우에 이 횟수만큼 호출이 쌓이기 전엔 실패율이
76
+ * 높아도 open 안 함(표본 부족 조기 trip 방지). 0=비활성. ⚠️ opossum 정본값(0)과 다른 프레임워크
77
+ * 디폴트 — 0 이면 부팅 직후/저빈도 호출에서 **첫 실패 1건**이 실패율 100% 가 돼 즉시 30s 차단되는
78
+ * 풋건이라 5 로 올렸다. opossum 원 동작이 필요하면 명시적으로 0 을 지정.
77
79
  * @property {number} [capacity] - 동시 진행(in-flight) 호출 상한. 초과분은 `ESEMLOCKED` 로 즉시 거부.
78
80
  * 미지정=무제한.
79
81
  * @property {(err: any, ...args: any[]) => boolean} [errorFilter] - `true` 반환 시 그 에러는 **실패로 집계하지 않음**
@@ -149,7 +151,7 @@ export class MegaCircuitBreaker {
149
151
  resetTimeout = 30_000,
150
152
  rollingCountTimeout = 10_000,
151
153
  rollingCountBuckets = 10,
152
- volumeThreshold = 0,
154
+ volumeThreshold = 5, // opossum 정본(0)과 의도적으로 다름 — 단일 실패 즉시 open 풋건 방지.
153
155
  capacity,
154
156
  errorFilter,
155
157
  name,
@@ -19,21 +19,40 @@
19
19
 
20
20
  import { MegaShutdown } from './mega-shutdown.js'
21
21
 
22
+ /** 체크 1개의 기본 타임아웃(ms) — hung 체크가 readiness 응답을 무기한 막지 않게 한다. */
23
+ export const DEFAULT_CHECK_TIMEOUT_MS = 5_000
24
+
25
+ /** @type {Map<string, { fn: Function, timeoutMs: number }>} */
22
26
  const checks = new Map()
23
27
 
24
28
  /**
25
29
  * 헬스 체크 등록.
26
30
  * @param {string} name
27
31
  * @param {() => Promise<{ ok: boolean, [key: string]: any }> | { ok: boolean }} fn
32
+ * @param {{ timeoutMs?: number }} [opts] - 체크별 타임아웃(양의 정수 ms, 기본 {@link DEFAULT_CHECK_TIMEOUT_MS}).
28
33
  */
29
- export function register(name, fn) {
34
+ export function register(name, fn, opts = {}) {
30
35
  if (typeof name !== 'string' || name.length === 0) {
31
36
  throw new Error('MegaHealth.register: name is required (string)')
32
37
  }
33
38
  if (typeof fn !== 'function') {
34
39
  throw new Error('MegaHealth.register: fn must be a function')
35
40
  }
36
- checks.set(name, fn)
41
+ const timeoutMs =
42
+ Number.isInteger(opts.timeoutMs) && /** @type {number} */ (opts.timeoutMs) > 0
43
+ ? /** @type {number} */ (opts.timeoutMs)
44
+ : DEFAULT_CHECK_TIMEOUT_MS
45
+ checks.set(name, { fn, timeoutMs })
46
+ }
47
+
48
+ /**
49
+ * 등록된 체크 제거(이름 기준). 닫힌 자원(예: hub link)의 체크가 stale 클로저로 남지 않게
50
+ * 소유자가 register 와 짝맞춰 부른다.
51
+ * @param {string} name
52
+ * @returns {boolean} 제거됐으면 true(미등록 이름이면 false).
53
+ */
54
+ export function unregister(name) {
55
+ return checks.delete(name)
37
56
  }
38
57
 
39
58
  /**
@@ -48,12 +67,24 @@ export async function checkAll() {
48
67
 
49
68
  const entries = [...checks.entries()]
50
69
  const results = await Promise.all(
51
- entries.map(async ([name, fn]) => {
70
+ entries.map(async ([name, { fn, timeoutMs }]) => {
71
+ /** @type {NodeJS.Timeout | undefined} */
72
+ let timer
52
73
  try {
53
- const result = await fn()
74
+ // 체크별 race hung 체크 1개가 readiness 전체를 무기한 막지 않게 timeoutMs 에서 끊는다
75
+ // (probe timeout 으로 인한 연쇄 재시작 방지). 타임아웃은 해당 체크만 ok:false 처리.
76
+ const result = await Promise.race([
77
+ Promise.resolve(fn()),
78
+ new Promise((_, reject) => {
79
+ timer = setTimeout(() => reject(new Error(`health check timed out after ${timeoutMs}ms`)), timeoutMs)
80
+ timer.unref?.()
81
+ }),
82
+ ])
54
83
  return [name, result?.ok === true ? result : { ok: false, ...result }]
55
84
  } catch (err) {
56
85
  return [name, { ok: false, error: err?.message ?? String(err) }]
86
+ } finally {
87
+ clearTimeout(timer)
57
88
  }
58
89
  }),
59
90
  )