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,323 @@
1
+ // @ts-check
2
+ /**
3
+ * 중앙 부팅 orchestrator — config·어댑터·플러그인·앱을 02-architecture §14 순서로 엮는다.
4
+ *
5
+ * config-loader / adapter-manager / MegaPluginHost / MegaApp·MegaServer 는 각자 완결돼 있으나
6
+ * 이를 잇는 중앙 지점이 없었다([ADR-122](../../docs/09-decisions-and-open-questions.md)).
7
+ * 본 모듈이 그 단일 지점이다 — `loadPlugins` 를 실 부팅 시퀀스에 끼우고 lifecycle hook(beforeBoot/
8
+ * afterBoot/beforeShutdown)을 구동한다(ADR-123).
9
+ *
10
+ * # 부팅 시퀀스 (02-architecture §14 — 4·5단계 = 플러그인)
11
+ * 1. `loadAndValidateConfig(projectRoot)` — mega.config.js + app.config.js 로드·검증(1~3단계).
12
+ * 4·5. `loadPlugins(global.plugins, host, { projectRoot })` — resolve→shape→apiVersion→install(순서대로).
13
+ * 2. `buildFromGlobalConfig(global)` — 어댑터 인스턴스화 + LIFO shutdown hook(**앱 생성보다 먼저**,
14
+ * adapter-manager 종료 순서 주석).
15
+ * 3. `connectAll({ ping })` — 어댑터 connect(+옵션 healthCheck). 실패 시 매니저가 LIFO cleanup 후 throw.
16
+ * 7. `host.runLifecycle('beforeBoot', ctx)` — 부팅 직전 hook(L-2 ctx 주입).
17
+ * 6. 각 앱: `new MegaApp({ ...config, plugins, globalMiddlewares })` + 라우트 자동 로딩 + vhost mount.
18
+ * 9. `server.listen({ port, host })`(listen=false 면 건너뜀 — 테스트/CLI 분기).
19
+ * 10. `host.runLifecycle('afterBoot', ctx)` — 부팅 완료 hook.
20
+ * + beforeShutdown hook 을 `MegaShutdown` 에 브리지(graceful, per-hook catch — ADR-122).
21
+ *
22
+ * # ⚠️ install → buildFromGlobalConfig 순서 (config-driven driver 의 핵심 제약)
23
+ * roadmap §224 검증기준("샘플 플러그인이 `adapters.register('sample')` → `services.databases.x.driver:
24
+ * 'sample'` 동작")이 성립하려면 **플러그인 install 이 `buildFromGlobalConfig` 보다 먼저** 와야 한다 —
25
+ * install 이 driver 를 레지스트리에 등록해야 buildFromGlobalConfig 가 `registry.resolve('sample')` 로
26
+ * 인스턴스화할 수 있다. 그래서 §14 의 4·5(플러그인)를 어댑터 빌드 앞에 둔다(adapter-manager 의 "앱
27
+ * 생성보다 먼저" 제약도 그대로 만족 — install < build < app).
28
+ *
29
+ * wsHub embedded 모드(ADR-137): `global.wsHub.enabled === true` 면 본 orchestrator 가 어댑터 connect
30
+ * 뒤·앱 생성 전에 같은 프로세스에 `MegaWsHub` 를 띄운다(single-node). 앱↔hub 연결은 여전히 명시
31
+ * (`app.connectHub`/`bridgeHub` — localhost 주소)이며 자동 배선하지 않는다 — 프로토콜은 별도 프로세스
32
+ * (`bin/mega-ws-hub.js`)와 완전 동일(WS over localhost). `enabled` 가 아니면 hub 를 띄우지 않는다.
33
+ *
34
+ * @module core/boot
35
+ */
36
+ import { join } from 'node:path'
37
+ // 빌트인 어댑터(postgres/mongodb/mariadb/sqlite/redis/file/nats/redlock)는 import 시 레지스트리에
38
+ // 자기등록한다(ADR-044). CLI 런타임 부팅(`mega start`/`worker`/`scheduler`/`migrate`)은 사용자 코드가
39
+ // 'mega-framework' 를 import 하기 전에 `buildFromGlobalConfig` 로 driver 를 resolve 하므로, 이 배럴을
40
+ // 부팅 진입 모듈에서 side-effect 로 먼저 로드해 빌트인 driver 등록을 보장한다(ADR-150 — E2E 발견 결함).
41
+ import '../adapters/index.js'
42
+ import { loadAndValidateConfig } from './config-loader.js'
43
+ import { loadRoutes } from './routes-loader.js'
44
+ import { loadServices } from './services-loader.js'
45
+ import { MegaApp } from './mega-app.js'
46
+ import { MegaServer } from './mega-server.js'
47
+ import { MegaPluginHost, loadPlugins } from '../lib/mega-plugin.js'
48
+ import { MegaShutdown } from '../lib/mega-shutdown.js'
49
+ import { buildLogger } from '../lib/mega-logger.js'
50
+ import { buildFromGlobalConfig, connectAll, get as getAdapter, entries as adapterEntries } from '../adapters/adapter-manager.js'
51
+ import { buildWorkers, startAll as startWorkers, contextProxy as workersContext } from './workers-manager.js'
52
+ import * as MegaMetrics from '../lib/mega-metrics.js'
53
+ import * as MegaTracing from '../lib/mega-tracing.js'
54
+ import { MegaWsHub } from '../cli/ws-hub.js'
55
+
56
+ /**
57
+ * 부팅 결과 핸들.
58
+ * @typedef {Object} BootResult
59
+ * @property {MegaServer} server - mount 된 MegaServer(listen=false 면 미-listen 상태).
60
+ * @property {MegaPluginHost} host - 플러그인 호스트(install 완료, queryable).
61
+ * @property {Object} config - mega.config.js default export(global).
62
+ * @property {Array<{ name: string, config: Object }>} apps - 로드된 앱 config 목록.
63
+ * @property {MegaApp[]} megaApps - 생성된 MegaApp 인스턴스(등록 순서).
64
+ * @property {BootContext} ctx - lifecycle hook 에 넘긴 boot context.
65
+ * @property {import('../cli/ws-hub.js').MegaWsHub | null} wsHub - embedded wsHub(ADR-137, `wsHub.enabled` OFF 면 null).
66
+ */
67
+
68
+ /**
69
+ * lifecycle hook(beforeBoot/afterBoot/beforeShutdown)이 받는 boot context (L-2/ADR-123).
70
+ * `db/cache/bus/lock` 은 **글로벌 키 직접 lookup**(앱 별명 변환 없음 — boot 레벨은 별명 스코프 밖).
71
+ * @typedef {Object} BootContext
72
+ * @property {Object} config - global config.
73
+ * @property {{ debug?: Function, info?: Function, warn?: Function }} [log]
74
+ * @property {(globalKey: string) => import('../adapters/mega-adapter.js').MegaAdapter} db
75
+ * @property {(globalKey: string) => import('../adapters/mega-adapter.js').MegaAdapter} cache
76
+ * @property {(globalKey: string) => import('../adapters/mega-adapter.js').MegaAdapter} bus
77
+ * @property {(globalKey: string) => import('../adapters/mega-adapter.js').MegaAdapter} lock
78
+ * @property {Record<string, import('../lib/mega-worker.js').MegaWorker>} workers - `ctx.workers.<name>.run(task)` (ADR-124).
79
+ */
80
+
81
+ /**
82
+ * ASP 설정 합성 (ADR-127) — global 스코프의 `masterSecret`(시크릿)과 앱 스코프의 옵트인 범위
83
+ * (`http.enabledPaths` / `websocket.namespaces`)를 하나로 합친다. 앱이 자체 masterSecret 을 명시하면
84
+ * 그쪽이 우선한다(앱 override). 둘 다 없으면 `undefined`(ASP 미사용).
85
+ *
86
+ * @param {{ masterSecret?: string } | undefined} globalAsp - mega.config.js 의 `asp`.
87
+ * @param {Record<string, any> | undefined} appAsp - app.config.js 의 `asp`.
88
+ * @returns {Record<string, any> | undefined}
89
+ */
90
+ function composeAspConfig(globalAsp, appAsp) {
91
+ const masterSecret = appAsp?.masterSecret ?? globalAsp?.masterSecret
92
+ if (!masterSecret && !appAsp) return undefined
93
+ return { ...appAsp, ...(masterSecret ? { masterSecret } : {}) }
94
+ }
95
+
96
+ /**
97
+ * boot/CLI 컨텍스트 — `db/cache/bus/lock` 을 글로벌 키로 직접 조회하고, `workers` 는 `static name` 으로
98
+ * lookup 한다(앱 별명 변환 없음 — 둘 다 글로벌 자원). worker/scheduler CLI 도 같은 형태를 재사용한다
99
+ * (잡/스케줄의 `static bus`/`static lock` = 글로벌 키; 잡이 CPU 작업을 `ctx.workers` 로 오프로드 가능).
100
+ * @param {Object} global - global config.
101
+ * @param {{ debug?: Function, info?: Function, warn?: Function }} [logger]
102
+ * @returns {BootContext}
103
+ */
104
+ export function buildBootContext(global, logger) {
105
+ return {
106
+ config: global,
107
+ log: logger,
108
+ db: (key) => getAdapter('db', key),
109
+ cache: (key) => getAdapter('cache', key),
110
+ bus: (key) => getAdapter('bus', key),
111
+ lock: (key) => getAdapter('lock', key),
112
+ workers: workersContext(), // ctx.workers.<name> — 미등록 이름 접근 시 worker.not_registered fail-fast.
113
+ }
114
+ }
115
+
116
+ /**
117
+ * 런타임 공통 준비 — config 로드 → 플러그인 install → 어댑터 build → connect → boot ctx.
118
+ * `bootApp`(서버)·`mega worker`/`mega scheduler`(CLI 호스트)가 **같은 순서**를 공유한다(install <
119
+ * build — config-driven driver 제약, 위 주석 참조). lifecycle hook/앱 생성/ listen 직전까지의 토대.
120
+ *
121
+ * @param {string} projectRoot
122
+ * @param {{ ping?: boolean, logger?: { debug?: Function, info?: Function, warn?: Function } }} [opts]
123
+ * @returns {Promise<{ global: Object, apps: Array<{ name: string, config: Object }>, host: MegaPluginHost, ctx: BootContext, wsHub: (MegaWsHub | null) }>}
124
+ */
125
+ export async function prepareRuntime(projectRoot, { ping = false, logger } = {}) {
126
+ // 1~3단계: config 로드 + 검증.
127
+ const { global, apps } = await loadAndValidateConfig(projectRoot)
128
+ logger?.debug?.({ apps: apps.map((a) => a.name) }, 'boot.config loaded')
129
+
130
+ // 4·5단계: 플러그인 로딩 + install — **어댑터 빌드보다 먼저**(config-driven driver, 상단 주석 참조).
131
+ const host = new MegaPluginHost({ logger })
132
+ await loadPlugins(/** @type {any} */ (global).plugins, host, { projectRoot, logger })
133
+ logger?.debug?.({ plugins: host.loadedPlugins.map((p) => p.name) }, 'boot.plugins installed')
134
+
135
+ // 어댑터 인스턴스화 + connect (MegaApp 생성보다 먼저 — LIFO shutdown 순서, adapter-manager 주석).
136
+ buildFromGlobalConfig(global)
137
+ await connectAll({ logger, ping })
138
+ logger?.debug?.('boot.adapters connected')
139
+
140
+ // 메트릭 SDK 옵트인 (ADR-072/131) — global `health.exposeMetrics:true` 면 MegaMetrics 초기화
141
+ // + 어댑터 onCallEnd 일괄 구독(connect 직후 — 모든 공유 어댑터 호출이 mega_adapter_* 메트릭으로 집계).
142
+ // 옵트인 OFF 면 init 미호출 → 모든 record* 가 0 비용. /metrics 라우트는 MegaApp 이 같은 config 로 등록.
143
+ const healthCfg = /** @type {any} */ (global).health
144
+ if (healthCfg && healthCfg.exposeMetrics === true && !MegaMetrics.isEnabled()) {
145
+ MegaMetrics.init({
146
+ serviceName: healthCfg.serviceName ?? /** @type {any} */ (global).server?.serviceName ?? process.env.MEGA_OTEL_SERVICE_NAME ?? 'mega-framework',
147
+ version: /** @type {any} */ (global).server?.version,
148
+ environment: process.env.NODE_ENV,
149
+ })
150
+ const subscribed = MegaMetrics.attachToManager({ entries: adapterEntries })
151
+ MegaShutdown.register('mega-metrics', async () => {
152
+ await MegaMetrics.shutdown()
153
+ })
154
+ logger?.debug?.({ subscribed }, 'boot.metrics enabled')
155
+ }
156
+
157
+ // 트레이싱 SDK 옵트인 (ADR-104/114/126/163) — `MEGA_OTEL_ENABLED=true` env 면 MegaTracing 초기화 + 어댑터
158
+ // onCallEnd 일괄 구독(자동 span, ADR-114). 메트릭(위)과 대칭 — `fromEnv` 가 env 게이트(미설정/false=no-op,
159
+ // 0 비용)한다. 미옵트인이면 mega-app 의 HTTP/WS 루트 span·`ctx.tracer.span` 이 모두 no-op. **부팅에서 한 번
160
+ // 켜야 프로덕션 분산 트레이싱이 동작한다** — 이전엔 boot 가 `MegaTracing.fromEnv()` 를 호출하지 않아(메트릭만
161
+ // 배선) 프로덕션에서 트레이싱이 미작동했다(ADR-163 에서 boot 배선 추가). 재진입 방지로 isEnabled 가드.
162
+ if (!MegaTracing.isEnabled() && MegaTracing.fromEnv() === true) {
163
+ const tracedAdapters = MegaTracing.attachToManager({ entries: adapterEntries })
164
+ MegaShutdown.register('mega-tracing', async () => {
165
+ await MegaTracing.shutdown()
166
+ })
167
+ logger?.debug?.({ subscribed: tracedAdapters }, 'boot.tracing enabled')
168
+ }
169
+
170
+ // CPU 워커 풀(ADR-124) — 등록 소스 = `config.workers`(정적) + 플러그인 `host.listWorkers()`(동적,
171
+ // ADR-134). jobs/schedules 듀얼 소스(`collectRegistrations`, ADR-123)와 동형이다. `buildWorkers` 가
172
+ // `static name` 중복을 부팅 시 fail-fast 한다. 어댑터 connect **뒤**에 등록되므로 MegaShutdown LIFO 상
173
+ // 워커가 어댑터보다 먼저 정리된다(워커가 어댑터를 쓰는 정리를 어댑터 종료 전에 끝냄).
174
+ const workerClasses = [...(/** @type {any} */ (global).workers ?? []), ...host.listWorkers()]
175
+ buildWorkers({ workers: /** @type {any} */ (workerClasses) }, { projectRoot })
176
+ await startWorkers({ logger })
177
+ logger?.debug?.({ workers: workerClasses.length }, 'boot.workers started')
178
+
179
+ // embedded wsHub (ADR-137) — `global.wsHub.enabled=true` 면 같은 프로세스에 hub 를 띄운다(single-node).
180
+ // 어댑터 connect 뒤·앱 생성 전에 listen 상태로 만들어 앱이 bridgeHub(localhost)로 붙을 수 있게 한다.
181
+ // 빈 acceptedTokens 면 MegaWsHub 생성자가 fail-fast throw(ADR-059). SIGTERM 시 drain(4503) 종료.
182
+ const wsHub = await startEmbeddedWsHub(/** @type {any} */ (global).wsHub, logger)
183
+
184
+ const ctx = buildBootContext(global, logger)
185
+ return { global, apps, host, ctx, wsHub }
186
+ }
187
+
188
+ /**
189
+ * embedded wsHub 기동 (ADR-137) — `cfg.enabled === true` 일 때만 `MegaWsHub` 를 같은 프로세스에 띄우고
190
+ * `MegaShutdown` 에 drain 종료 hook 을 등록한다. 아니면 `null`(미기동). 검증(빈 토큰 등)은 MegaWsHub
191
+ * 생성자에 위임한다(ADR-059 — fail-fast). 별도 프로세스(`runWsHubCli`)와 동일한 hub 구현·프로토콜.
192
+ *
193
+ * @param {any} cfg - `global.wsHub`(MegaWsHubConfig).
194
+ * @param {{ debug?: Function, info?: Function, warn?: Function }} [logger]
195
+ * @returns {Promise<MegaWsHub | null>}
196
+ */
197
+ async function startEmbeddedWsHub(cfg, logger) {
198
+ if (!cfg || cfg.enabled !== true) return null
199
+ const hub = new MegaWsHub({
200
+ acceptedTokens: cfg.acceptedTokens,
201
+ heartbeatMs: cfg.heartbeatMs,
202
+ compression: cfg.compression,
203
+ logger,
204
+ })
205
+ const addr = await hub.start({ port: cfg.port, host: cfg.host })
206
+ MegaShutdown.register('mega-ws-hub-embedded', async () => hub.stop({ drain: true }))
207
+ logger?.debug?.({ host: addr.host, port: addr.port, hubId: hub.hubId }, 'boot.wsHub embedded started')
208
+ return hub
209
+ }
210
+
211
+ /**
212
+ * 프로젝트를 부팅한다 — config→어댑터→플러그인→앱→listen 을 §14 순서로 엮는다.
213
+ *
214
+ * 어느 단계든 실패하면 그대로 throw(fail-fast). 어댑터 connect 실패는 매니저가 LIFO cleanup 하고,
215
+ * 그 외 단계 실패(예: server.listen EADDRINUSE, beforeBoot hook throw)는 어댑터가 이미 connect 돼
216
+ * `'adapters:disconnect'` MegaShutdown hook 이 등록된 상태라, 호출자가 `MegaShutdown.now` 로 그 hook 을
217
+ * LIFO 실행해 정리해야 한다 — `bin/mega.js` 의 catch 가 이 정리 경로를 배선한다(M-1). 그러지 않으면
218
+ * 연결된 어댑터가 이벤트루프를 살려 프로세스가 hang 한다.
219
+ *
220
+ * @param {string} projectRoot - 프로젝트 루트 절대 경로(mega.config.js 가 있는 곳).
221
+ * @param {Object} [opts]
222
+ * @param {boolean} [opts.listen=true] - false 면 mount 까지만(테스트·검증용).
223
+ * @param {number} [opts.port] - listen 포트(미지정 시 server/global 기본).
224
+ * @param {string} [opts.host] - listen 호스트.
225
+ * @param {boolean} [opts.ping=false] - connectAll 시 healthCheck 까지 검증.
226
+ * @param {{ debug?: Function, info?: Function, warn?: Function }} [opts.logger] - 길목 debug 로그(선택).
227
+ * @returns {Promise<BootResult>}
228
+ */
229
+ export async function bootApp(projectRoot, { listen = true, port, host: listenHost, ping = false, logger } = {}) {
230
+ logger?.debug?.({ projectRoot }, 'boot.start')
231
+
232
+ // 1~5단계 + 어댑터 connect 공통 토대(config → 플러그인 install → 어댑터 build → connect → boot ctx).
233
+ const { global, apps, host, ctx, wsHub } = await prepareRuntime(projectRoot, { ping, logger })
234
+
235
+ // 7단계: beforeBoot hook(부팅 직전, fail-fast).
236
+ await host.runLifecycle('beforeBoot', ctx)
237
+
238
+ // pino 로거(ADR-023/141) — global.logger 로 인스턴스를 한 번 만들어 모든 앱이 공유한다(worker thread·
239
+ // 파일 핸들 1벌). 비활성(logger 미설정/sinks 없음)이면 null → 앱은 logger:false(무로그). graceful shutdown
240
+ // 시 flush(버퍼·worker transport drain). MegaShutdown LIFO 라 어댑터/앱보다 나중 등록 = 가장 먼저 정리되지
241
+ // 않게(로그가 종료 과정 끝까지 살아 있도록) — 마지막 flush 단계(07-sequence §6).
242
+ const appLogger = buildLogger(/** @type {any} */ (global).logger)
243
+ if (appLogger) {
244
+ MegaShutdown.register('mega-logger', async () => {
245
+ await new Promise((resolve) => appLogger.flush(() => resolve(undefined)))
246
+ })
247
+ }
248
+
249
+ // listen 포트·호스트 해석 — CLI 인자(`--port`/`--host`) 우선, 없으면 정본 server config
250
+ // (`global.server.port`/`host`, 04-data-models §183: port 기본 3000·host 기본 '0.0.0.0').
251
+ // boot 가 config→listen 의 유일한 배선 지점이라 여기서 읽지 않으면 `server.port`/`PORT` 가 죽는다.
252
+ // 최종 폴백(3000/'0.0.0.0')은 MegaServer.listen 이 가진다(여기선 undefined 면 그대로 위임).
253
+ const serverCfg = /** @type {any} */ (global).server ?? {}
254
+ const resolvedPort = port ?? serverCfg.port
255
+ const resolvedHost = listenHost ?? serverCfg.host
256
+
257
+ // 6단계: 각 앱 Fastify 인스턴스 생성 + 라우트 자동 로딩 + 플러그인 주입 + vhost mount.
258
+ const server = new MegaServer({ port: resolvedPort, host: resolvedHost })
259
+ /** @type {MegaApp[]} */
260
+ const megaApps = []
261
+ for (const { name, config } of apps) {
262
+ const app = new MegaApp({
263
+ ...config,
264
+ name, // config.name(검증됨) 위에 폴더명을 확정(둘은 동일, ADR-067).
265
+ logger: appLogger ?? false, // pino 로거 주입(공유 인스턴스, ADR-141).
266
+ // ASP masterSecret 은 global 스코프(시크릿, scope-registry)라 앱 asp 옵트인에 합성한다(ADR-127).
267
+ // 앱 config.asp 가 http.enabledPaths/websocket 등 옵트인 범위를, global.asp 가 masterSecret 을 제공.
268
+ asp: composeAspConfig(/** @type {any} */ (global).asp, /** @type {any} */ (config).asp),
269
+ // 세션 쿠키 HMAC 시크릿은 global 스코프(server.sessionSecret, scope-registry)라 앱에 주입한다
270
+ // (ASP masterSecret 합성과 동일 패턴, ADR-129). 앱 session.secret 명시 시 그쪽이 우선(MegaApp).
271
+ sessionSecret: /** @type {any} */ (global).server?.sessionSecret,
272
+ // 운영 관측성 config 는 Global-only(ADR-072/131) — global `health` 블록을 모든 앱에 주입한다(/metrics
273
+ // 라우트 등록 + 보안 면제 + IP allowList). sessionSecret 주입과 동일 패턴.
274
+ health: /** @type {any} */ (global).health,
275
+ plugins: host.fastifyPlugins,
276
+ globalMiddlewares: host.globalMiddlewares,
277
+ })
278
+ // OpenAPI 옵트인 시 배리어(ADR-140): @fastify/swagger 의 onRoute 수집 훅은 plugin 로드 후 설치되므로,
279
+ // 라우트 동기 등록 전에 swagger 를 먼저 로드시켜야 라우트가 명세에 수집된다. `after()` 가 생성자에서 큐된
280
+ // 등록(swagger 포함)을 flush 한다. 비-openapi 앱은 timing 변경 없이 건너뜀(_openapiPath=null).
281
+ if (app._openapiPath) await app.fastify.after()
282
+ // 서비스 자동 로딩(ADR-148) — apps/<name>/services/*.js 를 name→Class 레지스트리로 만들어 앱에 주입한다.
283
+ // 라우트 등록 전에 채워 두면, 요청 ctx 의 ctx.services.<name> lazy DI 가 첫 요청부터 동작한다.
284
+ const servicesDir = join(projectRoot, 'apps', name, 'services')
285
+ const serviceRegistry = await loadServices({ servicesDir, appName: name })
286
+ app.setServiceRegistry(serviceRegistry)
287
+ logger?.debug?.({ app: name, services: serviceRegistry.size }, 'boot.services loaded')
288
+
289
+ const routesDir = join(projectRoot, 'apps', name, 'routes')
290
+ const { filesLoaded } = await loadRoutes({ fastify: app.fastify, appName: name, routesDir, app })
291
+ logger?.debug?.({ app: name, filesLoaded }, 'boot.routes loaded')
292
+ server.mount(app)
293
+ megaApps.push(app)
294
+ }
295
+
296
+ // 9단계: HTTP listen(분기 — CLI/테스트가 listen=false 로 mount 까지만 받을 수 있음).
297
+ if (listen) {
298
+ await server.listen({ port: resolvedPort, host: resolvedHost })
299
+ logger?.info?.({ hosts: server.hosts }, 'boot.listening')
300
+ }
301
+
302
+ // 10단계: afterBoot hook(부팅 완료).
303
+ await host.runLifecycle('afterBoot', ctx)
304
+
305
+ // beforeShutdown hook 을 graceful 종료 경로에 브리지(per-hook catch — graceful 중 한 hook 실패가
306
+ // 나머지 정리를 막으면 안 됨, ADR-122). MegaShutdown LIFO 라 어댑터 hook 보다 나중 등록 =
307
+ // 어댑터 disconnect 보다 먼저 실행(플러그인이 어댑터를 쓰는 정리를 어댑터 종료 전에 끝냄).
308
+ const shutdownHooks = host.lifecycleHooks('beforeShutdown')
309
+ if (shutdownHooks.length > 0) {
310
+ MegaShutdown.register('plugin:beforeShutdown', async () => {
311
+ for (const fn of shutdownHooks) {
312
+ try {
313
+ await fn(ctx)
314
+ } catch (err) {
315
+ logger?.warn?.({ err }, 'plugin beforeShutdown hook failed (continuing shutdown)')
316
+ }
317
+ }
318
+ })
319
+ }
320
+
321
+ logger?.debug?.('boot.done')
322
+ return { server, host, config: global, apps, megaApps, ctx, wsHub }
323
+ }
@@ -0,0 +1,278 @@
1
+ // @ts-check
2
+ /**
3
+ * 클러스터 메트릭 집계 — `mega start` 클러스터 모드(ADR-154)에서 워커별로 흩어진 Prometheus 메트릭을
4
+ * 한 응답으로 합산한다(ADR-163).
5
+ *
6
+ * # 왜 필요한가 (중학생용 설명)
7
+ * 클러스터는 같은 포트를 여러 워커 프로세스가 나눠 받는다. 메트릭(요청 수 등)은 **프로세스마다 따로**
8
+ * 쌓이므로, `/metrics` 를 한 번 긁으면 그때 응답한 워커의 숫자만 나온다 — 새로고침마다 다른 워커라 숫자가
9
+ * 들쭉날쭉해 보인다. OpenTelemetry SDK 에는 프로세스 간 합산 기능이 없다(prom-client 의 AggregatorRegistry
10
+ * 가 하는 일을 우리가 한다). 그래서 마스터가 모든 워커의 메트릭을 IPC 로 모아 합산해 돌려준다.
11
+ *
12
+ * # 흐름 (prom-client AggregatorRegistry 패턴)
13
+ * 1. `/metrics` 요청이 한 워커에 도착 → 그 워커가 마스터에 `request` 를 보낸다({@link collectCluster}).
14
+ * 2. 마스터가 모든 워커에 `collect` 를 fan-out → 각 워커가 자기 `MegaMetrics.collect()` 텍스트를 `collected`
15
+ * 로 회신({@link installWorkerResponder}).
16
+ * 3. 마스터가 회신을 모아(전원 응답 또는 timeout) {@link mergeExposition} 로 합산 → 요청 워커에 `aggregated`
17
+ * 회신({@link installPrimaryAggregator}).
18
+ * 4. 요청 워커가 그 합산 텍스트로 응답.
19
+ *
20
+ * Node `cluster` IPC 는 워커↔마스터만 가능(워커끼리 직통 X)하므로 마스터가 relay 한다. 단일 프로세스(클러스터
21
+ * 비활성)면 `collectCluster` 가 그냥 로컬 `collect()` 를 돌려준다(IPC 없음).
22
+ *
23
+ * @module core/cluster-metrics
24
+ * @see ADR-154 (클러스터)
25
+ * @see ADR-163 (본 집계)
26
+ * @see https://github.com/siimon/prom-client (AggregatorRegistry 참고 패턴)
27
+ */
28
+ import cluster from 'node:cluster'
29
+ import * as MegaMetrics from '../lib/mega-metrics.js'
30
+
31
+ /** IPC 메시지 타입 — 충돌 방지 prefix. */
32
+ const MSG_REQUEST = 'mega:metrics:request' // 워커(HTTP) → 마스터: 클러스터 집계 요청.
33
+ const MSG_AGGREGATED = 'mega:metrics:aggregated' // 마스터 → 워커(HTTP): 합산 결과.
34
+ const MSG_COLLECT = 'mega:metrics:collect' // 마스터 → 전 워커: 메트릭 회신 요청.
35
+ const MSG_COLLECTED = 'mega:metrics:collected' // 워커 → 마스터: 자기 메트릭 텍스트.
36
+
37
+ /** 요청 correlation id 시퀀스(워커측). pid 와 합쳐 유니크. */
38
+ let reqSeq = 0
39
+
40
+ /**
41
+ * 여러 워커의 Prometheus exposition 텍스트를 하나로 합산한다(순수 — 테스트 가능).
42
+ *
43
+ * - **counter / histogram(_bucket/_sum/_count)**: 동일 `name{labels}` 끼리 값 **합산**(클러스터 누적).
44
+ * - **gauge**: 합산(메모리·CPU 는 클러스터 합이 유의미). 단 프로세스별 gauge(uptime 등)는 합이 N배가 되는
45
+ * 문서화된 한계 — prom-client 기본도 gauge=sum. `_info`/`target_info` 메타는 합산 대신 **첫 값 유지**(=1).
46
+ * - `# HELP`/`# TYPE` 는 패밀리별 1줄로 dedupe 하고, 패밀리(TYPE 이름) 단위로 묶어 재출력한다.
47
+ *
48
+ * @param {string[]} texts - 각 워커 `collect()` 결과(빈 문자열 허용).
49
+ * @returns {string} 합산된 exposition 텍스트(끝 개행 포함). 입력이 모두 비면 빈 문자열.
50
+ */
51
+ export function mergeExposition(texts) {
52
+ /** @type {Map<string,string>} name → HELP 본문. */
53
+ const help = new Map()
54
+ /** @type {Map<string,string>} name → TYPE(예: 'counter'). */
55
+ const type = new Map()
56
+ /** @type {Map<string,{ labels: string, value: number, name: string }>} fullKey → 누적 샘플. */
57
+ const samples = new Map()
58
+
59
+ for (const text of texts) {
60
+ if (typeof text !== 'string' || text.length === 0) continue
61
+ for (const raw of text.split('\n')) {
62
+ const line = raw.trim()
63
+ if (line.length === 0) continue
64
+ if (line.startsWith('# HELP ')) {
65
+ const m = line.match(/^# HELP (\S+) (.*)$/)
66
+ if (m && !help.has(m[1])) help.set(m[1], m[2])
67
+ continue
68
+ }
69
+ if (line.startsWith('# TYPE ')) {
70
+ const m = line.match(/^# TYPE (\S+) (\S+)$/)
71
+ if (m && !type.has(m[1])) type.set(m[1], m[2])
72
+ continue
73
+ }
74
+ if (line.startsWith('#')) continue // 기타 주석 무시.
75
+ // 샘플: `name{labels} value [timestamp]`. 값 직전 마지막 공백으로 분리(라벨 안 공백 보존).
76
+ const sp = line.lastIndexOf(' ')
77
+ if (sp < 0) continue
78
+ const head = line.slice(0, sp) // name{labels}
79
+ const value = Number(line.slice(sp + 1))
80
+ if (!Number.isFinite(value)) continue
81
+ const brace = head.indexOf('{')
82
+ const name = brace < 0 ? head : head.slice(0, brace)
83
+ const labels = brace < 0 ? '' : head.slice(brace)
84
+ const key = head
85
+ const isInfo = name.endsWith('_info') || name === 'target_info'
86
+ const prev = samples.get(key)
87
+ if (prev === undefined) {
88
+ samples.set(key, { labels, value, name })
89
+ } else if (!isInfo) {
90
+ prev.value += value // info 메타는 첫 값 유지(N배 방지), 나머지는 합산.
91
+ }
92
+ }
93
+ }
94
+
95
+ if (samples.size === 0) return ''
96
+
97
+ // 패밀리(TYPE 이름) 단위로 묶어 출력 — HELP/TYPE 뒤에 해당 패밀리 샘플들.
98
+ const families = [...type.keys()].sort()
99
+ /** @type {Set<string>} 이미 출력한 샘플 키. */
100
+ const emitted = new Set()
101
+ const out = []
102
+ for (const fam of families) {
103
+ if (help.has(fam)) out.push(`# HELP ${fam} ${help.get(fam)}`)
104
+ out.push(`# TYPE ${fam} ${type.get(fam)}`)
105
+ const famSamples = [...samples.entries()]
106
+ .filter(([, s]) => belongsToFamily(s.name, fam))
107
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
108
+ for (const [key, s] of famSamples) {
109
+ out.push(`${key} ${formatValue(s.value)}`)
110
+ emitted.add(key)
111
+ }
112
+ }
113
+ // 패밀리(TYPE)에 안 묶인 고아 샘플 — 정렬해 뒤에 붙인다.
114
+ const orphans = [...samples.entries()].filter(([k]) => !emitted.has(k)).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
115
+ for (const [key, s] of orphans) out.push(`${key} ${formatValue(s.value)}`)
116
+
117
+ return out.join('\n') + '\n'
118
+ }
119
+
120
+ /**
121
+ * 샘플 metric 이름이 패밀리(TYPE 이름)에 속하는지 — 정확히 같거나 히스토그램/서머리 suffix.
122
+ * @param {string} name @param {string} family @returns {boolean}
123
+ */
124
+ function belongsToFamily(name, family) {
125
+ if (name === family) return true
126
+ return ['_bucket', '_sum', '_count', '_created'].some((suf) => name === family + suf)
127
+ }
128
+
129
+ /**
130
+ * 합산 값 직렬화. `String(v)` 가 정수(`8`)·소수(`0.5`)·지수(`1e+21`)를 모두 Prometheus 가 허용하는
131
+ * 형태로 내므로 분기 없이 그대로 쓴다(정수/소수 분기는 동일 결과라 제거 — 사문화 방지).
132
+ * @param {number} v @returns {string}
133
+ */
134
+ function formatValue(v) {
135
+ return String(v)
136
+ }
137
+
138
+ /**
139
+ * 워커 프로세스에 collect 응답기를 설치한다 — 마스터의 `collect` 요청에 자기 `MegaMetrics.collect()` 로 회신.
140
+ * 클러스터 워커가 아니면 no-op. `process.on('message')` 다중 리스너라 MegaCluster shutdown 핸들러와 공존한다.
141
+ *
142
+ * IPC 경로는 fork 워커 안에서만 실행돼 부모 v8 커버리지로 측정 불가다. cluster/process 를 주입 seam 으로
143
+ * 분리해 fake 로 in-process 단위 검증한다(per ADR-165 — `MegaCluster` 와 동일 패턴). 프로덕션 호출부는
144
+ * 인자를 안 넘겨 실 전역을 쓴다.
145
+ * @param {{ _cluster?: import('node:cluster').Cluster, _proc?: NodeJS.Process }} [opts]
146
+ * @returns {void}
147
+ */
148
+ export function installWorkerResponder({ _cluster = cluster, _proc = process } = {}) {
149
+ if (!_cluster.isWorker || typeof _proc.send !== 'function') return
150
+ _proc.on('message', (msg) => {
151
+ const m = /** @type {any} */ (msg)
152
+ if (m?.type !== MSG_COLLECT) return
153
+ // 비동기 collect 를 즉시 회신. 실패는 비치명적(부분 메트릭) — warn 후 빈 텍스트로 회신해 마스터가 안 멈추게.
154
+ Promise.resolve()
155
+ .then(() => MegaMetrics.collect())
156
+ .then((text) => trySend(_proc, { type: MSG_COLLECTED, roundId: m.roundId, pid: _proc.pid, text }))
157
+ .catch((err) => {
158
+ console.warn(`[mega-cluster-metrics] worker ${_proc.pid} collect failed:`, /** @type {any} */ (err)?.message ?? err)
159
+ trySend(_proc, { type: MSG_COLLECTED, roundId: m.roundId, pid: _proc.pid, text: '' })
160
+ })
161
+ })
162
+ }
163
+
164
+ /**
165
+ * 마스터 프로세스에 집계기를 설치한다 — 워커의 `request` 를 받아 전 워커에 fan-out, 회신을 모아 합산해 회신.
166
+ * 마스터가 아니면 no-op. `cluster.on('message')` 로 모든 워커 메시지를 한 핸들러로 받는다.
167
+ *
168
+ * IPC 경로는 마스터 안에서만 실행돼 부모 v8 커버리지로 측정 불가다 — cluster 를 주입 seam 으로 분리해
169
+ * fake 로 in-process 단위 검증한다(per ADR-165).
170
+ * @param {{ timeoutMs?: number, _cluster?: import('node:cluster').Cluster }} [opts] - 라운드 수집 timeout(전원 미응답 시 부분 합산). 기본 2000ms.
171
+ * @returns {void}
172
+ */
173
+ export function installPrimaryAggregator({ timeoutMs = 2000, _cluster = cluster } = {}) {
174
+ if (!_cluster.isPrimary) return
175
+ let roundSeq = 0
176
+ /** @type {Map<number, { replies: Map<number,string>, expected: number, timer: NodeJS.Timeout, requester: import('node:cluster').Worker, reqId: string }>} */
177
+ const rounds = new Map()
178
+
179
+ const finishRound = (/** @type {number} */ roundId) => {
180
+ const round = rounds.get(roundId)
181
+ if (!round) return
182
+ rounds.delete(roundId)
183
+ clearTimeout(round.timer)
184
+ const merged = mergeExposition([...round.replies.values()])
185
+ try {
186
+ round.requester.send({ type: MSG_AGGREGATED, id: round.reqId, text: merged })
187
+ } catch (err) {
188
+ // 요청 워커가 이미 죽음 — 비치명적(스크레이프 1건 유실). silent 금지: warn.
189
+ console.warn('[mega-cluster-metrics] failed to send aggregated metrics to requester:', err?.message ?? err)
190
+ }
191
+ }
192
+
193
+ _cluster.on('message', (worker, msg) => {
194
+ const m = /** @type {any} */ (msg)
195
+ if (m?.type === MSG_REQUEST) {
196
+ const roundId = ++roundSeq
197
+ const workers = Object.values(_cluster.workers ?? {}).filter(Boolean)
198
+ const replies = new Map()
199
+ const timer = setTimeout(() => finishRound(roundId), timeoutMs)
200
+ timer.unref?.()
201
+ let expected = 0
202
+ for (const w of workers) {
203
+ try {
204
+ /** @type {any} */ (w).send({ type: MSG_COLLECT, roundId })
205
+ expected += 1
206
+ } catch (err) {
207
+ // 죽은(disconnecting) 워커엔 못 보냄 — expected 에서 제외돼 라운드는 정상 완료된다. 다만 부분
208
+ // 스크레이프가 됐음을 운영자가 알도록 surface(silent 금지, P4) — requester 송신 실패 warn 과 동형.
209
+ console.warn('[mega-cluster-metrics] skipped a worker (send failed, excluded from round):', /** @type {any} */ (err)?.message ?? err)
210
+ }
211
+ }
212
+ rounds.set(roundId, { replies, expected, timer, requester: worker, reqId: m.id })
213
+ if (expected === 0) finishRound(roundId)
214
+ } else if (m?.type === MSG_COLLECTED) {
215
+ const round = rounds.get(m.roundId)
216
+ if (!round) return
217
+ round.replies.set(m.pid, m.text)
218
+ if (round.replies.size >= round.expected) finishRound(m.roundId)
219
+ }
220
+ })
221
+ }
222
+
223
+ /**
224
+ * 현재 프로세스 기준 메트릭을 수집한다 — 클러스터 워커면 마스터를 통해 **전 워커 합산**, 단일 프로세스면 로컬.
225
+ * `/metrics` 라우트와 `/demo/metrics` 가 쓴다. timeout/마스터 부재 시 로컬 `collect()` 로 폴백(스크레이프가 안
226
+ * 멈추도록).
227
+ * cluster/process 는 주입 seam(테스트용 — per ADR-165). 프로덕션 호출부는 인자를 안 넘겨 실 전역을 쓴다.
228
+ * @param {{ timeoutMs?: number, _cluster?: import('node:cluster').Cluster, _proc?: NodeJS.Process }} [opts] - 응답 대기 timeout(라운드 timeout 보다 넉넉히). 기본 2500ms.
229
+ * @returns {Promise<string>} Prometheus exposition 텍스트.
230
+ */
231
+ export function collectCluster({ timeoutMs = 2500, _cluster = cluster, _proc = process } = {}) {
232
+ // 단일 프로세스(클러스터 워커 아님) → 로컬 수집.
233
+ if (!_cluster.isWorker || typeof _proc.send !== 'function') {
234
+ return MegaMetrics.collect()
235
+ }
236
+ const id = `${_proc.pid}:${++reqSeq}`
237
+ return new Promise((resolve) => {
238
+ let done = false
239
+ const cleanup = () => {
240
+ if (done) return
241
+ done = true
242
+ clearTimeout(timer)
243
+ _proc.off('message', onMsg)
244
+ }
245
+ const onMsg = (/** @type {any} */ msg) => {
246
+ if (msg?.type === MSG_AGGREGATED && msg.id === id) {
247
+ cleanup()
248
+ resolve(typeof msg.text === 'string' ? msg.text : '')
249
+ }
250
+ }
251
+ const timer = setTimeout(() => {
252
+ cleanup()
253
+ // 마스터 무응답 → 로컬 폴백(이 워커 메트릭만이라도).
254
+ Promise.resolve(MegaMetrics.collect())
255
+ .then(resolve)
256
+ .catch(() => resolve(''))
257
+ }, timeoutMs)
258
+ timer.unref?.()
259
+ _proc.on('message', onMsg)
260
+ try {
261
+ _proc.send({ type: MSG_REQUEST, id })
262
+ } catch {
263
+ cleanup()
264
+ Promise.resolve(MegaMetrics.collect())
265
+ .then(resolve)
266
+ .catch(() => resolve(''))
267
+ }
268
+ })
269
+ }
270
+
271
+ /** @private 워커 회신 송신(실패 시 warn — silent 금지). @param {NodeJS.Process} proc @param {object} payload */
272
+ function trySend(proc, payload) {
273
+ try {
274
+ proc.send?.(payload)
275
+ } catch (err) {
276
+ console.warn('[mega-cluster-metrics] worker failed to send metrics reply:', /** @type {any} */ (err)?.message ?? err)
277
+ }
278
+ }