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,653 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaWorker — CPU-heavy 계산을 격리 실행하는 **워커 풀** (정본, ADR-121/124).
4
+ *
5
+ * # ⚠️ `MegaJobWorker`(잡 소비 런타임)와 전혀 다른 추상 (ADR-120/121)
6
+ * {@link import('./mega-job-worker.js').MegaJobWorker} 는 NATS 잡을 **자동 소비**하는 IO-bound 런타임이고,
7
+ * 본 `MegaWorker` 는 CPU-bound 작업을 스레드/프로세스 풀로 **격리**해 `ctx.workers.<name>.run(task)` 로
8
+ * **명시 호출**한다. 이름이 비슷할 뿐 역할이 정반대다(잡 소비 vs CPU 격리). 03-api-spec §6 정본.
9
+ *
10
+ * # 설계 결정 (ADR-124 — 사용자 확정: 추천안 + thread/process 양쪽)
11
+ * - **작업 로직 = `static taskFile`** 의 별도 모듈에 **named export 한 async 함수**(Piscina/workerpool
12
+ * 업계 표준). worker_threads/child_process 는 별도 파일을 실행하고 함수/클로저를 경계 너머로 넘길 수
13
+ * 없어(structured clone/IPC 는 데이터만), 서브클래스 메서드 인라인 대신 파일 경로가 불가피하다.
14
+ * 정본 §6 의 `async run(task)` 인라인 스케치는 본 패턴으로 갱신(ADR-124).
15
+ * - **`static mode = 'thread' | 'process'`** — 둘 다 구현. thread=`worker_threads`(가벼움, 같은
16
+ * 프로세스 메모리 격리), process=`child_process.fork`(완전 격리, 더 무거움). 둘 다 node 빌트인(의존성 0).
17
+ * - **풀 정책** — `static poolSize`(디폴트 `os.cpus().length - 1`, 최소 1). 작업 큐 + 가용 워커에 디스패치.
18
+ * - **crash 자동 재시작** — 워커가 예기치 않게 죽으면 in-flight task 를 `worker.crashed` 로 reject 하고
19
+ * `static maxRestarts`(디폴트 5)까지 교체 워커를 띄운다(MegaJobWorker M-1 패턴 정합).
20
+ * - **graceful shutdown** — `stop()`: 새 `run()` 거부 + 큐 대기분 `worker.stopped` reject + in-flight 완료
21
+ * 대기(allSettled) → 워커 terminate. `MegaShutdown` 통합은 workers-manager 가 배선.
22
+ *
23
+ * @example
24
+ * // workers/image-tasks.js (= static taskFile) — named export 한 async 함수.
25
+ * export async function resize({ src, dst, width }) { ...CPU... ; return out }
26
+ *
27
+ * class ImageProcessor extends MegaWorker {
28
+ * static name = 'image-processor'
29
+ * static taskFile = './workers/image-tasks.js'
30
+ * static mode = 'thread'
31
+ * static poolSize = 4
32
+ * }
33
+ * // 부팅 시 ctx.workers['image-processor'] 자동 배선(workers-manager).
34
+ * const out = await ctx.workers['image-processor'].run('resize', { src, dst, width })
35
+ *
36
+ * @module lib/mega-worker
37
+ * @see ADR-124, ADR-121, 03-api-spec §6
38
+ */
39
+ import { EventEmitter } from 'node:events'
40
+ import os from 'node:os'
41
+ import { Worker } from 'node:worker_threads'
42
+ import { fork } from 'node:child_process'
43
+ import { fileURLToPath } from 'node:url'
44
+ import { isAbsolute, resolve as resolvePath } from 'node:path'
45
+ import { MegaError } from '../errors/mega-error.js'
46
+ import { MegaConfigError } from '../errors/config-error.js'
47
+
48
+ /** 워커가 방출하는 이벤트 화이트리스트(형제 MegaJobWorker 정책). */
49
+ const KNOWN_EVENTS = Object.freeze(['dispatch', 'done', 'fail', 'crash', 'stopped'])
50
+
51
+ /** mode 별 워커 진입점(번들된 generic 러너 — 사용자 taskFile 을 로드해 메시지 루프 구동). */
52
+ const THREAD_ENTRY = new URL('./worker-runner/thread-entry.js', import.meta.url)
53
+ const PROCESS_ENTRY = fileURLToPath(new URL('./worker-runner/process-entry.js', import.meta.url))
54
+
55
+ /** poolSize 디폴트 — 코어 수 - 1(메인 스레드 여유), 최소 1. */
56
+ const DEFAULT_POOL_SIZE = Math.max(1, os.cpus().length - 1)
57
+ /** crash 시 풀 전체에서 허용하는 교체 워커 재시작 총량 디폴트. */
58
+ const DEFAULT_MAX_RESTARTS = 5
59
+
60
+ /**
61
+ * @typedef {object} WorkerHandle - 풀 안의 워커 1개 핸들.
62
+ * @property {number} id - 핸들 식별자(로그/디버그).
63
+ * @property {Worker | import('node:child_process').ChildProcess} native - 실제 thread/process.
64
+ * @property {number | null} current - 처리 중인 taskId(없으면 null).
65
+ * @property {boolean} down - 종료/크래시 처리됨(중복 처리 가드).
66
+ */
67
+
68
+ /**
69
+ * @typedef {object} WorkerTask - 디스패치 대기/진행 중인 task 1건.
70
+ * @property {number} taskId
71
+ * @property {string} taskName
72
+ * @property {any} args
73
+ * @property {{ timeoutMs?: number }} opts
74
+ * @property {(value: any) => void} resolve
75
+ * @property {(err: Error) => void} reject
76
+ * @property {WorkerHandle | null} handle - 배정된 워커(미배정 null).
77
+ * @property {ReturnType<typeof setTimeout> | null} timer - 타임아웃 타이머.
78
+ * @property {boolean} isSettled
79
+ * @property {(() => void) | null} _notify - stop() 의 완료 대기 깨우기.
80
+ */
81
+
82
+ /**
83
+ * CPU 워커 풀. 서브클래스가 `static name`/`static taskFile`/`static mode`/`static poolSize` 를 선언하고,
84
+ * 호출자는 `run(taskName, args, opts)` 로 task 를 디스패치한다.
85
+ */
86
+ export class MegaWorker extends EventEmitter {
87
+ /** @type {string} taskFile 상대경로 해석 기준. */ #projectRoot
88
+ /** @type {string} 해석된 taskFile 절대경로. */ #taskFile
89
+ /** @type {WorkerHandle[]} 전체 워커. */ #pool = []
90
+ /** @type {WorkerHandle[]} 유휴 워커(디스패치 가능). */ #idle = []
91
+ /** @type {WorkerTask[]} 디스패치 대기 큐. */ #queue = []
92
+ /** @type {Map<number, WorkerTask>} taskId → 진행 중 task. */ #inflight = new Map()
93
+ /** @type {boolean} */ #started = false
94
+ /** @type {boolean} */ #stopping = false
95
+ /** @type {number} */ #nextTaskId = 1
96
+ /** @type {number} */ #nextHandleId = 1
97
+ /** @type {number} crash 교체 누적. */ #restarts = 0
98
+ /** @type {number} 진행 중인 교체 spawn 수(일시적 풀 0 상태에서 run 을 큐잉할지 판단). */ #pendingRespawns = 0
99
+
100
+ /**
101
+ * @param {object} [args]
102
+ * @param {string} [args.projectRoot] - `static taskFile` 상대경로 해석 기준(디폴트 process.cwd()).
103
+ * @throws {MegaConfigError} static 설정(taskFile/mode/poolSize)이 잘못됐을 때 — 부팅 fail-fast.
104
+ */
105
+ constructor({ projectRoot } = {}) {
106
+ super()
107
+ this.#projectRoot = projectRoot ?? process.cwd()
108
+ // 잘못된 static 설정은 생성(=부팅) 시점에 즉시 드러낸다(fail-fast — 형제 register 패턴).
109
+ this.#taskFile = this.#resolveTaskFile()
110
+ this.#assertMode()
111
+ this.#assertPoolSize()
112
+ this.#assertMaxRestarts()
113
+ }
114
+
115
+ // ── static 설정 접근자 ─────────────────────────────────────────────────────
116
+
117
+ /** @returns {string} 등록 키(= `static name` 오버라이드 또는 클래스명). */
118
+ get name() {
119
+ return /** @type {any} */ (this.constructor).name
120
+ }
121
+
122
+ /** @returns {'thread' | 'process'} 실행 모드(디폴트 'thread'). */
123
+ get mode() {
124
+ return /** @type {any} */ (this.constructor).mode ?? 'thread'
125
+ }
126
+
127
+ /** @returns {number} 풀 크기. */
128
+ get poolSize() {
129
+ const p = /** @type {any} */ (this.constructor).poolSize
130
+ return typeof p === 'number' ? p : DEFAULT_POOL_SIZE
131
+ }
132
+
133
+ /** @returns {number} crash 교체 허용 총량. */
134
+ get maxRestarts() {
135
+ const r = /** @type {any} */ (this.constructor).maxRestarts
136
+ return Number.isInteger(r) && r >= 0 ? r : DEFAULT_MAX_RESTARTS
137
+ }
138
+
139
+ /** @returns {boolean} start() 후 stop() 전이면 true. */
140
+ get isStarted() {
141
+ return this.#started
142
+ }
143
+
144
+ // ── static 설정 검증(생성 시점 fail-fast) ──────────────────────────────────
145
+
146
+ /** @returns {string} 해석된 taskFile 절대경로. @throws {MegaConfigError} */
147
+ #resolveTaskFile() {
148
+ const tf = /** @type {any} */ (this.constructor).taskFile
149
+ if (typeof tf !== 'string' || tf.trim() === '') {
150
+ throw new MegaConfigError(
151
+ 'worker.missing_task_file',
152
+ `MegaWorker '${this.name}' — static taskFile must be a non-empty string (the worker entry module that named-exports task functions).`,
153
+ { details: { worker: this.name } },
154
+ )
155
+ }
156
+ return isAbsolute(tf) ? tf : resolvePath(this.#projectRoot, tf)
157
+ }
158
+
159
+ /** @throws {MegaConfigError} mode 가 'thread'/'process' 가 아닐 때. */
160
+ #assertMode() {
161
+ const m = this.mode
162
+ if (m !== 'thread' && m !== 'process') {
163
+ throw new MegaConfigError(
164
+ 'worker.invalid_mode',
165
+ `MegaWorker '${this.name}' — static mode must be 'thread' or 'process', got '${m}'.`,
166
+ { details: { worker: this.name, mode: m } },
167
+ )
168
+ }
169
+ }
170
+
171
+ /** @throws {MegaConfigError} poolSize 가 양의 정수가 아닐 때. */
172
+ #assertPoolSize() {
173
+ const p = /** @type {any} */ (this.constructor).poolSize
174
+ if (p !== undefined && (!Number.isInteger(p) || p < 1)) {
175
+ throw new MegaConfigError(
176
+ 'worker.invalid_pool_size',
177
+ `MegaWorker '${this.name}' — static poolSize must be a positive integer, got ${p}.`,
178
+ { details: { worker: this.name, poolSize: p } },
179
+ )
180
+ }
181
+ }
182
+
183
+ /** @throws {MegaConfigError} maxRestarts 가 음이 아닌 정수가 아닐 때. */
184
+ #assertMaxRestarts() {
185
+ const r = /** @type {any} */ (this.constructor).maxRestarts
186
+ if (r !== undefined && (!Number.isInteger(r) || r < 0)) {
187
+ throw new MegaConfigError(
188
+ 'worker.invalid_max_restarts',
189
+ `MegaWorker '${this.name}' — static maxRestarts must be a non-negative integer, got ${r}.`,
190
+ { details: { worker: this.name, maxRestarts: r } },
191
+ )
192
+ }
193
+ }
194
+
195
+ // ── 이벤트 화이트리스트(형제 MegaJobWorker 정책) ──────────────────────
196
+
197
+ /** @param {string} method @param {string} event @throws {RangeError} */
198
+ #assertKnownEvent(method, event) {
199
+ if (!KNOWN_EVENTS.includes(event)) {
200
+ throw new RangeError(
201
+ `MegaWorker.${method}('${event}', ...) — unknown event. Known: ${KNOWN_EVENTS.join(', ')}.`,
202
+ )
203
+ }
204
+ }
205
+
206
+ /** @param {'dispatch'|'done'|'fail'|'crash'|'stopped'} event @param {(payload: any) => void} listener @returns {this} */
207
+ on(event, listener) {
208
+ this.#assertKnownEvent('on', event)
209
+ return super.on(event, listener)
210
+ }
211
+
212
+ /** @param {'dispatch'|'done'|'fail'|'crash'|'stopped'} event @param {(payload: any) => void} listener @returns {this} */
213
+ once(event, listener) {
214
+ this.#assertKnownEvent('once', event)
215
+ return super.once(event, listener)
216
+ }
217
+
218
+ /** @param {'dispatch'|'done'|'fail'|'crash'|'stopped'} event @param {(payload: any) => void} listener @returns {this} */
219
+ off(event, listener) {
220
+ this.#assertKnownEvent('off', event)
221
+ return super.off(event, listener)
222
+ }
223
+
224
+ // ── 라이프사이클 ───────────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * 워커 풀을 띄운다(`poolSize` 개). 각 워커가 taskFile 을 로드해 `{ ready:true }` 를 보낼 때까지 대기한다
228
+ * (race 방지). 멱등 — 이미 start 됐으면 무시. taskFile 로드 실패는 spawn 핸드셰이크에서 fail-fast.
229
+ * @returns {Promise<this>}
230
+ * @throws {MegaError} 전원 spawn 실패 시 근본 원인(`worker.spawn_failed`)을 전파. 풀 **일부만** 떠도
231
+ * 성공분을 terminate 한 뒤 `worker.start_partial` 로 fail-fast(M-1 — 누수 방지).
232
+ */
233
+ async start() {
234
+ if (this.#started) return this
235
+ // M-1: Promise.all 은 첫 reject 시 즉시 끝나 **이미 spawn 된 다른 워커가 누수**(terminate 안 됨)된다.
236
+ // allSettled 로 전부 기다린 뒤, 하나라도 실패하면 성공분을 정리(terminate)하고 fail-fast 한다.
237
+ const results = await Promise.allSettled(
238
+ Array.from({ length: this.poolSize }, () => this.#spawn()),
239
+ )
240
+ /** @type {WorkerHandle[]} */
241
+ const handles = []
242
+ /** @type {unknown[]} */
243
+ const errors = []
244
+ for (const r of results) {
245
+ if (r.status === 'fulfilled') handles.push(r.value)
246
+ else errors.push(r.reason)
247
+ }
248
+ if (handles.length < this.poolSize) {
249
+ // 성공분을 terminate 해 누수 막는다(M-1 핵심 — 일부만 떴을 때만 실제 누수가 생긴다).
250
+ await Promise.allSettled(handles.map((h) => this.#terminate(h)))
251
+ if (handles.length === 0) {
252
+ // 전원 실패는 "부분(partial)" 이 아니다 — 근본 원인(spawn 에러)을 그대로 전파한다(정확성).
253
+ // #spawn 은 항상 MegaError('worker.spawn_failed') 로 reject 하므로 그 코드가 보존된다.
254
+ throw errors[0]
255
+ }
256
+ // 일부만 떴음 → 풀 불완전(genuine partial). fail-fast.
257
+ throw new MegaError(
258
+ 'worker.start_partial',
259
+ `MegaWorker '${this.name}' — worker pool spawn partial failure (${handles.length}/${this.poolSize}).`,
260
+ {
261
+ details: { worker: this.name, spawned: handles.length, poolSize: this.poolSize },
262
+ cause: errors[0],
263
+ },
264
+ )
265
+ }
266
+ for (const h of handles) {
267
+ this.#pool.push(h)
268
+ this.#idle.push(h)
269
+ }
270
+ this.#started = true
271
+ return this
272
+ }
273
+
274
+ /**
275
+ * task 를 워커에 디스패치한다. 유휴 워커가 있으면 즉시, 없으면 큐 대기 후 가용 시 디스패치.
276
+ * @param {string} taskName - taskFile 이 export 한 함수명.
277
+ * @param {any} [args] - 함수 인자(structured clone/IPC 가능한 데이터만 — 함수·클래스 인스턴스 X). 직렬화
278
+ * 불가 인자는 디스패치 시점에 `worker.invalid_args` 로 reject 된다(fail-fast — H-1/M-2, ADR-124 보강).
279
+ * @param {{ timeoutMs?: number }} [opts] - timeoutMs 초과 시 `worker.task_timeout` reject + 워커 교체.
280
+ * @returns {Promise<any>} task 함수 반환값.
281
+ * @throws {MegaError} not_started / stopped / invalid_task / pool_exhausted (즉시 reject) /
282
+ * invalid_args (직렬화 불가 인자 — 디스패치 시점 reject).
283
+ */
284
+ run(taskName, args, opts = {}) {
285
+ // stopping 을 not_started 보다 먼저 본다 — stop() 이 #started=false 로 만들지만, 중단된 워커의 run 은
286
+ // "not started" 가 아니라 "stopped" 가 의미상 맞다(#stopping 은 stop 후 계속 true).
287
+ if (this.#stopping) {
288
+ return Promise.reject(
289
+ new MegaError('worker.stopped', `MegaWorker '${this.name}' — run() after stop().`, {
290
+ details: { worker: this.name },
291
+ }),
292
+ )
293
+ }
294
+ if (!this.#started) {
295
+ return Promise.reject(
296
+ new MegaError('worker.not_started', `MegaWorker '${this.name}' — run() before start().`, {
297
+ details: { worker: this.name },
298
+ }),
299
+ )
300
+ }
301
+ if (typeof taskName !== 'string' || taskName.trim() === '') {
302
+ return Promise.reject(
303
+ new MegaError('worker.invalid_task', `MegaWorker '${this.name}' — taskName must be a non-empty string.`, {
304
+ details: { worker: this.name },
305
+ }),
306
+ )
307
+ }
308
+ if (this.#pool.length === 0 && this.#pendingRespawns === 0) {
309
+ // 모든 워커가 죽고 교체도 진행 중이 아님(한도 소진) → 더는 처리 불가. 교체가 진행 중이면(아래)
310
+ // 큐잉만 하고 respawn 이 완료되면 pump 가 집어간다(일시적 풀 0 race 를 pool_exhausted 로 오인하지 않음).
311
+ return Promise.reject(
312
+ new MegaError('worker.pool_exhausted', `MegaWorker '${this.name}' — no live workers (pool exhausted).`, {
313
+ details: { worker: this.name },
314
+ }),
315
+ )
316
+ }
317
+ return new Promise((resolve, reject) => {
318
+ /** @type {WorkerTask} */
319
+ const task = {
320
+ taskId: this.#nextTaskId++,
321
+ taskName,
322
+ args,
323
+ opts: opts ?? {},
324
+ resolve: (v) => {
325
+ task.isSettled = true
326
+ resolve(v)
327
+ task._notify?.()
328
+ },
329
+ reject: (e) => {
330
+ task.isSettled = true
331
+ reject(e)
332
+ task._notify?.()
333
+ },
334
+ handle: null,
335
+ timer: null,
336
+ isSettled: false,
337
+ _notify: null,
338
+ }
339
+ this.#queue.push(task)
340
+ this.#pump()
341
+ })
342
+ }
343
+
344
+ /**
345
+ * graceful shutdown — 새 run() 거부 + 큐 대기분 `worker.stopped` reject + in-flight 완료 대기 → terminate.
346
+ * `MegaShutdown` 정리 경로에서 호출된다(workers-manager 가 `register('workers:stop', ...)` 배선).
347
+ * @returns {Promise<this>}
348
+ */
349
+ async stop() {
350
+ this.#stopping = true
351
+ // 1) 아직 시작 안 한 큐 대기분은 즉시 reject(in-flight 만 완료 대기 — stop 시간 bound).
352
+ for (const task of this.#queue.splice(0)) {
353
+ task.reject(
354
+ new MegaError('worker.stopped', `MegaWorker '${this.name}' stopped before task '${task.taskName}' started.`, {
355
+ details: { worker: this.name, taskName: task.taskName },
356
+ }),
357
+ )
358
+ }
359
+ // 2) in-flight 완료 대기 — 각 task settle 시 _notify 로 깨운다.
360
+ const waits = [...this.#inflight.values()].map(
361
+ (t) =>
362
+ new Promise((res) => {
363
+ t._notify = /** @type {() => void} */ (res)
364
+ if (t.isSettled) res(undefined)
365
+ }),
366
+ )
367
+ await Promise.allSettled(waits)
368
+ // 3) 워커 terminate(중복 down 가드 후).
369
+ await Promise.allSettled(this.#pool.map((h) => this.#terminate(h)))
370
+ this.#pool = []
371
+ this.#idle = []
372
+ this.#started = false
373
+ this.emit('stopped', { worker: this.name })
374
+ return this
375
+ }
376
+
377
+ // ── 내부: 디스패치 / 풀 관리 ───────────────────────────────────────────────
378
+
379
+ /** 큐와 유휴 워커가 둘 다 있으면 배정한다. */
380
+ #pump() {
381
+ while (this.#queue.length > 0 && this.#idle.length > 0) {
382
+ const task = /** @type {WorkerTask} */ (this.#queue.shift())
383
+ const handle = /** @type {WorkerHandle} */ (this.#idle.shift())
384
+ this.#assign(handle, task)
385
+ }
386
+ }
387
+
388
+ /** @param {WorkerHandle} handle @param {WorkerTask} task */
389
+ #assign(handle, task) {
390
+ handle.current = task.taskId
391
+ task.handle = handle
392
+ this.#inflight.set(task.taskId, task)
393
+ if (typeof task.opts.timeoutMs === 'number' && task.opts.timeoutMs > 0) {
394
+ const timer = setTimeout(() => this.#onTimeout(handle, task), task.opts.timeoutMs)
395
+ timer.unref()
396
+ task.timer = timer
397
+ }
398
+ try {
399
+ // ⚠️ H-1/M-2: 직렬화 불가 인자(함수·클래스 인스턴스 등)는 #send 가 **동기 throw** 한다
400
+ // (thread=structured clone DataCloneError, process=advanced serialization Error — 둘 다 실측 확인).
401
+ // 롤백 없이 두면 좀비 task 가 #inflight 에 영원히 남아 stop() 이 in-flight 대기에서 영구 hang(H-1).
402
+ // → in-flight·timer·핸들을 원복하고 task 를 worker.invalid_args 로 명시 reject(fail-fast).
403
+ // 워커 자체는 멀쩡하므로 유휴로 되돌려 다음 큐를 계속 처리한다.
404
+ this.#send(handle, { id: task.taskId, taskName: task.taskName, args: task.args })
405
+ } catch (sendErr) {
406
+ this.#inflight.delete(task.taskId)
407
+ if (task.timer) {
408
+ clearTimeout(task.timer)
409
+ task.timer = null
410
+ }
411
+ handle.current = null
412
+ task.handle = null
413
+ // 핸들은 살아있으니 유휴 복귀(stop 중이면 복귀 안 함 — #release 와 동일 정책). 복귀하면 #pump 의
414
+ // while 가 다음 반복에서 이 핸들을 다시 집어 큐를 진행한다(여기서 재귀 #pump 호출은 불필요).
415
+ if (!this.#stopping) this.#idle.push(handle)
416
+ const reason = sendErr instanceof Error ? sendErr.message : String(sendErr)
417
+ task.reject(
418
+ new MegaError('worker.invalid_args', `MegaWorker '${this.name}' task '${task.taskName}' — arguments are not serializable: ${reason}`, {
419
+ details: { worker: this.name, taskName: task.taskName },
420
+ cause: sendErr,
421
+ }),
422
+ )
423
+ return
424
+ }
425
+ this.emit('dispatch', { worker: this.name, taskId: task.taskId, taskName: task.taskName })
426
+ }
427
+
428
+ /** 워커를 유휴로 되돌리고 다음 큐를 펌프한다. @param {WorkerHandle} handle */
429
+ #release(handle) {
430
+ handle.current = null
431
+ if (!this.#stopping) this.#idle.push(handle)
432
+ this.#pump()
433
+ }
434
+
435
+ /**
436
+ * 워커가 보낸 task 결과 메시지 처리. @param {WorkerHandle} handle @param {any} m `{ id, ok, result, error }`.
437
+ */
438
+ #onMessage(handle, m) {
439
+ if (!m || typeof m.id !== 'number') return // ready 등 비-task 메시지 무시.
440
+ const task = this.#inflight.get(m.id)
441
+ if (task === undefined) return
442
+ this.#inflight.delete(m.id)
443
+ if (task.timer) clearTimeout(task.timer)
444
+ this.#release(handle)
445
+ if (m.ok) {
446
+ this.emit('done', { worker: this.name, taskId: m.id, taskName: task.taskName })
447
+ task.resolve(m.result)
448
+ } else {
449
+ this.emit('fail', { worker: this.name, taskId: m.id, taskName: task.taskName, error: m.error })
450
+ task.reject(this.#rebuildError('worker.task_failed', task, m.error))
451
+ }
452
+ }
453
+
454
+ /**
455
+ * 워커가 예기치 않게 죽음(error/비정상 exit). in-flight reject + crash 이벤트 + 교체 재시작.
456
+ * @param {WorkerHandle} handle @param {Error} err
457
+ */
458
+ #onWorkerDown(handle, err) {
459
+ if (handle.down) return // error→exit 이중 발화 가드.
460
+ handle.down = true
461
+ this.#removeHandle(handle)
462
+ if (handle.current !== null) {
463
+ const task = this.#inflight.get(handle.current)
464
+ if (task) {
465
+ this.#inflight.delete(handle.current)
466
+ if (task.timer) clearTimeout(task.timer)
467
+ task.reject(
468
+ new MegaError('worker.crashed', `MegaWorker '${this.name}' worker crashed: ${err.message}`, {
469
+ details: { worker: this.name, taskName: task.taskName },
470
+ cause: err,
471
+ }),
472
+ )
473
+ }
474
+ }
475
+ this.emit('crash', { worker: this.name, error: err.message })
476
+ this.#scheduleRespawn()
477
+ this.#drainIfDead()
478
+ }
479
+
480
+ /** 비정상 exit 처리(정상 종료 code 0 는 stop 경로에서만 기대). @param {WorkerHandle} handle @param {number|null} code */
481
+ #onExit(handle, code) {
482
+ if (this.#stopping || handle.down) return // 종료 중 terminate 로 인한 exit 은 정상.
483
+ this.#onWorkerDown(handle, new Error(`worker exited unexpectedly (code ${code})`))
484
+ }
485
+
486
+ /** 타임아웃 — task reject + 멈춘 워커 교체. @param {WorkerHandle} handle @param {WorkerTask} task */
487
+ #onTimeout(handle, task) {
488
+ if (task.isSettled || !this.#inflight.has(task.taskId)) return
489
+ this.#inflight.delete(task.taskId)
490
+ task.reject(
491
+ new MegaError('worker.task_timeout', `MegaWorker '${this.name}' task '${task.taskName}' timed out after ${task.opts.timeoutMs}ms.`, {
492
+ details: { worker: this.name, taskName: task.taskName, timeoutMs: task.opts.timeoutMs },
493
+ }),
494
+ )
495
+ // 멈춘 워커는 회수 불가 — 교체로 처리(down 처리 + 재시작).
496
+ handle.down = true
497
+ this.#removeHandle(handle)
498
+ void this.#terminate(handle)
499
+ this.emit('crash', { worker: this.name, error: `task '${task.taskName}' timeout — worker recycled` })
500
+ this.#scheduleRespawn()
501
+ this.#drainIfDead()
502
+ }
503
+
504
+ /**
505
+ * crash/timeout 으로 죽은 워커를 교체한다 — 종료 중이 아니고 재시작 한도(`maxRestarts`) 내일 때만(
506
+ * M-1 패턴). spawn 은 비동기라 완료 시 풀에 넣고 pump. spawn 실패도 묵살하지 않는다(fail 이벤트).
507
+ * @returns {void}
508
+ */
509
+ #scheduleRespawn() {
510
+ if (this.#stopping || this.#restarts >= this.maxRestarts) return
511
+ this.#restarts++
512
+ this.#pendingRespawns++
513
+ this.#spawn()
514
+ .then((h) => {
515
+ this.#pendingRespawns--
516
+ if (this.#stopping) {
517
+ void this.#terminate(h)
518
+ return
519
+ }
520
+ this.#pool.push(h)
521
+ this.#release(h)
522
+ })
523
+ .catch((e) => {
524
+ this.#pendingRespawns--
525
+ this.emit('fail', { worker: this.name, error: `respawn failed: ${e?.message ?? e}` })
526
+ this.#drainIfDead()
527
+ })
528
+ }
529
+
530
+ /**
531
+ * 풀이 완전히 비고 교체 spawn 도 더 없으면(한도 소진/실패) 큐 대기 task 들을 `pool_exhausted` 로
532
+ * 비운다 — 영영 디스패치 안 될 task 가 hang 하지 않게(silent stall 금지).
533
+ * @returns {void}
534
+ */
535
+ #drainIfDead() {
536
+ if (this.#stopping) return
537
+ if (this.#pool.length === 0 && this.#pendingRespawns === 0) {
538
+ for (const task of this.#queue.splice(0)) {
539
+ task.reject(
540
+ new MegaError('worker.pool_exhausted', `MegaWorker '${this.name}' — pool exhausted (all workers died, no respawn left).`, {
541
+ details: { worker: this.name, taskName: task.taskName },
542
+ }),
543
+ )
544
+ }
545
+ }
546
+ }
547
+
548
+ /** @param {WorkerHandle} handle 풀·유휴 목록에서 제거. */
549
+ #removeHandle(handle) {
550
+ const pi = this.#pool.indexOf(handle)
551
+ if (pi !== -1) this.#pool.splice(pi, 1)
552
+ const ii = this.#idle.indexOf(handle)
553
+ if (ii !== -1) this.#idle.splice(ii, 1)
554
+ }
555
+
556
+ // ── 내부: thread/process 추상화 ────────────────────────────────────────────
557
+
558
+ /**
559
+ * 워커 1개를 띄우고 `{ ready:true }` 핸드셰이크까지 기다린다. 영속 리스너는 ready 후 부착.
560
+ * @returns {Promise<WorkerHandle>} @throws {MegaError} ready 전 죽으면 `worker.spawn_failed`.
561
+ */
562
+ async #spawn() {
563
+ /** @type {WorkerHandle} */
564
+ const handle = { id: this.#nextHandleId++, native: /** @type {any} */ (null), current: null, down: false }
565
+ const native =
566
+ this.mode === 'thread'
567
+ ? new Worker(THREAD_ENTRY, { workerData: { taskFile: this.#taskFile } })
568
+ : // serialization:'advanced' → IPC 가 V8 직렬화기를 써서 (1) thread 의 structured clone 과 동일하게
569
+ // Date/Map/Set/BigInt 등을 보존하고, (2) 함수 등 직렬화 불가 인자에 process.send 가 **동기 throw**
570
+ // 한다(기본 json 은 함수를 silent drop → M-2 의 silent-wrong). 둘 다 #assign 의 try/catch 가 잡아
571
+ // worker.invalid_args 로 fail-fast. 자식 채널 모드는 fork 시 자동 전파(NODE_CHANNEL_SERIALIZATION_MODE).
572
+ fork(PROCESS_ENTRY, [this.#taskFile], {
573
+ stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
574
+ serialization: 'advanced',
575
+ })
576
+ handle.native = native
577
+
578
+ await new Promise((resolve, reject) => {
579
+ const onMsg = (/** @type {any} */ m) => {
580
+ if (m && m.ready) {
581
+ cleanup()
582
+ resolve(undefined)
583
+ }
584
+ }
585
+ const onErr = (/** @type {any} */ e) => {
586
+ cleanup()
587
+ reject(
588
+ new MegaError('worker.spawn_failed', `MegaWorker '${this.name}' worker failed to start: ${e?.message ?? e}`, {
589
+ details: { worker: this.name, taskFile: this.#taskFile },
590
+ cause: e instanceof Error ? e : new Error(String(e)),
591
+ }),
592
+ )
593
+ }
594
+ const onExit = (/** @type {any} */ code) => {
595
+ cleanup()
596
+ reject(
597
+ new MegaError('worker.spawn_failed', `MegaWorker '${this.name}' worker exited before ready (code ${code}).`, {
598
+ details: { worker: this.name, taskFile: this.#taskFile, code },
599
+ }),
600
+ )
601
+ }
602
+ const cleanup = () => {
603
+ native.removeListener('message', onMsg)
604
+ native.removeListener('error', onErr)
605
+ native.removeListener('exit', onExit)
606
+ }
607
+ native.on('message', onMsg)
608
+ native.on('error', onErr)
609
+ native.on('exit', onExit)
610
+ })
611
+
612
+ // 정상 가동 — 영속 리스너 부착.
613
+ native.on('message', (/** @type {any} */ m) => this.#onMessage(handle, m))
614
+ native.on('error', (/** @type {any} */ e) => this.#onWorkerDown(handle, e instanceof Error ? e : new Error(String(e))))
615
+ native.on('exit', (/** @type {any} */ code) => this.#onExit(handle, /** @type {number|null} */ (code)))
616
+ return handle
617
+ }
618
+
619
+ /** @param {WorkerHandle} handle @param {any} msg 부모→워커 메시지 전송(채널 추상화). */
620
+ #send(handle, msg) {
621
+ if (this.mode === 'thread') {
622
+ /** @type {Worker} */ (handle.native).postMessage(msg)
623
+ } else {
624
+ /** @type {import('node:child_process').ChildProcess} */ (handle.native).send(msg)
625
+ }
626
+ }
627
+
628
+ /** @param {WorkerHandle} handle 워커 종료(thread terminate / process kill). @returns {Promise<void>} */
629
+ async #terminate(handle) {
630
+ handle.down = true // terminate 로 인한 exit 을 crash 로 오인하지 않게.
631
+ if (this.mode === 'thread') {
632
+ await /** @type {Worker} */ (handle.native).terminate()
633
+ } else {
634
+ /** @type {import('node:child_process').ChildProcess} */ (handle.native).kill()
635
+ }
636
+ }
637
+
638
+ /**
639
+ * 워커가 직렬화해 보낸 error 를 MegaError 로 복원한다(name/message/stack/code 보존을 cause 로).
640
+ * @param {string} code @param {WorkerTask} task @param {{ name?: string, message?: string, stack?: string, code?: string }} [serialized]
641
+ * @returns {MegaError}
642
+ */
643
+ #rebuildError(code, task, serialized) {
644
+ const cause = new Error(serialized?.message ?? 'worker task failed')
645
+ if (serialized?.name) cause.name = serialized.name
646
+ if (serialized?.stack) cause.stack = serialized.stack
647
+ if (serialized?.code) /** @type {any} */ (cause).code = serialized.code
648
+ return new MegaError(code, `MegaWorker '${this.name}' task '${task.taskName}' failed: ${serialized?.message ?? 'unknown error'}`, {
649
+ details: { worker: this.name, taskName: task.taskName },
650
+ cause,
651
+ })
652
+ }
653
+ }
@@ -0,0 +1,30 @@
1
+ // @ts-check
2
+ /**
3
+ * `child_process` 진입점 (mode:'process') — `MegaWorker` 가 `fork(process-entry, [taskFile], { ipc })` 로
4
+ * 띄운다 (ADR-124). thread-entry 와 동형이나 채널이 IPC(`process.send`/`process.on`)이고
5
+ * taskFile 을 `process.argv[2]` 로 받는다. 메모리·V8 완전 격리가 필요할 때 mode:'process' 를 쓴다(스레드보다
6
+ * 무겁지만 한 task 가 프로세스를 죽여도 나머지 격리).
7
+ *
8
+ * IPC 채널은 부모가 `serialization:'advanced'`(V8 직렬화기)로 fork 하므로 thread 의 structured clone 과
9
+ * 동일하게 Date/Map/Set/BigInt 등을 보존한다(자식 채널 모드는 `NODE_CHANNEL_SERIALIZATION_MODE` 로 자동
10
+ * 전파). 함수·심볼 등 직렬화 불가 값은 송신 시 동기 throw → 부모가 worker.invalid_args 로 fail-fast(ADR-124 보강).
11
+ *
12
+ * @module lib/worker-runner/process-entry
13
+ */
14
+ import { loadTaskModule, handleTaskMessage } from './task-dispatch.js'
15
+
16
+ const taskFile = process.argv[2]
17
+ if (typeof taskFile !== 'string' || taskFile.length === 0) {
18
+ throw new Error('process-entry.js requires a taskFile path as argv[2].')
19
+ }
20
+ if (typeof process.send !== 'function') {
21
+ throw new Error('process-entry.js must be forked with an IPC channel (process.send is undefined).')
22
+ }
23
+
24
+ const send = process.send.bind(process)
25
+ const mod = await loadTaskModule(taskFile)
26
+ process.on('message', (msg) => {
27
+ void handleTaskMessage(mod, msg, send)
28
+ })
29
+ // 로드 완료 신호 — 부모는 이 메시지를 받고서야 task 를 디스패치한다(race 방지).
30
+ send({ ready: true })