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,225 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaBruteForce — 동일 키 반복 시도 차단 (ADR-049 / ADR-130).
4
+ *
5
+ * # 무엇을 막나 (중학생용 설명)
6
+ * 공격자는 한 계정에 비밀번호를 수천 번 찍어보며 뚫으려 한다(brute force). MegaBruteForce 는 "같은
7
+ * 대상(이메일·IP 등)이 정해진 횟수 이상 **실패**하면 일정 시간 잠근다". 잠긴 동안에는 맞는 비밀번호를
8
+ * 넣어도 일단 거부 — 공격 비용을 폭증시킨다. 로그인뿐 아니라 비밀번호 리셋·인증코드 확인 등 "반복
9
+ * 시도가 위험한 모든 길목" 에 쓴다.
10
+ *
11
+ * # 동작 (fixed window + lockout)
12
+ * - `fail(subject)` 한 번마다 시도 카운터를 **원자적으로 +1**(Redis `INCR`). 첫 실패에 윈도우 TTL
13
+ * (`windowMs`)을 건다 — 윈도우가 지나면 카운터가 자연 소멸(고정 윈도우).
14
+ * - 카운터가 `maxAttempts` 에 도달하면 **잠금 키**를 `lockMs` 동안 세우고 카운터는 비운다.
15
+ * - `check(subject)` 는 시도 **전에** 호출해 현재 잠금/남은 횟수를 본다(부수효과 없음).
16
+ * - `reset(subject)` 는 로그인 **성공** 시 호출해 카운터·잠금을 즉시 지운다.
17
+ *
18
+ * # subject 선택 가이드 (계정 잠금 DoS 방어, I-1)
19
+ * subject 는 **IP 또는 IP+email 조합을 권장**한다. email 단독으로 잠그면, 공격자가 피해자 email 로
20
+ * 일부러 반복 실패시켜 **정상 사용자를 잠가버리는** 계정 잠금 DoS(account-lockout DoS)가 가능하다.
21
+ * IP 를 섞으면(예: `${ip}:${email}`) 공격자는 자기 IP 만 잠그게 돼 피해자 로그인은 막히지 않는다.
22
+ * (프록시·NAT 뒤 다중 사용자, IPv6 prefix 등 환경에 따라 IP 단독/조합을 호출부가 선택한다.)
23
+ *
24
+ * # 저장소 = Redis (ADR-049)
25
+ * 원자적 카운팅이 핵심이라 백엔드는 Redis 다(`INCR`/`PEXPIRE`/`PTTL`/`SET PX`). 생성자에 **연결된
26
+ * `MegaCacheAdapter`(redis driver)** 를 주입하면 그 `.native`(ioredis)로 직접 명령한다. 03-api-spec §786
27
+ * 의 `cache: string`(별명)은 프레임워크 배선층(`ctx.bruteForce`)이 별명→어댑터로 해석해 주입한다 —
28
+ * 순수 lib 은 별명을 모르므로 어댑터 인스턴스를 받는다(ADR-130 명시).
29
+ *
30
+ * # 에러를 묵지 않는다
31
+ * Redis 명령 실패는 throw 한다(삼키지 않음). 잠금 판단을 "에러나서 통과" 시키면 보호가 무력화되므로,
32
+ * 호출부가 장애를 인지하고 정책(예: 5xx 또는 보수적 거부)을 **명시적으로** 정하게 한다.
33
+ *
34
+ * # 관측은 하되 PII 는 가린다
35
+ * subject(이메일·IP)는 원본을 span/로그에 절대 안 박는다 — SHA-256 앞 16자 해시만 노출.
36
+ * 활성 트레이싱 span 에 `mega.bruteforce.{event,locked,attempts_left,namespace,subject(hash)}` attribute
37
+ * (ADR-126 재사용, 옵트인 OFF 면 no-op).
38
+ *
39
+ * @module lib/mega-brute-force
40
+ * @see ADR-049 (MegaBruteForce 1차 포함), ADR-130 (scrypt/배선/정책)
41
+ */
42
+ import { createHash } from 'node:crypto'
43
+ import { MegaValidationError, MegaInternalError } from '../errors/http-errors.js'
44
+ import { tracer as megaTracer } from './mega-tracing.js'
45
+ import { recordBruteForce } from './mega-metrics.js'
46
+
47
+ /** 기본 정책값 (ADR-049 — docs/12 T8 정합). 생성자 opts 로 오버라이드. */
48
+ const DEFAULTS = Object.freeze({ maxAttempts: 5, windowMs: 15 * 60_000, lockMs: 15 * 60_000 })
49
+
50
+ /** Redis 키 prefix(cache 어댑터의 `mega:cache:*` 와 분리된 brute-force 전용 네임스페이스). */
51
+ const KEY_ROOT = 'mega:bf'
52
+
53
+ /**
54
+ * @typedef {object} BruteForceResult - check/fail 반환 형태 (03-api-spec §786).
55
+ * @property {boolean} locked - 현재 잠김 여부.
56
+ * @property {boolean} isLocked - `locked` 별칭(Boolean 컨벤션 — 둘 다 노출).
57
+ * @property {number} attemptsLeft - 잠기기까지 남은 실패 허용 횟수(잠겼으면 0).
58
+ * @property {number} retryAfterMs - 잠금 해제까지 남은 ms(안 잠겼으면 0).
59
+ */
60
+
61
+ export class MegaBruteForce {
62
+ /** @type {import('../adapters/mega-cache-adapter.js').MegaCacheAdapter} */
63
+ #cache
64
+ /** @type {string} 네임스페이스(도메인 구분 — 예: 'login', 'password-reset'). */
65
+ #namespace
66
+ #maxAttempts
67
+ #windowMs
68
+ #lockMs
69
+
70
+ /**
71
+ * @param {object} opts
72
+ * @param {import('../adapters/mega-cache-adapter.js').MegaCacheAdapter} opts.cache - 연결된 redis 캐시 어댑터(`.native`=ioredis).
73
+ * @param {string} opts.key - 네임스페이스(도메인 구분). 03-api-spec 의 생성자 `key`.
74
+ * @param {number} [opts.maxAttempts=5] - 잠금까지 허용 실패 횟수.
75
+ * @param {number} [opts.windowMs=900000] - 시도 카운트 윈도우(ms). 첫 실패부터 이 시간 뒤 카운터 소멸.
76
+ * @param {number} [opts.lockMs=900000] - 잠금 지속 시간(ms).
77
+ * @throws {MegaValidationError} `bruteforce.invalid_option` - cache/key 누락 또는 수치 옵션 비-양의정수.
78
+ */
79
+ constructor(opts = /** @type {any} */ ({})) {
80
+ if (!opts.cache || typeof opts.cache !== 'object') {
81
+ throw new MegaValidationError('bruteforce.invalid_option', 'MegaBruteForce: opts.cache (connected redis MegaCacheAdapter) is required.')
82
+ }
83
+ if (typeof opts.key !== 'string' || opts.key.length === 0) {
84
+ throw new MegaValidationError('bruteforce.invalid_option', 'MegaBruteForce: opts.key (namespace string) is required.')
85
+ }
86
+ this.#cache = opts.cache
87
+ this.#namespace = opts.key
88
+ this.#maxAttempts = assertPositiveInt(opts.maxAttempts ?? DEFAULTS.maxAttempts, 'maxAttempts')
89
+ this.#windowMs = assertPositiveInt(opts.windowMs ?? DEFAULTS.windowMs, 'windowMs')
90
+ this.#lockMs = assertPositiveInt(opts.lockMs ?? DEFAULTS.lockMs, 'lockMs')
91
+ }
92
+
93
+ /**
94
+ * 연결된 ioredis 클라이언트. cache 어댑터가 redis 가 아니면(=`.native` 가 ioredis 명령 미보유) fail-fast.
95
+ * @returns {import('ioredis').Redis}
96
+ */
97
+ #client() {
98
+ const client = /** @type {any} */ (this.#cache).native // 미연결이면 베이스 getter 가 throw(fail-fast).
99
+ if (!client || typeof client.incr !== 'function') {
100
+ throw new MegaInternalError('bruteforce.redis_required', 'MegaBruteForce requires a Redis cache adapter (atomic INCR/PEXPIRE). The provided cache is not Redis.')
101
+ }
102
+ return client
103
+ }
104
+
105
+ /** @param {string} subject @returns {string} 시도 카운터 Redis 키. */
106
+ #attemptsKey(subject) {
107
+ return `${KEY_ROOT}:${this.#namespace}:a:${subject}`
108
+ }
109
+
110
+ /** @param {string} subject @returns {string} 잠금 Redis 키. */
111
+ #lockKey(subject) {
112
+ return `${KEY_ROOT}:${this.#namespace}:l:${subject}`
113
+ }
114
+
115
+ /**
116
+ * 시도 **전** 현재 상태 조회(부수효과 없음). 잠겨 있으면 호출부가 즉시 거부할 수 있다.
117
+ * @param {string} subject - 대상 식별자(이메일·IP 등).
118
+ * @returns {Promise<BruteForceResult>}
119
+ */
120
+ async check(subject) {
121
+ assertSubject(subject)
122
+ const client = this.#client()
123
+ const lockTtl = await client.pttl(this.#lockKey(subject)) // -2=키없음, -1=무한, >0=남은 ms.
124
+ if (lockTtl > 0) {
125
+ return this.#emit('check', subject, { locked: true, isLocked: true, attemptsLeft: 0, retryAfterMs: lockTtl })
126
+ }
127
+ const raw = await client.get(this.#attemptsKey(subject))
128
+ const n = raw === null ? 0 : Number(raw)
129
+ return this.#emit('check', subject, {
130
+ locked: false,
131
+ isLocked: false,
132
+ attemptsLeft: Math.max(0, this.#maxAttempts - n),
133
+ retryAfterMs: 0,
134
+ })
135
+ }
136
+
137
+ /**
138
+ * 실패 1건 기록. 카운터를 원자적으로 +1 하고, `maxAttempts` 도달 시 잠금을 건다.
139
+ * 이미 잠긴 상태면 카운터를 더 늘리지 않고 잠금 정보를 그대로 반환한다.
140
+ * @param {string} subject - 대상 식별자.
141
+ * @returns {Promise<BruteForceResult>}
142
+ */
143
+ async fail(subject) {
144
+ assertSubject(subject)
145
+ const client = this.#client()
146
+ const lockKey = this.#lockKey(subject)
147
+ const lockTtl = await client.pttl(lockKey)
148
+ if (lockTtl > 0) {
149
+ // 이미 잠김 — 추가 증가 없이 현 상태 반환.
150
+ return this.#emit('fail', subject, { locked: true, isLocked: true, attemptsLeft: 0, retryAfterMs: lockTtl })
151
+ }
152
+ const attemptsKey = this.#attemptsKey(subject)
153
+ const n = await client.incr(attemptsKey) // 원자적 +1(없으면 1로 생성).
154
+ if (n === 1) {
155
+ // 첫 실패에만 윈도우 TTL — 고정 윈도우(이후 실패는 같은 윈도우 안에서 누적).
156
+ // (incr→pexpire 사이 크래시 시 키가 TTL 없이 잔존할 수 있으나, 이는 "더 오래 카운트"=fail-closed
157
+ // 방향이라 보안상 안전. 다음 reset/lockout 이 정리한다.)
158
+ await client.pexpire(attemptsKey, this.#windowMs)
159
+ }
160
+ if (n >= this.#maxAttempts) {
161
+ // 임계 도달 — 잠금 세우고(PX=lockMs) 카운터는 비운다(잠금 해제 후 새 윈도우로 시작).
162
+ await client.set(lockKey, '1', 'PX', this.#lockMs)
163
+ await client.del(attemptsKey)
164
+ return this.#emit('lockout', subject, { locked: true, isLocked: true, attemptsLeft: 0, retryAfterMs: this.#lockMs })
165
+ }
166
+ return this.#emit('fail', subject, {
167
+ locked: false,
168
+ isLocked: false,
169
+ attemptsLeft: this.#maxAttempts - n,
170
+ retryAfterMs: 0,
171
+ })
172
+ }
173
+
174
+ /**
175
+ * 카운터·잠금 즉시 제거(로그인 성공 등 정상 시도 후).
176
+ * @param {string} subject - 대상 식별자.
177
+ * @returns {Promise<void>}
178
+ */
179
+ async reset(subject) {
180
+ assertSubject(subject)
181
+ const client = this.#client()
182
+ await client.del(this.#attemptsKey(subject), this.#lockKey(subject))
183
+ this.#emit('reset', subject, { locked: false, isLocked: false, attemptsLeft: this.#maxAttempts, retryAfterMs: 0 })
184
+ }
185
+
186
+ /**
187
+ * 활성 트레이싱 span 에 brute-force 이벤트 attribute 를 박고 결과를 그대로 반환한다(ADR-126).
188
+ * 옵트인 OFF 면 activeSpan()===undefined → no-op. subject 는 해시만 노출.
189
+ * @template {object} T
190
+ * @param {'check'|'fail'|'lockout'|'reset'} event @param {string} subject @param {T} result
191
+ * @returns {T}
192
+ */
193
+ #emit(event, subject, result) {
194
+ megaTracer.activeSpan()?.setAttributes({
195
+ 'mega.bruteforce.event': event,
196
+ 'mega.bruteforce.namespace': this.#namespace,
197
+ 'mega.bruteforce.subject': hashSubject(subject),
198
+ 'mega.bruteforce.locked': /** @type {any} */ (result).isLocked === true,
199
+ 'mega.bruteforce.attempts_left': /** @type {any} */ (result).attemptsLeft,
200
+ })
201
+ // 메트릭 집계(ADR-131) — namespace+event 만(subject 는 PII·카디널리티 폭증이라 미노출). 옵트인 OFF 면 0 비용.
202
+ recordBruteForce({ namespace: this.#namespace, event })
203
+ return result
204
+ }
205
+ }
206
+
207
+ /** subject 를 span/로그용으로 해시(원본 비노출). @param {string} subject @returns {string} */
208
+ function hashSubject(subject) {
209
+ return createHash('sha256').update(subject).digest('hex').slice(0, 16)
210
+ }
211
+
212
+ /** @param {unknown} subject @throws {MegaValidationError} 비-문자열/빈 문자열. */
213
+ function assertSubject(subject) {
214
+ if (typeof subject !== 'string' || subject.length === 0) {
215
+ throw new MegaValidationError('bruteforce.invalid_subject', 'MegaBruteForce: subject must be a non-empty string (e.g. email or IP).')
216
+ }
217
+ }
218
+
219
+ /** @param {unknown} v @param {string} name @returns {number} 양의 정수면 반환, 아니면 throw. */
220
+ function assertPositiveInt(v, name) {
221
+ if (typeof v !== 'number' || !Number.isInteger(v) || v <= 0) {
222
+ throw new MegaValidationError('bruteforce.invalid_option', `MegaBruteForce: ${name} must be a positive integer. Got ${v}`)
223
+ }
224
+ return v
225
+ }
@@ -0,0 +1,412 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaCircuitBreaker — 외부 호출 보호용 서킷 브레이커 (ADR-117).
4
+ *
5
+ * 검증된 공개 라이브러리 `opossum`(v9) 을 우리 컨벤션으로 얇게 래핑한다(ADR-029: "내부 구현은
6
+ * 검증된 공개 npm 라이브러리를 래핑한다" — MegaRetry=p-retry 선례와 동일 원칙). 롤링 윈도우 집계,
7
+ * half-open 복구 프로빙, 타임아웃, semaphore(capacity) 같은 경계 케이스를 직접 짜면 추론 최소화 원칙
8
+ * 위반이 되기 쉽다. opossum 은 이를 검증된 형태로 제공한다.
9
+ *
10
+ * # 서킷 브레이커란 (중학생용 설명)
11
+ * 외부 서비스(결제 API·DB 등)가 자꾸 실패하면, 매번 똑같이 호출해 봐야 또 실패하고 시간만 낭비된다.
12
+ * 서킷 브레이커는 "최근 실패율이 너무 높다" 싶으면 회로를 **열어**(open) 한동안 아예 호출을 막고
13
+ * 즉시 실패시킨다(빠른 실패). 일정 시간 뒤 **half-open** 상태로 딱 한 번 살짝 찔러보고, 성공하면
14
+ * 다시 **닫아**(closed=정상) 호출을 흘려보낸다. 장애 서비스에 부하를 안 주고, 우리 쪽도 빨리 회복.
15
+ *
16
+ * 상태 3종:
17
+ * - closed : 정상. 호출이 그대로 통과. 실패율을 롤링 윈도우로 집계.
18
+ * - open : 차단. errorThresholdPercentage 초과 → 즉시 거부(`EOPENBREAKER`). 원함수 호출 안 함.
19
+ * - halfOpen : 복구 프로빙. resetTimeout 경과 후 1회 시도 — 성공 시 closed, 실패 시 다시 open.
20
+ *
21
+ * # stateful — `wrap()` 는 재사용 인스턴스를 반환 (MegaRetry 와 다른 점)
22
+ * MegaRetry.withRetry 는 호출 1회를 즉시 실행하는 one-shot 헬퍼다. 서킷 브레이커는 **실패 통계를
23
+ * 누적**해야 동작하므로 그럴 수 없다 — 호출마다 새로 만들면 카운터가 늘 0 이라 절대 trip(open)
24
+ * 되지 않는다. 그래서 `wrap(fn, opts)` 는 보호할 함수 1개당 **하나의 장수(long-lived) 브레이커**를
25
+ * 만들어 반환하고, 이후 모든 호출은 `breaker.fire(...args)` 로 같은 인스턴스를 거친다.
26
+ *
27
+ * # 에러를 묵지 않는다
28
+ * 회로가 열려 거부될 때 `fire()` 는 `code: 'EOPENBREAKER'` 에러로 **reject** 한다(삼키지 않음).
29
+ * 타임아웃은 `ETIMEDOUT`, capacity 초과는 `ESEMLOCKED`. 호출부는 이 코드로 분기해 폴백/재시도 등을
30
+ * **명시적으로** 처리한다. 빠른 폴백이 필요하면 {@link MegaCircuitBreaker#fallback}.
31
+ *
32
+ * # 상태 전이는 관측 가능하게
33
+ * 브레이커는 logger 를 모르는 순수 유틸이라(MegaRetry 와 동일) 직접 로그를 박지 않는다. 대신 상태
34
+ * 전이(`open`/`halfOpen`/`close`)와 호출 결과(`success`/`failure`/`timeout`/`reject`) 이벤트를
35
+ * {@link MegaCircuitBreaker#on} 으로 노출한다 — 소비자(스케줄러·잡·라우트 등)가 이 분기점에
36
+ * `logger.debug/warn` 을 박는다. open/close 는 대표적 "분기점"이다.
37
+ *
38
+ * @module lib/mega-circuit-breaker
39
+ * @see https://nodeshift.dev/opossum/ (opossum 공식 문서)
40
+ * @see ADR-117, ADR-029 (공개 라이브러리 래핑 원칙)
41
+ */
42
+ import CircuitBreaker from 'opossum'
43
+
44
+ /**
45
+ * 회로가 **열려** 호출이 거부됐을 때 `fire()` 가 reject 하는 에러의 `code`. 호출부가
46
+ * `err.code === OPEN_CIRCUIT_ERROR_CODE` 로 "지금은 차단 중" 을 분기 판별할 때 쓴다. (opossum 정본.)
47
+ * @type {'EOPENBREAKER'}
48
+ */
49
+ export const OPEN_CIRCUIT_ERROR_CODE = 'EOPENBREAKER'
50
+
51
+ /**
52
+ * 호출이 `timeout` ms 를 넘겨 실패 처리된 에러의 `code`. (opossum 정본.)
53
+ * @type {'ETIMEDOUT'}
54
+ */
55
+ export const TIMEOUT_ERROR_CODE = 'ETIMEDOUT'
56
+
57
+ /**
58
+ * 동시 호출이 `capacity` 한도를 넘어 거부된 에러의 `code`. (opossum 정본.)
59
+ * @type {'ESEMLOCKED'}
60
+ */
61
+ export const CAPACITY_ERROR_CODE = 'ESEMLOCKED'
62
+
63
+ /**
64
+ * 브레이커 생성 옵션. opossum 옵션 중 **서킷 브레이커 본질에 해당하는 것만** 추려 노출한다(캐시·
65
+ * coalesce 등 부가 기능은 escape hatch {@link MegaCircuitBreaker#raw} 로 직접 접근). 디폴트는
66
+ * opossum 정본값을 **명시적으로** 다시 박는다 — opossum 버전 드리프트와 무관하게 동작을 고정.
67
+ *
68
+ * @typedef {Object} MegaCircuitBreakerOptions
69
+ * @property {number} [timeout=10000] - 단일 호출 제한 시간(ms). 초과 시 `ETIMEDOUT` 으로 실패 처리.
70
+ * `false`(또는 0)면 타임아웃 끔.
71
+ * @property {number} [errorThresholdPercentage=50] - 롤링 윈도우 실패율(%)이 이 값을 넘으면 회로 open.
72
+ * @property {number} [resetTimeout=30000] - open 상태 유지 시간(ms). 경과 후 halfOpen 으로 1회 프로빙.
73
+ * @property {number} [rollingCountTimeout=10000] - 실패율 집계 롤링 윈도우 길이(ms).
74
+ * @property {number} [rollingCountBuckets=10] - 롤링 윈도우를 나누는 버킷 수.
75
+ * @property {number} [volumeThreshold=0] - 이 횟수만큼 호출이 쌓이기 전엔 실패율이 높아도 open 안 함
76
+ * (표본 부족으로 인한 조기 trip 방지). 0=비활성.
77
+ * @property {number} [capacity] - 동시 진행(in-flight) 호출 상한. 초과분은 `ESEMLOCKED` 로 즉시 거부.
78
+ * 미지정=무제한.
79
+ * @property {(err: any, ...args: any[]) => boolean} [errorFilter] - `true` 반환 시 그 에러는 **실패로 집계하지 않음**
80
+ * (예: 4xx 같은 "정상적 거절"). 회로 trip 대상에서 제외.
81
+ * @property {string} [name] - 브레이커 이름(이벤트·stats 식별용). 미지정 시 `fn.name || 'anonymous'`.
82
+ */
83
+
84
+ /**
85
+ * 브레이커 통계 스냅샷({@link MegaCircuitBreaker#getStats} 반환). opossum `status.stats` 의 핵심
86
+ * 카운터만 추려 평탄화한다(핵심 필드만, 페이로드·시크릿 없음).
87
+ *
88
+ * @typedef {Object} MegaCircuitBreakerStats
89
+ * @property {string} name - 브레이커 이름.
90
+ * @property {'closed'|'open'|'halfOpen'|'shutdown'} state - 현재 상태. shutdown 후엔 `'shutdown'`.
91
+ * @property {number} fires - 누적 호출 시도 수.
92
+ * @property {number} successes - 성공 수.
93
+ * @property {number} failures - 실패 수.
94
+ * @property {number} timeouts - 타임아웃 수.
95
+ * @property {number} rejects - 회로 open 으로 거부된 수.
96
+ * @property {number} fallbacks - 폴백이 실행된 수.
97
+ */
98
+
99
+ /** opossum 이벤트명 화이트리스트 — 오타 방지 + 문서화(소비자가 구독 가능한 길목). */
100
+ const KNOWN_EVENTS = Object.freeze([
101
+ 'fire', // 매 호출 시작
102
+ 'success', // 호출 성공
103
+ 'failure', // 호출 실패(집계 대상)
104
+ 'timeout', // timeout 초과
105
+ 'reject', // 회로 open 으로 거부
106
+ 'open', // 회로 열림(차단 시작) — 분기점
107
+ 'halfOpen', // 복구 프로빙 진입 — 분기점
108
+ 'close', // 회로 닫힘(정상 복귀) — 분기점
109
+ 'fallback', // 폴백 실행
110
+ 'semaphoreLocked', // capacity 초과 거부
111
+ 'healthCheckFailed', // healthCheck 함수 실패
112
+ 'shutdown', // 브레이커 종료
113
+ ])
114
+
115
+ /**
116
+ * 외부 호출을 서킷 브레이커로 감싸는 래퍼. opossum `CircuitBreaker` 인스턴스 1개를 보유하고, 우리
117
+ * 컨벤션(Boolean `is*` 게터·`getStats()`·이벤트 화이트리스트)으로 표면을 정리한다.
118
+ *
119
+ * @example
120
+ * const breaker = new MegaCircuitBreaker(callPaymentApi, { timeout: 3000, errorThresholdPercentage: 50 })
121
+ * breaker.on('open', () => log.warn('payment circuit open'))
122
+ * try {
123
+ * const res = await breaker.fire(orderId)
124
+ * } catch (e) {
125
+ * if (e.code === OPEN_CIRCUIT_ERROR_CODE) return useCachedQuote() // 명시적 폴백
126
+ * throw e
127
+ * }
128
+ */
129
+ export class MegaCircuitBreaker {
130
+ /** @type {import('opossum')<any[], any>} 내부 opossum 브레이커. */
131
+ #breaker
132
+
133
+ /**
134
+ * @param {(...args: any[]) => Promise<any>} fn - 보호할 비동기 함수(외부 호출).
135
+ * @param {MegaCircuitBreakerOptions} [options] - 브레이커 옵션.
136
+ * @throws {TypeError} `fn` 이 함수가 아니면.
137
+ */
138
+ constructor(fn, options = {}) {
139
+ if (typeof fn !== 'function') {
140
+ // 잘못된 사용은 부팅 시점에 즉시 드러나게(추측 진행 금지).
141
+ throw new TypeError(
142
+ `MegaCircuitBreaker(fn, opts) — fn must be a function (the async call to protect). Got: ${typeof fn}.`,
143
+ )
144
+ }
145
+
146
+ const {
147
+ timeout = 10_000,
148
+ errorThresholdPercentage = 50,
149
+ resetTimeout = 30_000,
150
+ rollingCountTimeout = 10_000,
151
+ rollingCountBuckets = 10,
152
+ volumeThreshold = 0,
153
+ capacity,
154
+ errorFilter,
155
+ name,
156
+ } = options
157
+
158
+ // 디폴트를 명시 리터럴로 구성(opossum 정본값 1:1) — 버전 드리프트와 무관하게 동작 고정.
159
+ // 선택 옵션(capacity/errorFilter/name)은 있을 때만 실어 opossum 기본 동작을 유지한다.
160
+ /** @type {import('opossum').Options} */
161
+ const opts = {
162
+ timeout,
163
+ errorThresholdPercentage,
164
+ resetTimeout,
165
+ rollingCountTimeout,
166
+ rollingCountBuckets,
167
+ volumeThreshold,
168
+ // 빈 문자열(익명 화살표의 fn.name='')도 'anonymous' 로 대체 — `||` 가 falsy 전부 걸러냄.
169
+ // (`??` 는 null/undefined 만 걸러 '' 가 통과 → JSDoc 약속과 어긋남.)
170
+ name: name ?? (fn.name || 'anonymous'),
171
+ ...(typeof capacity === 'number' ? { capacity } : {}),
172
+ ...(typeof errorFilter === 'function' ? { errorFilter } : {}),
173
+ }
174
+
175
+ this.#breaker = new CircuitBreaker(fn, opts)
176
+ }
177
+
178
+ /**
179
+ * 보호된 함수를 호출한다. 회로가 closed/halfOpen 이면 원함수를 실행하고, open 이면 원함수를 건너뛰고
180
+ * 즉시 `EOPENBREAKER` 로 reject 한다(빠른 실패). 결과/에러는 **그대로 전파**(삼키지 않음).
181
+ *
182
+ * @param {...any} args - 원함수에 그대로 전달할 인자.
183
+ * @returns {Promise<any>} 원함수의 반환값. 거부/타임아웃/실패 시 reject(에러 `code` 로 분기).
184
+ */
185
+ fire(...args) {
186
+ return this.#breaker.fire(...args)
187
+ }
188
+
189
+ /**
190
+ * 회로가 열려 있거나 호출이 실패할 때 대신 실행할 폴백을 등록한다. 폴백이 있으면 `fire()` 는 에러로
191
+ * reject 하지 않고 폴백 반환값으로 resolve 한다(`fallback` 이벤트 발화).
192
+ *
193
+ * @param {(...args: any[]) => any} fn - 폴백 함수(동기/비동기 모두 허용). 원호출 인자를 그대로 받는다.
194
+ * @returns {this} 체이닝용.
195
+ */
196
+ fallback(fn) {
197
+ this.#breaker.fallback(fn)
198
+ return this
199
+ }
200
+
201
+ /**
202
+ * 이벤트명 화이트리스트 검증. 모든 구독/해제 메서드가 공유 — 오타 이벤트명을 조용히 무시하지
203
+ * 않고 즉시 RangeError 로 드러낸다(L-1: on/off 뿐 아니라 once/addListener/prepend* 도 동일 보호).
204
+ * @param {string} method - 호출된 메서드명(에러 메시지용).
205
+ * @param {string} event - 검증할 이벤트명.
206
+ * @returns {void}
207
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
208
+ */
209
+ #assertKnownEvent(method, event) {
210
+ if (!KNOWN_EVENTS.includes(event)) {
211
+ throw new RangeError(
212
+ `MegaCircuitBreaker.${method}('${event}', ...) — unknown event. Known: ${KNOWN_EVENTS.join(', ')}.`,
213
+ )
214
+ }
215
+ }
216
+
217
+ /**
218
+ * 화이트리스트 검증 후 내부 opossum 브레이커(EventEmitter)로 위임한다. opossum 의 on/once 등은
219
+ * 이벤트별 오버로드라 유니온 event 가 단일 오버로드에 안 맞는다 — 런타임 화이트리스트로 이미
220
+ * 검증했으므로 베이스 EventEmitter 시그니처로 안전하게 상위 캐스트해 위임한다.
221
+ * @param {'on'|'off'|'once'|'addListener'|'removeListener'|'prependListener'|'prependOnceListener'} method
222
+ * @param {string} event @param {(...args: any[]) => void} listener @returns {this}
223
+ */
224
+ #delegateEvent(method, event, listener) {
225
+ this.#assertKnownEvent(method, event)
226
+ const emitter = /** @type {import('node:events').EventEmitter} */ (this.#breaker)
227
+ emitter[method](event, listener)
228
+ return this
229
+ }
230
+
231
+ /**
232
+ * 브레이커 이벤트를 구독한다(소비자가 분기점에 로그를 박는 지점). 알 수 없는 이벤트명은 오타로
233
+ * 보고 throw 한다(조용히 무시 금지).
234
+ *
235
+ * @param {'fire'|'success'|'failure'|'timeout'|'reject'|'open'|'halfOpen'|'close'|'fallback'|'semaphoreLocked'|'healthCheckFailed'|'shutdown'} event
236
+ * @param {(...args: any[]) => void} listener
237
+ * @returns {this} 체이닝용.
238
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
239
+ */
240
+ on(event, listener) {
241
+ return this.#delegateEvent('on', event, listener)
242
+ }
243
+
244
+ /**
245
+ * {@link MegaCircuitBreaker#on} 으로 등록한 리스너를 해제한다. 알 수 없는 이벤트명은 오타로 보고
246
+ * throw 한다 — `on()` 과 동일한 화이트리스트. 검증 없이 위임하면 오타 이벤트명이 조용히 매칭
247
+ * 실패해 "해제했다고 믿지만 리스너가 그대로 살아있는" 디버깅 지옥을 만든다. (`removeListener` 별칭도 보호.)
248
+ *
249
+ * @param {'fire'|'success'|'failure'|'timeout'|'reject'|'open'|'halfOpen'|'close'|'fallback'|'semaphoreLocked'|'healthCheckFailed'|'shutdown'} event
250
+ * @param {(...args: any[]) => void} listener
251
+ * @returns {this} 체이닝용.
252
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
253
+ */
254
+ off(event, listener) {
255
+ return this.#delegateEvent('off', event, listener)
256
+ }
257
+
258
+ /**
259
+ * 1회성 구독(EventEmitter `once`). on() 과 동일 화이트리스트(L-1).
260
+ * @param {'fire'|'success'|'failure'|'timeout'|'reject'|'open'|'halfOpen'|'close'|'fallback'|'semaphoreLocked'|'healthCheckFailed'|'shutdown'} event
261
+ * @param {(...args: any[]) => void} listener @returns {this}
262
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
263
+ */
264
+ once(event, listener) {
265
+ return this.#delegateEvent('once', event, listener)
266
+ }
267
+
268
+ /**
269
+ * `on` 의 EventEmitter 별칭. 우회로 화이트리스트를 비껴가지 않도록 동일 검증(L-1).
270
+ * @param {'fire'|'success'|'failure'|'timeout'|'reject'|'open'|'halfOpen'|'close'|'fallback'|'semaphoreLocked'|'healthCheckFailed'|'shutdown'} event
271
+ * @param {(...args: any[]) => void} listener @returns {this}
272
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
273
+ */
274
+ addListener(event, listener) {
275
+ return this.#delegateEvent('addListener', event, listener)
276
+ }
277
+
278
+ /**
279
+ * `off` 의 EventEmitter 별칭. 동일 화이트리스트 검증(L-1).
280
+ * @param {'fire'|'success'|'failure'|'timeout'|'reject'|'open'|'halfOpen'|'close'|'fallback'|'semaphoreLocked'|'healthCheckFailed'|'shutdown'} event
281
+ * @param {(...args: any[]) => void} listener @returns {this}
282
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
283
+ */
284
+ removeListener(event, listener) {
285
+ return this.#delegateEvent('removeListener', event, listener)
286
+ }
287
+
288
+ /**
289
+ * 리스너를 큐 앞에 추가(EventEmitter `prependListener`). 동일 화이트리스트 검증(L-1).
290
+ * @param {'fire'|'success'|'failure'|'timeout'|'reject'|'open'|'halfOpen'|'close'|'fallback'|'semaphoreLocked'|'healthCheckFailed'|'shutdown'} event
291
+ * @param {(...args: any[]) => void} listener @returns {this}
292
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
293
+ */
294
+ prependListener(event, listener) {
295
+ return this.#delegateEvent('prependListener', event, listener)
296
+ }
297
+
298
+ /**
299
+ * 1회성 리스너를 큐 앞에 추가(EventEmitter `prependOnceListener`). 동일 화이트리스트 검증(L-1).
300
+ * @param {'fire'|'success'|'failure'|'timeout'|'reject'|'open'|'halfOpen'|'close'|'fallback'|'semaphoreLocked'|'healthCheckFailed'|'shutdown'} event
301
+ * @param {(...args: any[]) => void} listener @returns {this}
302
+ * @throws {RangeError} 알 수 없는 이벤트명일 때.
303
+ */
304
+ prependOnceListener(event, listener) {
305
+ return this.#delegateEvent('prependOnceListener', event, listener)
306
+ }
307
+
308
+ /** 회로를 강제로 연다(차단). 테스트/운영 수동 개입용. */
309
+ open() {
310
+ this.#breaker.open()
311
+ }
312
+
313
+ /** 회로를 강제로 닫는다(정상 복귀). 카운터를 비우고 호출을 다시 흘려보낸다. */
314
+ close() {
315
+ this.#breaker.close()
316
+ }
317
+
318
+ /** 브레이커를 비활성화한다 — 회로 로직을 우회하고 원함수를 항상 그대로 호출. */
319
+ disable() {
320
+ this.#breaker.disable()
321
+ }
322
+
323
+ /** {@link MegaCircuitBreaker#disable} 를 되돌려 회로 로직을 다시 활성화한다. */
324
+ enable() {
325
+ this.#breaker.enable()
326
+ }
327
+
328
+ /**
329
+ * 브레이커를 영구 종료한다 — 내부 롤링 윈도우 타이머를 정리한다. **테스트/graceful shutdown 시 필수**
330
+ * (호출 안 하면 setInterval 타이머가 남아 프로세스/테스트 종료를 막는다).
331
+ */
332
+ shutdown() {
333
+ this.#breaker.shutdown()
334
+ }
335
+
336
+ /** @returns {boolean} 회로가 열려(차단) 있으면 true. */
337
+ get isOpen() {
338
+ return this.#breaker.opened
339
+ }
340
+
341
+ /** @returns {boolean} 회로가 닫혀(정상) 있으면 true. */
342
+ get isClosed() {
343
+ return this.#breaker.closed
344
+ }
345
+
346
+ /** @returns {boolean} 복구 프로빙(half-open) 중이면 true. */
347
+ get isHalfOpen() {
348
+ return this.#breaker.halfOpen
349
+ }
350
+
351
+ /** @returns {boolean} 브레이커가 활성(enable) 상태면 true. */
352
+ get isEnabled() {
353
+ return this.#breaker.enabled
354
+ }
355
+
356
+ /** @returns {boolean} 브레이커가 종료(shutdown)됐으면 true. */
357
+ get isShutdown() {
358
+ return this.#breaker.isShutdown
359
+ }
360
+
361
+ /** @returns {string} 브레이커 이름. */
362
+ get name() {
363
+ return this.#breaker.name
364
+ }
365
+
366
+ /**
367
+ * 통계 스냅샷을 평탄화해 반환한다(핵심 카운터만). 모니터링/디버그 로그용.
368
+ * @returns {MegaCircuitBreakerStats}
369
+ */
370
+ getStats() {
371
+ const s = this.#breaker.stats
372
+ // shutdown 은 opened/halfOpen/closed 와 별개 종단 상태라 먼저 판별(종료 후 state 정확화, L-2).
373
+ const state = this.#breaker.isShutdown
374
+ ? 'shutdown'
375
+ : this.#breaker.opened
376
+ ? 'open'
377
+ : this.#breaker.halfOpen
378
+ ? 'halfOpen'
379
+ : 'closed'
380
+ return {
381
+ name: this.#breaker.name,
382
+ state,
383
+ fires: s.fires,
384
+ successes: s.successes,
385
+ failures: s.failures,
386
+ timeouts: s.timeouts,
387
+ rejects: s.rejects,
388
+ fallbacks: s.fallbacks,
389
+ }
390
+ }
391
+
392
+ /**
393
+ * 내부 opossum 브레이커 raw 핸들(escape hatch). 여기서 노출하지 않은 opossum 고급 기능(캐시·
394
+ * coalesce·healthCheck·snapshot 등)이 필요할 때만 직접 접근한다. 어댑터의 `.native` 와 같은 취지.
395
+ * @returns {import('opossum')<any[], any>}
396
+ */
397
+ get raw() {
398
+ return this.#breaker
399
+ }
400
+ }
401
+
402
+ /**
403
+ * 외부 호출을 서킷 브레이커로 감싸 **재사용 가능한** 브레이커를 만든다(보호 함수 1개당 1 브레이커).
404
+ * `new MegaCircuitBreaker(fn, opts)` 의 함수형 별칭 — 다른 Mega* 유틸(`withRetry`)과 호출 모양 일관.
405
+ *
406
+ * @param {(...args: any[]) => Promise<any>} fn - 보호할 비동기 함수.
407
+ * @param {MegaCircuitBreakerOptions} [options] - 브레이커 옵션.
408
+ * @returns {MegaCircuitBreaker} 장수(long-lived) 브레이커. 이후 `.fire(...args)` 로 호출.
409
+ */
410
+ export function wrap(fn, options = {}) {
411
+ return new MegaCircuitBreaker(fn, options)
412
+ }