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,322 @@
1
+ // @ts-check
2
+ /**
3
+ * Bridge ↔ Hub 12-타입 프로토콜 카탈로그 (04-data-models §7 정본).
4
+ *
5
+ * Bridge(=mega server) 와 Hub(=`mega ws-hub`) 사이는 WebSocket 위에 §6.2 envelope 을 얹어
6
+ * 통신한다 (ADR-033). 메시지 `type` 은 아래 **12 종** 중 하나다. 이 모듈은 그 12 타입의
7
+ * 상수·payload JSON Schema·검증 함수를 한곳에 모은다.
8
+ *
9
+ * # type 네이밍 — ADR-097
10
+ * 04-data-models §7 의 카탈로그는 논리명을 UPPERCASE(`REGISTER` 등)로 적지만, 모든 WS envelope
11
+ * 의 `type` 은 §6.2(ADR-015)의 `domain.action` 패턴({@link WS_TYPE_PATTERN}, 소문자+점)을 따라야
12
+ * 한다. 둘이 충돌하므로 **오너 결정(ADR-097)** 으로 wire `type` 은 `hub.<lower>` 로 못박는다
13
+ * (`REGISTER` → `hub.register`). 논리명↔wire 매핑은 {@link HUB_MESSAGE_TYPES}. 이로써 hub 메시지도
14
+ * §6.2 검증(`validateWsMessage`)을 그대로 통과한다.
15
+ *
16
+ * # ASP 무관 (ADR-059)
17
+ * 12 타입은 모두 **평문**으로 hub link 를 흐른다. hub link 는 Bearer 토큰 + timing-safe 비교 +
18
+ * 내부망 TLS 로 보호하며 ASP 암복호화는 bridge 가 종단(클라↔bridge 구간)한다. 따라서 본 모듈은
19
+ * 암호화에 관여하지 않는다.
20
+ *
21
+ * # ERROR 의 ErrorObject 위치
22
+ * §7 은 `ERROR` payload 를 §6.3 `ErrorObject` 라 적지만, §6.2 envelope 엔 이미 전용 `error` 필드가
23
+ * 있고 그 스키마가 §6.3 그대로다. 프레임워크 기존 관례(ws-upgrade `sendError`, error-mapper)와
24
+ * 정합을 위해 `hub.error` 의 ErrorObject 는 envelope 의 **`error` 필드**에 싣는다(payload 아님,
25
+ * ADR-097 에 명시).
26
+ *
27
+ * @module lib/hub-protocol
28
+ */
29
+
30
+ /**
31
+ * hub 메시지 envelope (§6.2 + hub 타입). payload/error 는 타입별로 다르므로 any 로 둔다.
32
+ * @typedef {{ v: number, id: string, type: string, ts: number, ns?: string, payload?: any, error?: any, ref?: string }} HubEnvelope
33
+ */
34
+ import { Ajv } from 'ajv'
35
+ import {
36
+ createWsMessage,
37
+ validateWsMessage,
38
+ WS_TYPE_PATTERN,
39
+ } from '../core/ws-message.js'
40
+
41
+ /**
42
+ * 논리명(04 §7 UPPERCASE) → wire `type`(`hub.<lower>`, ADR-097) 매핑.
43
+ * 키는 04-data-models §7 표의 타입명과 1:1. 값은 §6.2 패턴을 통과하는 소문자-점 형식.
44
+ * @type {Readonly<Record<string, string>>}
45
+ */
46
+ export const HUB_MESSAGE_TYPES = Object.freeze({
47
+ REGISTER: 'hub.register',
48
+ REGISTER_OK: 'hub.register_ok',
49
+ JOIN: 'hub.join',
50
+ LEAVE: 'hub.leave',
51
+ BULK_LEAVE: 'hub.bulk_leave',
52
+ BROADCAST: 'hub.broadcast',
53
+ DIRECT: 'hub.direct',
54
+ METADATA: 'hub.metadata',
55
+ DISCONNECT: 'hub.disconnect',
56
+ BINARY: 'hub.binary',
57
+ HEARTBEAT: 'hub.heartbeat',
58
+ ERROR: 'hub.error',
59
+ })
60
+
61
+ /** 12 개 wire `type` 문자열 집합 (빠른 소속 판별용). @type {ReadonlySet<string>} */
62
+ export const HUB_TYPE_SET = new Set(Object.values(HUB_MESSAGE_TYPES))
63
+
64
+ /**
65
+ * bridge↔hub WebSocket close code 카탈로그 (ADR-098).
66
+ *
67
+ * 클라↔bridge 의 close code(4500 decrypt / 1011 internal, {@link import('../core/ws-upgrade.js')})
68
+ * 와 **다른 평면**이다 — 이쪽은 bridge↔hub 전송계층(plaintext+Bearer, ADR-059) 전용.
69
+ *
70
+ * - `1001` GOING_AWAY — 일반 graceful shutdown(테스트/명시 stop). 재연결 의무 없음.
71
+ * - `1008` UNAUTHORIZED — REGISTER Bearer 불일치(RFC 6455 policy violation, ADR-097).
72
+ * - `4503` DRAIN — hub 가 의도적으로 비우는 중. bridge 는 **다른 hub 인스턴스로 재연결**해야 한다
73
+ * (LB/mesh 회전). ADR-097 카탈로그가 예약했던 코드를 활성화.
74
+ * @type {Readonly<Record<string, number>>}
75
+ */
76
+ export const HUB_CLOSE_CODES = Object.freeze({
77
+ GOING_AWAY: 1001,
78
+ UNAUTHORIZED: 1008,
79
+ DRAIN: 4503,
80
+ })
81
+
82
+ /** hub drain close code(4503). bridge 가 이 코드를 받으면 재연결 대상으로 간주(ADR-098). */
83
+ export const CLOSE_CODE_DRAIN = HUB_CLOSE_CODES.DRAIN
84
+
85
+ /** hub.error 의 `error` 필드 = §6.3 ErrorObject. 재사용을 위해 단독 정의. */
86
+ const ERROR_OBJECT_SCHEMA = {
87
+ type: 'object',
88
+ required: ['code', 'message'],
89
+ properties: {
90
+ code: { type: 'string', pattern: WS_TYPE_PATTERN.source },
91
+ message: { type: 'string' },
92
+ details: {},
93
+ },
94
+ additionalProperties: false,
95
+ }
96
+
97
+ /**
98
+ * 타입별 payload JSON Schema (04-data-models §7 그대로). AJV 컴파일 대상.
99
+ * `hub.error` 는 payload 가 아니라 envelope `error` 로 검증하므로 여기엔 없다(아래 별도 처리).
100
+ * @type {Readonly<Record<string, Object>>}
101
+ */
102
+ export const HUB_PAYLOAD_SCHEMAS = Object.freeze({
103
+ [HUB_MESSAGE_TYPES.REGISTER]: {
104
+ type: 'object',
105
+ required: ['instanceId', 'token', 'capabilities'],
106
+ properties: {
107
+ instanceId: { type: 'string', minLength: 1 },
108
+ token: { type: 'string', minLength: 1 },
109
+ capabilities: { type: 'array', items: { type: 'string' } },
110
+ },
111
+ additionalProperties: false,
112
+ },
113
+ [HUB_MESSAGE_TYPES.REGISTER_OK]: {
114
+ type: 'object',
115
+ required: ['hubId', 'acceptedAt', 'heartbeatMs'],
116
+ properties: {
117
+ hubId: { type: 'string', minLength: 1 },
118
+ acceptedAt: { type: 'integer' },
119
+ heartbeatMs: { type: 'integer', minimum: 1 },
120
+ },
121
+ additionalProperties: false,
122
+ },
123
+ [HUB_MESSAGE_TYPES.JOIN]: {
124
+ type: 'object',
125
+ required: ['userId', 'sessionId', 'channels'],
126
+ properties: {
127
+ userId: { type: 'string', minLength: 1 },
128
+ sessionId: { type: 'string', minLength: 1 },
129
+ channels: { type: 'array', items: { type: 'string' } },
130
+ metadata: { type: 'object' },
131
+ },
132
+ additionalProperties: false,
133
+ },
134
+ [HUB_MESSAGE_TYPES.LEAVE]: {
135
+ type: 'object',
136
+ required: ['sessionId'],
137
+ properties: { sessionId: { type: 'string', minLength: 1 } },
138
+ additionalProperties: false,
139
+ },
140
+ [HUB_MESSAGE_TYPES.BULK_LEAVE]: {
141
+ type: 'object',
142
+ required: ['sessionIds'],
143
+ properties: { sessionIds: { type: 'array', items: { type: 'string' } } },
144
+ additionalProperties: false,
145
+ },
146
+ [HUB_MESSAGE_TYPES.BROADCAST]: {
147
+ type: 'object',
148
+ required: ['ns', 'channel', 'message'],
149
+ properties: {
150
+ ns: { type: 'string', minLength: 1 },
151
+ channel: { type: 'string', minLength: 1 },
152
+ message: { type: 'object' },
153
+ // exceptSessionIds: wire 호환을 위해 schema 는 유지하나, 세션단위 fan-out 전까지
154
+ // hub fan-out 어디서도 읽지 않는 **no-op** 이다(ADR-097). bridge 는 채널 단위로만 구독하므로
155
+ // hub 가 개별 세션을 제외하려면 세션↔소켓 매핑(인증·세션 DI 연동)이 선행돼야 한다.
156
+ exceptSessionIds: { type: 'array', items: { type: 'string' } },
157
+ },
158
+ additionalProperties: false,
159
+ },
160
+ [HUB_MESSAGE_TYPES.DIRECT]: {
161
+ type: 'object',
162
+ required: ['userId', 'message'],
163
+ properties: {
164
+ userId: { type: 'string', minLength: 1 },
165
+ message: { type: 'object' },
166
+ },
167
+ additionalProperties: false,
168
+ },
169
+ [HUB_MESSAGE_TYPES.METADATA]: {
170
+ type: 'object',
171
+ required: ['sessionId', 'metadata'],
172
+ properties: {
173
+ sessionId: { type: 'string', minLength: 1 },
174
+ metadata: { type: 'object' },
175
+ },
176
+ additionalProperties: false,
177
+ },
178
+ [HUB_MESSAGE_TYPES.DISCONNECT]: {
179
+ type: 'object',
180
+ required: ['sessionId', 'reason'],
181
+ properties: {
182
+ sessionId: { type: 'string', minLength: 1 },
183
+ reason: { type: 'string' },
184
+ requeue: { type: 'boolean' },
185
+ },
186
+ additionalProperties: false,
187
+ },
188
+ [HUB_MESSAGE_TYPES.BINARY]: {
189
+ type: 'object',
190
+ required: ['ref', 'mimeType', 'bytes'],
191
+ properties: {
192
+ ref: { type: 'string', minLength: 1 },
193
+ mimeType: { type: 'string', minLength: 1 },
194
+ bytes: { type: 'integer', minimum: 0 },
195
+ },
196
+ additionalProperties: false,
197
+ },
198
+ [HUB_MESSAGE_TYPES.HEARTBEAT]: {
199
+ type: 'object',
200
+ required: ['at'],
201
+ properties: {
202
+ at: { type: 'integer' },
203
+ pendingDeliveries: { type: 'integer', minimum: 0 },
204
+ },
205
+ additionalProperties: false,
206
+ },
207
+ })
208
+
209
+ // 부팅 시 1회 컴파일 (요청 경로에서 컴파일 비용 회피). allErrors=true 로 모든 위반 수집.
210
+ const hubAjv = new Ajv({ allErrors: true })
211
+
212
+ /** type(wire) → 컴파일된 payload 검증 함수. @type {Record<string, import('ajv').ValidateFunction>} */
213
+ const PAYLOAD_VALIDATORS = {}
214
+ for (const [type, schema] of Object.entries(HUB_PAYLOAD_SCHEMAS)) {
215
+ PAYLOAD_VALIDATORS[type] = hubAjv.compile(schema)
216
+ }
217
+ const ERROR_OBJECT_VALIDATOR = hubAjv.compile(ERROR_OBJECT_SCHEMA)
218
+
219
+ /**
220
+ * AJV 에러 배열 → 사람이 읽는 사유 문자열 배열. validateHubMessage 의 결과 누적용.
221
+ * @param {import('ajv').ErrorObject[] | null | undefined} errors
222
+ * @param {string} prefix - 'payload' | 'error' 등 위치 라벨.
223
+ * @returns {string[]}
224
+ */
225
+ function ajvErrorsToStrings(errors, prefix) {
226
+ if (!Array.isArray(errors)) return []
227
+ return errors.map((e) => {
228
+ const where = e.instancePath ? `${prefix}${e.instancePath}` : prefix
229
+ return `${where} ${e.message || 'invalid'}`
230
+ })
231
+ }
232
+
233
+ /**
234
+ * hub 메시지 전체 검증 — envelope(§6.2) + type 소속(12 종) + payload/error 스키마.
235
+ *
236
+ * 던지지 않고 위반 사유 배열을 돌려준다(빈 배열 = 유효). 호출부(hub/link)가 사유를 그대로
237
+ * `hub.error` envelope 의 `details` 로 실어 보낼 수 있도록 한다(ADR-075 배열 표준).
238
+ *
239
+ * @param {any} msg - 파싱된 envelope 객체.
240
+ * @returns {string[]} 위반 사유 목록(없으면 빈 배열).
241
+ */
242
+ export function validateHubMessage(msg) {
243
+ // 1) envelope(§6.2) 검증을 먼저 위임 — v/id/ts/type 패턴/허용키 등.
244
+ const errors = validateWsMessage(msg)
245
+ // type 자체가 깨졌으면(객체 아님 등) 여기서 종료 — payload 검증 의미 없음.
246
+ if (!msg || typeof msg !== 'object') return errors
247
+
248
+ // 2) type 이 12 종 hub 타입인지.
249
+ const type = msg.type
250
+ if (typeof type === 'string' && !HUB_TYPE_SET.has(type)) {
251
+ errors.push(`type must be one of the 12 hub protocol types (got '${type}')`)
252
+ return errors
253
+ }
254
+
255
+ // 3) hub.error 는 envelope.error(=§6.3 ErrorObject) 를 검증. payload 는 사용 안 함.
256
+ if (type === HUB_MESSAGE_TYPES.ERROR) {
257
+ if (msg.error === undefined) {
258
+ errors.push('error is required for hub.error (§6.3 ErrorObject)')
259
+ } else if (!ERROR_OBJECT_VALIDATOR(msg.error)) {
260
+ errors.push(...ajvErrorsToStrings(ERROR_OBJECT_VALIDATOR.errors, 'error'))
261
+ }
262
+ return errors
263
+ }
264
+
265
+ // 4) 나머지 11 종은 payload 를 타입별 스키마로 검증.
266
+ const validate = typeof type === 'string' ? PAYLOAD_VALIDATORS[type] : undefined
267
+ if (validate) {
268
+ if (msg.payload === undefined) {
269
+ errors.push(`payload is required for ${type}`)
270
+ } else if (!validate(msg.payload)) {
271
+ errors.push(...ajvErrorsToStrings(validate.errors, 'payload'))
272
+ }
273
+ }
274
+ return errors
275
+ }
276
+
277
+ /**
278
+ * hub 메시지 envelope 생성. {@link createWsMessage} 위임 — v/id/ts 자동.
279
+ * wire `type` 은 반드시 {@link HUB_MESSAGE_TYPES} 의 값(`hub.*`) 이어야 한다.
280
+ *
281
+ * @param {Object} fields
282
+ * @param {string} fields.type - wire type(`hub.*`). 12 종 아님 → throw.
283
+ * @param {string} [fields.ns]
284
+ * @param {Object} [fields.payload]
285
+ * @param {Object} [fields.error] - hub.error 의 §6.3 ErrorObject.
286
+ * @param {string} [fields.ref]
287
+ * @param {{ id?: string, ts?: number }} [opts] - 테스트 주입용.
288
+ * @returns {{ v: number, id: string, type: string, ts: number }}
289
+ * @throws {Error} type 이 12 종 hub 타입이 아님.
290
+ */
291
+ export function createHubMessage(fields, opts = {}) {
292
+ if (!fields || !HUB_TYPE_SET.has(fields.type)) {
293
+ throw new Error(
294
+ `createHubMessage: 'type' must be one of the 12 hub types (ADR-097). Got: ${JSON.stringify(fields?.type)}`,
295
+ )
296
+ }
297
+ return createWsMessage(fields, opts)
298
+ }
299
+
300
+ /**
301
+ * JSON 문자열 → 검증된 hub 메시지 envelope. 파싱/검증 실패 시 throw(silent 금지).
302
+ *
303
+ * @param {string} json - hub link 평문 JSON.
304
+ * @returns {HubEnvelope} 검증 통과한 envelope.
305
+ * @throws {Error} JSON 파싱 실패 또는 hub 프로토콜 위반(메시지에 사유 포함).
306
+ */
307
+ export function parseHubMessage(json) {
308
+ let obj
309
+ try {
310
+ obj = JSON.parse(json)
311
+ } catch (err) {
312
+ throw new Error(
313
+ `parseHubMessage: invalid JSON — ${err instanceof Error ? err.message : String(err)}`,
314
+ { cause: err },
315
+ )
316
+ }
317
+ const errors = validateHubMessage(obj)
318
+ if (errors.length > 0) {
319
+ throw new Error(`parseHubMessage: invalid hub message — ${errors.join('; ')}`)
320
+ }
321
+ return obj
322
+ }
@@ -0,0 +1,42 @@
1
+ // @ts-check
2
+ export * as MegaHealth from './mega-health.js'
3
+ export { MegaShutdown } from './mega-shutdown.js'
4
+ // 재시도 백오프 유틸 (p-retry 래퍼, ADR-029/098)
5
+ export { MegaRetry, withRetry, RetryAbortError } from './mega-retry.js'
6
+ // 서킷 브레이커 — 외부 호출 보호 (opossum 래퍼, ADR-029/117)
7
+ export {
8
+ MegaCircuitBreaker,
9
+ wrap as wrapCircuitBreaker,
10
+ OPEN_CIRCUIT_ERROR_CODE,
11
+ TIMEOUT_ERROR_CODE,
12
+ CAPACITY_ERROR_CODE,
13
+ } from './mega-circuit-breaker.js'
14
+ // cron 파서 + cron 기반 스케줄러(분산 중복방지) (croner 래퍼, ADR-029/118)
15
+ export { MegaCron } from './mega-cron.js'
16
+ export { MegaSchedule, MegaScheduler } from './mega-schedule.js'
17
+ // NATS JetStream 잡 큐 — 영속 큐 + 재시도 + DLQ (JetStream 래퍼, ADR-029/119)
18
+ export { MegaJob, resolveJobRetryConfig, JOB_RETRY_DEFAULTS } from './mega-job.js'
19
+ export { MegaJobQueue } from './mega-job-queue.js'
20
+ // 잡 소비 워커 런타임 — 잡 등록 + bus 배선 + consume 라이프사이클 (ADR-120)
21
+ export { MegaJobWorker } from './mega-job-worker.js'
22
+ // CPU 워커 풀(정본) — worker_threads/child_process 격리 + ctx.workers.<name>.run(task) (ADR-121/124)
23
+ export { MegaWorker } from './mega-worker.js'
24
+ // 인증 보안 묶음 — 비밀번호 해싱(scrypt) + brute-force 차단 (ADR-049/050/130)
25
+ export { MegaHash } from './mega-hash.js'
26
+ export { MegaBruteForce } from './mega-brute-force.js'
27
+ // 플러그인 시스템 — install(mega) 패턴 + apiVersion 호환 (ADR-079/122)
28
+ export { MegaPluginHost, loadPlugins, CORE_API_VERSION } from './mega-plugin.js'
29
+ // .env → 어댑터 옵션 자동 매핑 (12-factor, ADR-109)
30
+ export { buildAdapterEnvConfig } from './env-mapper.js'
31
+ // OpenTelemetry 분산 트레이싱 옵트인 (ADR-077 hook 위 자동 span, ADR-114)
32
+ export * as MegaTracing from './mega-tracing.js'
33
+ // OpenTelemetry 메트릭 + Prometheus /metrics 옵트인 (ADR-072/131)
34
+ export * as MegaMetrics from './mega-metrics.js'
35
+
36
+ // ASP (Application Secure Protocol) (ADR-053 ~ ADR-060).
37
+ export * as MegaAspCrypto from './asp/crypto.js'
38
+ export { registerAspPlugin } from './asp/plugin.js'
39
+ export { MegaAspTerminator } from './asp/ws-terminator.js'
40
+ export { MegaAspNonceCache, MegaMemoryNonceStore } from './asp/nonce-cache.js'
41
+ export { MegaAspDecryptError, ASP_RULES } from './asp/errors.js'
42
+ export { normalizeAspConfig } from './asp/config.js'
@@ -0,0 +1,150 @@
1
+ // @ts-check
2
+ /**
3
+ * 텔레그램 sink 순수 로직 — throttle + 메시지 포맷 + disk-backed retry queue (ADR-023/141).
4
+ *
5
+ * worker thread transport(`telegram-transport.js`)가 이 순수 함수들을 조합한다. 순수/주입 가능 설계라
6
+ * worker thread 없이 단위 테스트한다(throttle 윈도우·포맷·retry queue append/drain·send 주입).
7
+ *
8
+ * @module lib/logger/telegram-core
9
+ */
10
+ import { appendFile, readFile, rename, mkdir, rm } from 'node:fs/promises'
11
+ import { dirname } from 'node:path'
12
+
13
+ /**
14
+ * 슬라이딩 윈도우 throttle — 최근 `windowMs` 안에 `max` 건까지만 허용(폭주 시 텔레그램 rate limit 직격 방지).
15
+ *
16
+ * @param {number} max - 윈도우당 최대 전송 건수(디폴트 5).
17
+ * @param {number} windowMs - 윈도우 길이 ms(디폴트 60_000 = 1분).
18
+ * @returns {{ tryAcquire(now: number): boolean, size(): number }}
19
+ */
20
+ export function createThrottle(max = 5, windowMs = 60_000) {
21
+ /** @type {number[]} 최근 전송 타임스탬프(ms). */
22
+ let stamps = []
23
+ return {
24
+ /**
25
+ * 지금(now) 전송이 허용되면 true(슬롯 점유), 초과면 false.
26
+ * @param {number} now
27
+ * @returns {boolean}
28
+ */
29
+ tryAcquire(now) {
30
+ stamps = stamps.filter((t) => now - t < windowMs) // 윈도우 밖 만료 제거.
31
+ if (stamps.length >= max) return false
32
+ stamps.push(now)
33
+ return true
34
+ },
35
+ size() {
36
+ return stamps.length
37
+ },
38
+ }
39
+ }
40
+
41
+ /**
42
+ * pino 레코드를 텔레그램 메시지 텍스트로 포맷한다. redact 는 메인 스레드 pino 가 이미 적용했으므로
43
+ * 레코드에는 시크릿이 없다(transport 는 마스킹된 NDJSON 만 받음). 핵심 필드만 추린다(전체 payload X, P5).
44
+ *
45
+ * @param {Record<string, any>} record - pino 표준 레코드(JSON 파싱됨).
46
+ * @param {string} [serviceName]
47
+ * @returns {string} 텔레그램 sendMessage 용 텍스트.
48
+ */
49
+ export function formatMessage(record, serviceName = 'mega') {
50
+ const levelName = LEVEL_NAMES[record.level] ?? String(record.level ?? '')
51
+ const parts = [`🚨 [${serviceName}] ${levelName.toUpperCase()}: ${record.msg ?? ''}`.trim()]
52
+ if (record.err?.message) parts.push(`err: ${record.err.message}`)
53
+ if (record.req?.method && record.req?.url) parts.push(`req: ${record.req.method} ${record.req.url}`)
54
+ if (record.reqId ?? record.req_id ?? record.request_id) parts.push(`reqId: ${record.reqId ?? record.req_id ?? record.request_id}`)
55
+ if (record.trace_id) parts.push(`trace: ${record.trace_id}`)
56
+ return parts.join('\n')
57
+ }
58
+
59
+ /** pino 숫자 level → 이름. */
60
+ const LEVEL_NAMES = /** @type {Record<number, string>} */ ({
61
+ 10: 'trace',
62
+ 20: 'debug',
63
+ 30: 'info',
64
+ 40: 'warn',
65
+ 50: 'error',
66
+ 60: 'fatal',
67
+ })
68
+
69
+ /**
70
+ * disk-backed retry queue — 전송 실패한 텍스트를 파일(JSONL)에 쌓고, 다음 기회에 재시도한다(전송 실패해도
71
+ * 메시지를 잃지 않음, ADR-023). worker thread 내에서만 접근하므로 락 불필요(단일 소비자).
72
+ */
73
+ export class RetryQueue {
74
+ /**
75
+ * @param {string} filePath - JSONL 큐 파일 경로(없으면 비어 있음).
76
+ */
77
+ constructor(filePath) {
78
+ /** @type {string} */
79
+ this._file = filePath
80
+ }
81
+
82
+ /**
83
+ * 실패 메시지를 큐에 append.
84
+ * @param {string} text
85
+ * @returns {Promise<void>}
86
+ */
87
+ async append(text) {
88
+ await mkdir(dirname(this._file), { recursive: true })
89
+ await appendFile(this._file, JSON.stringify({ text, ts: Date.now() }) + '\n', 'utf8')
90
+ }
91
+
92
+ /**
93
+ * 큐 전체를 가로채(claim) 비우고, 각 항목 텍스트 배열을 반환한다. 호출자가 재전송 시도 후, 다시
94
+ * 실패한 것만 `append` 로 되돌린다.
95
+ *
96
+ * read-then-`writeFile('')` 는 두 await 사이에 끼어든 `append` 를 통째로 지운다(producer=write 경로의
97
+ * 재시도 append, consumer=drain). 그래서 `rename` 으로 **단일 syscall** 가로채기를 쓴다: 가로챈 뒤 들어오는
98
+ * append 는 새 파일(`this._file`)에 쌓이므로 유실되지 않는다(유실 0, ADR-023/141). 동시 drain 이 한 번 더
99
+ * 들어오면 파일이 이미 옮겨져 `ENOENT` → 빈 배열(중복 처리 없음).
100
+ * @returns {Promise<string[]>}
101
+ */
102
+ async drain() {
103
+ const claim = `${this._file}.draining`
104
+ try {
105
+ await rename(this._file, claim)
106
+ } catch (e) {
107
+ // 큐 파일 없음(아직 실패 없음) 또는 다른 drain 이 이미 가로챔 → 처리할 것 없음.
108
+ if (/** @type {NodeJS.ErrnoException} */ (e)?.code === 'ENOENT') return []
109
+ throw e
110
+ }
111
+ const raw = await readFile(claim, 'utf8')
112
+ await rm(claim, { force: true })
113
+ /** @type {string[]} */
114
+ const texts = []
115
+ for (const line of raw.split('\n')) {
116
+ if (!line.trim()) continue
117
+ try {
118
+ const obj = JSON.parse(line)
119
+ if (typeof obj.text === 'string') texts.push(obj.text)
120
+ } catch {
121
+ // 손상된 라인은 건너뛴다 — 큐 전체를 막지 않기 위함(비치명적, 다음 항목 계속).
122
+ continue
123
+ }
124
+ }
125
+ return texts
126
+ }
127
+
128
+ /** 큐 파일 제거(테스트·정리용). @returns {Promise<void>} */
129
+ async clear() {
130
+ await rm(this._file, { force: true })
131
+ }
132
+ }
133
+
134
+ /**
135
+ * 텔레그램 Bot API `sendMessage` 호출. https 호출은 주입 가능(`httpsRequest`)이라 단위 테스트에서 모킹한다.
136
+ *
137
+ * @param {object} args
138
+ * @param {string} args.botToken
139
+ * @param {string} args.chatId
140
+ * @param {string} args.text
141
+ * @param {(url: string, body: string) => Promise<{ statusCode: number }>} args.httpsRequest - 주입 HTTP.
142
+ * @returns {Promise<boolean>} 전송 성공(2xx) 여부.
143
+ * @throws 호출 자체가 throw 하면 호출자(transport)가 retry queue 로 보낸다.
144
+ */
145
+ export async function sendTelegram({ botToken, chatId, text, httpsRequest }) {
146
+ const url = `https://api.telegram.org/bot${botToken}/sendMessage`
147
+ const body = JSON.stringify({ chat_id: chatId, text })
148
+ const res = await httpsRequest(url, body)
149
+ return res.statusCode >= 200 && res.statusCode < 300
150
+ }
@@ -0,0 +1,126 @@
1
+ // @ts-check
2
+ /**
3
+ * 텔레그램 pino transport — **worker thread** 에서 실행(pino transport 가 자동 격리, ADR-023).
4
+ *
5
+ * pino 가 NDJSON 청크를 이 stream 에 흘려보내면: (1) throttle 통과분만 (2) 텔레그램으로 POST,
6
+ * (3) 실패는 disk-backed retry queue 로 적재 후 타이머가 주기 재시도. 메인 이벤트루프와 격리돼 전송 지연·
7
+ * 실패가 앱을 막지 않는다. 순수 로직은 `telegram-core.js`(단위 테스트), 본 모듈은 https + stream glue.
8
+ *
9
+ * @module lib/logger/telegram-transport
10
+ * @see ADR-023
11
+ * @see ADR-141
12
+ */
13
+ import { Writable } from 'node:stream'
14
+ import { request as httpsRequestRaw } from 'node:https'
15
+ import { join } from 'node:path'
16
+ import { createThrottle, formatMessage, RetryQueue, sendTelegram } from './telegram-core.js'
17
+
18
+ /**
19
+ * 실제 https POST — telegram-core 의 주입 시그니처에 맞춘 thin 래퍼.
20
+ *
21
+ * @param {string} url
22
+ * @param {string} body
23
+ * @param {{ request?: typeof httpsRequestRaw, timeoutMs?: number }} [deps] - request 는 테스트 주입용(기본=node:https).
24
+ * @returns {Promise<{ statusCode: number }>}
25
+ */
26
+ export function httpsPost(url, body, { request = httpsRequestRaw, timeoutMs = 10_000 } = {}) {
27
+ return new Promise((resolve, reject) => {
28
+ /** @type {ReturnType<typeof setTimeout> | null} */
29
+ let timer = null
30
+ /** 한 번만 settle — timer 정리 후 결정(중복 settle 은 Promise 가 무시). */
31
+ const settle = (/** @type {(v:any)=>void} */ fn, /** @type {any} */ arg) => {
32
+ if (timer) {
33
+ clearTimeout(timer)
34
+ timer = null
35
+ }
36
+ fn(arg)
37
+ }
38
+ const req = request(url, { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } }, (res) => {
39
+ // 헤더 수신 후 응답 스트림이 끊기면 unhandled 'error' 로 워커가 죽는다 — reject 로 흡수(호출자가 retry queue 로).
40
+ res.on('error', (e) => settle(reject, e))
41
+ res.resume() // 응답 본문 드레인(메모리 누수 방지) — statusCode 만 본다.
42
+ res.on('end', () => settle(resolve, { statusCode: res.statusCode ?? 0 }))
43
+ })
44
+ // 무응답(연결만 열리고 응답 없음)이면 trySend·drain 루프가 영구 정지 → 타임아웃으로 끊어 retry queue 로 넘긴다.
45
+ timer = setTimeout(() => req.destroy(new Error(`telegram request timeout after ${timeoutMs}ms`)), timeoutMs)
46
+ if (typeof timer.unref === 'function') timer.unref() // 종료 막지 않음.
47
+ req.on('error', (e) => settle(reject, e))
48
+ req.write(body)
49
+ req.end()
50
+ })
51
+ }
52
+
53
+ /**
54
+ * pino transport 진입점 — `{ target: <this>, options }` 로 등록되면 worker 가 이 default export 를 호출한다.
55
+ *
56
+ * @param {object} opts
57
+ * @param {string} opts.botToken
58
+ * @param {string} opts.chatId
59
+ * @param {number} [opts.throttleMax] - 윈도우당 최대 전송(디폴트 5).
60
+ * @param {number} [opts.throttleWindowMs] - 윈도우 ms(디폴트 60_000).
61
+ * @param {string} [opts.retryDir] - retry queue 디렉터리(디폴트 './logs/telegram-retry').
62
+ * @param {string} [opts.serviceName]
63
+ * @param {number} [opts.retryDrainMs] - retry 드레인 주기(디폴트 30_000).
64
+ * @param {(url: string, body: string) => Promise<{ statusCode: number }>} [opts.httpsRequest] - HTTP 주입(기본=httpsPost). 단위 테스트용 seam(ADR-165 동일 패턴) — pino worker 는 미전달.
65
+ * @returns {import('node:stream').Writable}
66
+ */
67
+ export default function telegramTransport(opts) {
68
+ const {
69
+ botToken,
70
+ chatId,
71
+ throttleMax = 5,
72
+ throttleWindowMs = 60_000,
73
+ retryDir = './logs/telegram-retry',
74
+ serviceName = 'mega',
75
+ retryDrainMs = 30_000,
76
+ httpsRequest = httpsPost,
77
+ } = opts
78
+ const throttle = createThrottle(throttleMax, throttleWindowMs)
79
+ const queue = new RetryQueue(join(retryDir, 'telegram-retry.jsonl'))
80
+
81
+ /** 한 건 전송 시도 — 실패/throw 시 retry queue 로. */
82
+ async function trySend(/** @type {string} */ text) {
83
+ try {
84
+ const ok = await sendTelegram({ botToken, chatId, text, httpsRequest })
85
+ if (!ok) await queue.append(text)
86
+ } catch {
87
+ // 네트워크 throw 도 유실 금지 — 큐로 보관 후 다음 드레인에서 재시도(silent 아님: 큐가 증거).
88
+ await queue.append(text)
89
+ }
90
+ }
91
+
92
+ // 주기 드레인 — 쌓인 실패분을 재전송(여전히 실패하면 다시 큐로). unref 로 프로세스 종료 막지 않음.
93
+ /** @returns {Promise<void>} 쌓인 실패분 재전송(여전히 실패하면 trySend 가 다시 큐로). */
94
+ const drainTick = async () => {
95
+ const pending = await queue.drain().catch(() => /** @type {string[]} */ ([]))
96
+ for (const text of pending) await trySend(text)
97
+ }
98
+ const timer = setInterval(drainTick, retryDrainMs)
99
+ if (typeof timer.unref === 'function') timer.unref()
100
+
101
+ let buffer = '' // NDJSON 프레이밍 — 청크 경계에 걸친 미완 라인 보관.
102
+ return new Writable({
103
+ write(chunk, _enc, cb) {
104
+ buffer += chunk.toString()
105
+ const lines = buffer.split('\n')
106
+ buffer = lines.pop() ?? '' // 마지막(미완) 라인은 다음 write 까지 보류.
107
+ for (const line of lines) {
108
+ if (!line.trim()) continue
109
+ /** @type {Record<string, any>} */
110
+ let record
111
+ try {
112
+ record = JSON.parse(line)
113
+ } catch {
114
+ continue // 손상 라인 skip — 다음 라인 계속.
115
+ }
116
+ if (!throttle.tryAcquire(Date.now())) continue // 폭주 억제(초과분 드롭).
117
+ void trySend(formatMessage(record, serviceName))
118
+ }
119
+ cb()
120
+ },
121
+ final(cb) {
122
+ clearInterval(timer)
123
+ cb()
124
+ },
125
+ })
126
+ }