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,525 @@
1
+ // @ts-check
2
+ /**
3
+ * MegaApp i18n(국제화) 자동 등록 — `i18next` 통합.
4
+ *
5
+ * # 무엇인가 (중학생용 설명)
6
+ * 웹 앱을 여러 나라 말로 보여주려면 "키 → 번역문" 사전이 필요하다. `user.not_found` 같은 키를 주면
7
+ * 한국어면 "사용자를 찾을 수 없습니다", 영어면 "User not found" 를 돌려준다. 본 모듈은 검증된 표준
8
+ * 라이브러리 `i18next`(per ADR-037) 를 앱마다 한 인스턴스 띄우고, 그 위에 **요청별 언어 결정(쿠키)**·
9
+ * **scope 분리(server/client)**·**관측성(트레이싱·메트릭)**·**개발 모드 자동 키 생성(saveMissing)** 을 얹는다.
10
+ *
11
+ * # 동작 한눈에 (session.js / multipart.js 형제 패턴)
12
+ * 1. `registerI18n(fastify, { i18n })` — `i18n` config 가 있을 때만 옵트인 등록.
13
+ * 2. `locales/<scope>/<lng>.json`(디렉터리) + inline `i18n.resources` 를 i18next 인스턴스로 로드.
14
+ * 3. onRequest hook — **쿠키만으로 언어 결정**(ADR-038). `req.lang` + `req.t`(server scope 번역기) +
15
+ * `req.setLocale(lng)`(쿠키 발급) + `req.translations(scope)`(client 번들) 를 요청에 부착.
16
+ * 4. ctx-builder 가 `req.lang`/`req.t` 를 `ctx.lang`/`ctx.t` 로 노출(canonical, docs/03 §632).
17
+ * 5. (옵션) `GET /i18n/translations` — **client scope 만** 노출(ADR-039 — 서버 키가 SPA 로 새지 않게).
18
+ *
19
+ * # scope 분리 (ADR-039)
20
+ * scope 를 i18next **namespace** 로 매핑한다 — `server`(에러·검증 메시지, defaultNS)·`client`(SPA UI).
21
+ * `ctx.t()` 는 server scope 자동 선택. HTTP 번역 endpoint 는 client scope 만 돌려줘 서버 전용 키가
22
+ * 클라 번들에 노출되지 않게 한다(보안·번들 크기).
23
+ *
24
+ * # locale 결정 = 쿠키만 (ADR-038)
25
+ * query·`Accept-Language` 헤더 미사용. 언어 변경은 명시적 API(`req.setLocale`)로 서버가 쿠키 발급.
26
+ * 동작이 한 곳(쿠키)에서만 결정돼 예측 가능. 쿠키값이 `available` 에 없으면 fallback 으로 클램프.
27
+ *
28
+ * # saveMissing (명시적 개발 모드 한정 `NODE_ENV==='development'`, ADR-037/164 — axion 패턴)
29
+ * dev 에서 코드가 새 키를 부르면 locale 파일에 자동 추가한다: 500ms 디바운스(같은 키 폭주 흡수) →
30
+ * 기존 키 보존 deep-merge → tmp 작성 → rename(원자적 쓰기) → `timer.unref()`(프로세스 종료 안 막음).
31
+ * 누락 키는 **설정된 모든 available 언어**(예: ko·en) 파일에 기입한다(ADR-164) — 키가 전 언어에 존재해야
32
+ * 다음 요청에서 재트리거되지 않는다(재오염 루프 차단). 기입 값은 defaultValue, 없으면 **키 이름**(모든
33
+ * 언어 동일 → 개발자가 이후 번역). 또한 init 시 **언어 간 키 parity 보정**(`reconcileLocaleKeys`)으로
34
+ * 한 언어에만 있는 키(예: en 만 있고 ko 없음)를 나머지 언어에 **fallback 값 우선**으로 채운다 — i18next 가
35
+ * fallback 으로 찾아 missingKeyHandler 가 안 터지는 공백을 메운다. **자동 기입은 `NODE_ENV==='development'`
36
+ * 에서만** — test/CI/prod/미설정에선 off 라 추적 locale 파일을 디스크 기입하지 않는다(ADR-164, 테스트 오염 방지).
37
+ *
38
+ * # 트레이싱·메트릭 (ADR-126 / ADR-131 인프라 재사용)
39
+ * - 트레이싱: onRequest 가 활성 HTTP span 에 `mega.i18n.lang` 기록. 옵트인 OFF 면 0 비용 no-op.
40
+ * - 메트릭: `mega_i18n_events_total{app,lang,scope,event=request|missing}` — 요청당 1건(언어 분포) +
41
+ * 누락 키(dev 관측). lang/scope 는 bounded 라 카디널리티 안전.
42
+ *
43
+ * # CSRF·ASP 통합
44
+ * locale 쿠키(`mega.lang`)는 보안 토큰이 아니다(httpOnly:false — SPA 가 읽을 수 있게). 번역 endpoint 는
45
+ * GET 이라 CSRF(폼 mutation) 대상이 아니고, ASP enabledPaths 에 없으면 평문. **별도 보안 배선 불필요**.
46
+ *
47
+ * @module core/i18n
48
+ * @see ADR-037 (i18next 채택), ADR-038 (쿠키만), ADR-039 (scope 분리), ADR-135
49
+ * @see https://www.i18next.com/
50
+ */
51
+ import { readFileSync, existsSync } from 'node:fs'
52
+ import { writeFile, rename, mkdir } from 'node:fs/promises'
53
+ import { join, dirname } from 'node:path'
54
+ import i18next from 'i18next'
55
+ import { MegaValidationError } from '../errors/http-errors.js'
56
+ import * as MegaTracing from '../lib/mega-tracing.js'
57
+ import { recordI18n } from '../lib/mega-metrics.js'
58
+
59
+ /** locale 쿠키 기본 이름 (04-data-models §394 `cookieName: 'mega.lang'`). */
60
+ export const DEFAULT_I18N_COOKIE = 'mega.lang'
61
+
62
+ /** scope 분리 고정 namespace 2종 (ADR-039). server=에러·검증, client=SPA UI. */
63
+ export const I18N_SCOPES = Object.freeze(['server', 'client'])
64
+
65
+ /** server scope = 기본 namespace — `ctx.t()` 가 자동 선택(ADR-039). */
66
+ export const DEFAULT_SCOPE = 'server'
67
+
68
+ /** saveMissing 디바운스 기본값 (ms, ADR-037 axion 패턴). */
69
+ export const DEFAULT_DEBOUNCE_MS = 500
70
+
71
+ /** locale 쿠키 기본 수명 (1년, 초). 보안 토큰 아니라 길게 — 사용자 언어 선호 유지. */
72
+ const DEFAULT_COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60
73
+
74
+ /**
75
+ * i18n 등록 결과 요약(디버그·테스트용).
76
+ * @typedef {Object} I18nSummary
77
+ * @property {boolean} enabled - 등록 여부.
78
+ * @property {string} default - 기본 언어.
79
+ * @property {string} fallback - fallback 언어.
80
+ * @property {string[]} available - 지원 언어 목록.
81
+ * @property {string} cookieName - locale 쿠키 이름.
82
+ * @property {boolean} autoComplete - saveMissing(dev 자동 키 생성) 활성 여부.
83
+ * @property {import('i18next').i18n | null} instance - 생성된 i18next 인스턴스(미등록 시 null).
84
+ */
85
+
86
+ /**
87
+ * 정규화된 i18n 설정.
88
+ * @typedef {Object} NormalizedI18n
89
+ * @property {string} default
90
+ * @property {string} fallback
91
+ * @property {string[]} available
92
+ * @property {string} cookieName
93
+ * @property {{ enabled: boolean, dir: string|null, debounceMs: number }} autoComplete
94
+ * @property {string|null} localesDir
95
+ * @property {Record<string, any>} resources
96
+ * @property {boolean} exposeTranslations
97
+ * @property {string} translationsPath
98
+ */
99
+
100
+ /**
101
+ * 앱 `i18n` config 를 정규화한다. falsy 면 미옵트인(`null`).
102
+ *
103
+ * 잘못된 형태는 **fail-fast** 로 throw 한다(silent 보정 X). `available` 이 배열이 아니거나 비었으면
104
+ * 부팅 에러로 드러내, locale 미설정이 런타임에 조용히 fallback 으로 뭉개지지 않게 한다.
105
+ *
106
+ * @param {unknown} i18n - `MegaI18nAppConfig`(default/available/fallback/cookieName/autoComplete/...).
107
+ * @returns {NormalizedI18n | null}
108
+ * @throws {Error} `available` 가 비-배열/빈 배열 등 명백한 오설정.
109
+ */
110
+ export function normalizeI18n(i18n) {
111
+ if (!i18n || typeof i18n !== 'object') return null
112
+ const c = /** @type {Record<string, any>} */ (i18n)
113
+
114
+ const available = Array.isArray(c.available) ? c.available.filter((/** @type {unknown} */ l) => typeof l === 'string' && l.length > 0) : []
115
+ if (available.length === 0) {
116
+ throw new Error("registerI18n: i18n.available must be a non-empty string[] (e.g. ['ko', 'en']) — locale set is required (ADR-038).")
117
+ }
118
+ const def = typeof c.default === 'string' && available.includes(c.default) ? c.default : available[0]
119
+ const fallback = typeof c.fallback === 'string' && available.includes(c.fallback) ? c.fallback : def
120
+ const cookieName = typeof c.cookieName === 'string' && c.cookieName.length > 0 ? c.cookieName : DEFAULT_I18N_COOKIE
121
+
122
+ const ac = c.autoComplete && typeof c.autoComplete === 'object' ? c.autoComplete : {}
123
+ // 자동 기입은 **명시적 개발 모드**(`NODE_ENV==='development'`)에서만 — test/CI/미설정(undefined)에서 켜면
124
+ // vitest 부팅이 추적된 locale 파일을 디스크 기입해 오염시킨다(ADR-164). 샘플은 (a)로 dev=`NODE_ENV=development`,
125
+ // start=`production` 이라 정확히 맞물린다. 명시 `autoComplete.enabled` 가 있으면 그것을 우선.
126
+ const autoEnabled = ac.enabled !== undefined ? ac.enabled === true : process.env.NODE_ENV === 'development'
127
+ const debounceMs = Number.isInteger(ac.debounceMs) && ac.debounceMs >= 0 ? ac.debounceMs : DEFAULT_DEBOUNCE_MS
128
+
129
+ // localesDir: 명시 우선, 없으면 autoComplete.dir. 디렉터리 로딩·saveMissing 쓰기의 기준 경로.
130
+ const localesDir = typeof c.localesDir === 'string' && c.localesDir.length > 0 ? c.localesDir : typeof ac.dir === 'string' && ac.dir.length > 0 ? ac.dir : null
131
+
132
+ const resources = c.resources && typeof c.resources === 'object' ? c.resources : {}
133
+ const exposeTranslations = c.exposeTranslations !== false // 디폴트 ON(ADR-039 client endpoint).
134
+ const translationsPath = typeof c.translationsPath === 'string' && c.translationsPath.length > 0 ? c.translationsPath : '/i18n/translations'
135
+
136
+ return { default: def, fallback, available, cookieName, autoComplete: { enabled: autoEnabled, dir: localesDir, debounceMs }, localesDir, resources, exposeTranslations, translationsPath }
137
+ }
138
+
139
+ /**
140
+ * `<dir>/<scope>/<lng>.json` 디렉터리 구조에서 locale 리소스를 로드한다(ADR-039 scope 분리 레이아웃,
141
+ * 04-data-models §768). 파일이 없으면 건너뛴다(빈 namespace). 부팅 시 1회 동기 읽기.
142
+ *
143
+ * @param {string|null} dir - locale 루트 디렉터리. null 이면 빈 리소스.
144
+ * @param {object} opts
145
+ * @param {string[]} opts.available - 로드할 언어 목록.
146
+ * @param {{ warn?: Function }} [opts.logger]
147
+ * @returns {Record<string, Record<string, object>>} `{ <lng>: { <scope>: {...} } }` i18next resources 형태.
148
+ */
149
+ export function loadLocaleResources(dir, { available, logger } = /** @type {any} */ ({})) {
150
+ /** @type {Record<string, Record<string, object>>} */
151
+ const resources = {}
152
+ if (!dir || !Array.isArray(available)) return resources
153
+ for (const lng of available) {
154
+ for (const scope of I18N_SCOPES) {
155
+ const file = join(dir, scope, `${lng}.json`)
156
+ if (!existsSync(file)) continue
157
+ try {
158
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
159
+ if (parsed && typeof parsed === 'object') {
160
+ resources[lng] = resources[lng] ?? {}
161
+ resources[lng][scope] = parsed
162
+ }
163
+ } catch (err) {
164
+ // 손상된 locale 파일은 부팅을 막지 않되 조용히 넘기지 않는다 — warn 으로 드러내고 빈 namespace.
165
+ logger?.warn?.({ file, err }, 'i18n.locale_parse_failed')
166
+ }
167
+ }
168
+ }
169
+ return resources
170
+ }
171
+
172
+ /**
173
+ * 두 resources 트리를 깊게 병합한다(`base` 위에 `override`). 디스크 파일(base) + inline config(override)
174
+ * 결합용 — inline 이 같은 키를 덮어쓴다.
175
+ * @param {Record<string, any>} base @param {Record<string, any>} override @returns {Record<string, any>}
176
+ */
177
+ function mergeResources(base, override) {
178
+ /** @param {any} a @param {any} b @returns {any} */
179
+ const deep = (a, b) => {
180
+ if (b === null || typeof b !== 'object' || Array.isArray(b)) return b
181
+ const out = a && typeof a === 'object' && !Array.isArray(a) ? { ...a } : {}
182
+ for (const k of Object.keys(b)) out[k] = deep(out[k], b[k])
183
+ return out
184
+ }
185
+ return deep(base, override)
186
+ }
187
+
188
+ /**
189
+ * dot-notation 키를 중첩 객체에 set 한다(기존 키 보존). saveMissing 머지용.
190
+ * @param {Record<string, any>} obj @param {string} key - `a.b.c` @param {string} value @returns {void}
191
+ */
192
+ function setNested(obj, key, value) {
193
+ const parts = key.split('.')
194
+ let node = obj
195
+ for (let i = 0; i < parts.length - 1; i++) {
196
+ const p = parts[i]
197
+ if (node[p] === null || typeof node[p] !== 'object') node[p] = {}
198
+ node = node[p]
199
+ }
200
+ node[parts[parts.length - 1]] = value
201
+ }
202
+
203
+ /**
204
+ * 중첩 locale 객체를 `dot.path → 문자열값` 맵으로 평탄화한다(leaf 문자열만; 중첩 객체는 재귀).
205
+ * @param {Record<string, any>} obj @param {string} [prefix] @param {Map<string,string>} [out]
206
+ * @returns {Map<string,string>}
207
+ */
208
+ function flattenLeafKeys(obj, prefix = '', out = new Map()) {
209
+ if (!obj || typeof obj !== 'object') return out
210
+ for (const k of Object.keys(obj)) {
211
+ const v = obj[k]
212
+ const path = prefix ? `${prefix}.${k}` : k
213
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) flattenLeafKeys(v, path, out)
214
+ else if (typeof v === 'string') out.set(path, v)
215
+ }
216
+ return out
217
+ }
218
+
219
+ /**
220
+ * dev 언어 간 키 parity 보정 — **한 언어에만 있는 키를 나머지 모든 available 언어에 채운다**(ADR-164).
221
+ *
222
+ * i18next 는 ko 에 없고 en 에만 있는 키를 `fallbackLng` 로 찾아 en 값을 돌려주지만, 이를 "누락"으로 보지
223
+ * 않아 `missingKeyHandler` 가 발화하지 않는다(소스 확인) → ko.json 이 영영 안 채워진다. 그래서 init 전
224
+ * 병합된 resources 에서 scope 별 키 합집합을 구해, 키가 빠진 언어에 **fallback 언어 값 우선**(없으면 그 키를
225
+ * 가진 첫 available 언어 값)으로 채운다. in-memory 반영(즉시 `t()` 가 그 값 반환) + writer 있으면 디스크
226
+ * 기입(다음 부팅 idempotent). prod 는 호출 안 함(resources 원본 그대로).
227
+ *
228
+ * @param {Record<string, Record<string, any>>} resources - `{ <lng>: { <scope>: {...} } }`.
229
+ * @param {object} opts
230
+ * @param {string[]} opts.available
231
+ * @param {string} opts.fallback
232
+ * @param {readonly string[]} opts.scopes
233
+ * @param {ReturnType<typeof createMissingKeyWriter>|null} opts.writer
234
+ * @returns {Record<string, Record<string, any>>} 보정된 resources(입력 불변, 새 트리 반환).
235
+ */
236
+ function reconcileLocaleKeys(resources, { available, fallback, scopes, writer }) {
237
+ const out = mergeResources({}, resources) // 깊은 복제(입력 불변).
238
+ for (const scope of scopes) {
239
+ /** @type {Map<string, Map<string,string>>} 언어별 평탄 맵. */
240
+ const flatByLng = new Map()
241
+ /** @type {Set<string>} 전 언어 키 합집합. */
242
+ const union = new Set()
243
+ for (const lng of available) {
244
+ const flat = flattenLeafKeys(out[lng]?.[scope] ?? {})
245
+ flatByLng.set(lng, flat)
246
+ for (const key of flat.keys()) union.add(key)
247
+ }
248
+ if (union.size === 0) continue
249
+ for (const lng of available) {
250
+ const flat = flatByLng.get(lng) ?? new Map()
251
+ for (const key of union) {
252
+ if (flat.has(key)) continue // 이미 있으면 보존.
253
+ // 값 우선순위: fallback 언어 값 → 그 키를 가진 첫 available 언어 값(union 이라 최소 1개 존재).
254
+ let value = flatByLng.get(fallback)?.get(key)
255
+ if (typeof value !== 'string') {
256
+ for (const other of available) {
257
+ const v = flatByLng.get(other)?.get(key)
258
+ if (typeof v === 'string') { value = v; break }
259
+ }
260
+ }
261
+ if (typeof value !== 'string') continue
262
+ out[lng] = out[lng] ?? {}
263
+ out[lng][scope] = out[lng][scope] ?? {}
264
+ setNested(out[lng][scope], key, value)
265
+ writer?.enqueue(lng, scope, key, value)
266
+ }
267
+ }
268
+ }
269
+ return out
270
+ }
271
+
272
+ /**
273
+ * saveMissing 디바운스 writer 를 만든다 — axion 패턴(ADR-037).
274
+ *
275
+ * `(lng, scope, key, value)` 누락 키를 모아 `<dir>/<scope>/<lng>.json` 파일에 **기존 키 보존**하며
276
+ * 병합 저장한다. 같은 파일의 연속 호출은 `debounceMs` 동안 흡수(타이머 1개)하고, flush 시:
277
+ * 1. 기존 파일 읽어 base 로(없으면 {}),
278
+ * 2. 모아둔 누락 키를 dot-notation 으로 set(기존 키 안 건드림),
279
+ * 3. tmp 파일 작성 → `rename`(원자적 쓰기 — 부분 파일 노출 방지),
280
+ * 4. `timer.unref()` 라 보류 중 타이머가 프로세스 종료를 막지 않음.
281
+ *
282
+ * 쓰기 실패는 비치명적(dev 편의 기능) — warn 후 다음 호출에 재시도.
283
+ *
284
+ * @param {object} opts
285
+ * @param {string} opts.dir - locale 루트 디렉터리.
286
+ * @param {number} opts.debounceMs
287
+ * @param {{ debug?: Function, warn?: Function }} [opts.logger]
288
+ * @returns {{ enqueue: (lng: string, scope: string, key: string, value: string) => void, flushAll: () => Promise<void>, _pendingCount: () => number }}
289
+ */
290
+ export function createMissingKeyWriter({ dir, debounceMs, logger }) {
291
+ /** 파일별(`<lng>:<scope>`) 보류 상태: 모아둔 키 + 타이머. @type {Map<string, { lng: string, scope: string, pending: Map<string, string>, timer: NodeJS.Timeout | null }>} */
292
+ const files = new Map()
293
+
294
+ /** @param {string} lng @param {string} scope */
295
+ const flush = async (lng, scope) => {
296
+ const fileKey = `${lng}:${scope}`
297
+ const entry = files.get(fileKey)
298
+ if (!entry || entry.pending.size === 0) return
299
+ const pending = entry.pending
300
+ entry.pending = new Map() // flush 중 들어오는 새 키는 다음 사이클로.
301
+ entry.timer = null
302
+
303
+ const path = join(dir, scope, `${lng}.json`)
304
+ try {
305
+ /** @type {Record<string, any>} */
306
+ let base = {}
307
+ if (existsSync(path)) {
308
+ try {
309
+ base = JSON.parse(readFileSync(path, 'utf8')) || {}
310
+ } catch (err) {
311
+ // 손상 파일을 통째로 덮어쓰면 사람이 쓴 키가 날아간다 — 머지 포기하고 재전파 없이 보류 복원.
312
+ logger?.warn?.({ path, err }, 'i18n.saveMissing read failed (skip merge)')
313
+ for (const [k, v] of pending) entry.pending.set(k, v)
314
+ return
315
+ }
316
+ }
317
+ for (const [k, v] of pending) setNested(base, k, v)
318
+ await mkdir(dirname(path), { recursive: true })
319
+ const tmp = `${path}.${process.pid}.tmp`
320
+ await writeFile(tmp, JSON.stringify(base, null, 2) + '\n', 'utf8')
321
+ await rename(tmp, path) // 원자적 교체.
322
+ logger?.debug?.({ path, keys: pending.size }, 'i18n.saveMissing flushed')
323
+ } catch (err) {
324
+ logger?.warn?.({ path, err }, 'i18n.saveMissing write failed (will retry)')
325
+ for (const [k, v] of pending) entry.pending.set(k, v) // 실패분 복원 — 다음 호출에 재시도.
326
+ }
327
+ }
328
+
329
+ return {
330
+ enqueue(lng, scope, key, value) {
331
+ const fileKey = `${lng}:${scope}`
332
+ let entry = files.get(fileKey)
333
+ if (!entry) {
334
+ entry = { lng, scope, pending: new Map(), timer: null }
335
+ files.set(fileKey, entry)
336
+ }
337
+ entry.pending.set(key, value)
338
+ if (entry.timer) return // 디바운스 — 이미 타이머 대기 중.
339
+ entry.timer = setTimeout(() => {
340
+ flush(lng, scope).catch((err) => logger?.warn?.({ lng, scope, err }, 'i18n.saveMissing flush error'))
341
+ }, debounceMs)
342
+ entry.timer.unref?.() // 보류 타이머가 프로세스 종료를 막지 않게(axion 패턴).
343
+ },
344
+ async flushAll() {
345
+ for (const { lng, scope, timer } of files.values()) {
346
+ if (timer) clearTimeout(timer)
347
+ await flush(lng, scope)
348
+ }
349
+ },
350
+ _pendingCount() {
351
+ let n = 0
352
+ for (const e of files.values()) n += e.pending.size
353
+ return n
354
+ },
355
+ }
356
+ }
357
+
358
+ /**
359
+ * 요청 Cookie 헤더에서 locale 쿠키를 읽어 유효 언어로 결정한다(ADR-038 — 쿠키만).
360
+ *
361
+ * 쿠키가 없거나 `available` 에 없는 값이면 `default` 로 폴백한다(조용한 클램프 — 변조·구식 쿠키 방어).
362
+ *
363
+ * @param {string|undefined} cookieHeader - `req.headers.cookie`.
364
+ * @param {object} opts
365
+ * @param {string} opts.cookieName
366
+ * @param {string[]} opts.available
367
+ * @param {string} opts.default
368
+ * @returns {string} 결정된 언어.
369
+ */
370
+ export function detectLocale(cookieHeader, { cookieName, available, default: def }) {
371
+ const raw = readCookie(cookieHeader, cookieName)
372
+ if (raw && available.includes(raw)) return raw
373
+ return def
374
+ }
375
+
376
+ /**
377
+ * Cookie 헤더에서 특정 이름의 값을 꺼낸다(@fastify/cookie 의존 회피 — locale 쿠키 1종만 파싱, session.js 정합).
378
+ * @param {string|undefined} header @param {string} name @returns {string|undefined}
379
+ */
380
+ function readCookie(header, name) {
381
+ if (!header) return undefined
382
+ for (const part of header.split(';')) {
383
+ const eq = part.indexOf('=')
384
+ if (eq === -1) continue
385
+ if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim())
386
+ }
387
+ return undefined
388
+ }
389
+
390
+ /**
391
+ * locale Set-Cookie 헤더 문자열을 만든다(session.js serializeCookie 정합, locale 전용 디폴트).
392
+ * @param {string} name @param {string} value @param {{ secure: boolean }} opts @returns {string}
393
+ */
394
+ function serializeLocaleCookie(name, value, { secure }) {
395
+ // httpOnly 미설정 — SPA 가 현재 언어를 읽을 수 있게(보안 토큰 아님). SameSite=Lax, 1년 수명.
396
+ let str = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${DEFAULT_COOKIE_MAX_AGE_SEC}; SameSite=Lax`
397
+ if (secure) str += '; Secure'
398
+ return str
399
+ }
400
+
401
+ /**
402
+ * 앱 전용 i18next 인스턴스를 만들고 init 한다(Option B — i18next 직접 통합, 추가 dep 0).
403
+ *
404
+ * scope 를 namespace 로(server=defaultNS), inline+디스크 resources 병합, dev 면 saveMissing 활성.
405
+ * init 은 inline resources 만 쓰면 동기적으로 완료되나, 반환 promise 는 `registerI18n` 이 onReady 에서
406
+ * await 해 첫 요청 전 완료를 보장한다.
407
+ *
408
+ * @param {NormalizedI18n} cfg
409
+ * @param {object} deps
410
+ * @param {string} deps.appName
411
+ * @param {ReturnType<typeof createMissingKeyWriter>|null} deps.writer - saveMissing writer(비활성 시 null).
412
+ * @param {{ debug?: Function, warn?: Function }} [deps.logger]
413
+ * @returns {{ instance: import('i18next').i18n, initPromise: Promise<unknown> }}
414
+ */
415
+ function createI18nextInstance(cfg, { appName, writer, logger }) {
416
+ const fileResources = loadLocaleResources(cfg.localesDir, { available: cfg.available, logger })
417
+ let resources = mergeResources(fileResources, cfg.resources)
418
+
419
+ // dev: 언어 간 키 parity 보정 — 한 언어에만 있는 키(예: en 만 있고 ko 없음)를 나머지 모든 available
420
+ // 언어에 fallback 값 우선으로 채운다(ADR-164). i18next 가 fallback 으로 찾으면 missingKeyHandler 가
421
+ // 발화 안 하는 공백을 메운다. prod 는 비활성(resources 원본 그대로).
422
+ if (cfg.autoComplete.enabled) {
423
+ resources = reconcileLocaleKeys(resources, { available: cfg.available, fallback: cfg.fallback, scopes: I18N_SCOPES, writer })
424
+ }
425
+
426
+ const instance = i18next.createInstance()
427
+ const initPromise = instance.init({
428
+ lng: cfg.default,
429
+ fallbackLng: cfg.fallback,
430
+ supportedLngs: cfg.available,
431
+ ns: [...I18N_SCOPES],
432
+ defaultNS: DEFAULT_SCOPE,
433
+ resources,
434
+ // 서버 사이드라 HTML escape 비활성(출력 escape 는 뷰/SPA 책임). 클라 번들도 raw 로 내려 SPA 가 처리.
435
+ interpolation: { escapeValue: false },
436
+ // dev 자동 키 생성(ADR-037/164). prod 면 false 라 missingKeyHandler 미발화(0 비용).
437
+ saveMissing: cfg.autoComplete.enabled && writer !== null,
438
+ // 핸들러를 실제 요청 언어 기준 1회만 발화시킨다(기입 대상 언어는 아래에서 available 전체로 직접 결정).
439
+ saveMissingTo: 'current',
440
+ missingKeyHandler: (lngs, ns, key, fallbackValue) => {
441
+ const reqLng = Array.isArray(lngs) ? lngs[0] : String(lngs)
442
+ const scope = String(ns)
443
+ // 기입 값 = defaultValue(문자열)면 그대로, 없으면 키 이름(ADR-164). 모든 언어 파일에 동일 값.
444
+ const value = typeof fallbackValue === 'string' && fallbackValue.length > 0 ? fallbackValue : key
445
+ // 누락 관측(메트릭·span·로그)은 실제 요청 언어 기준 1건 — 다중 기입 중복 집계 회피.
446
+ recordI18n({ app: appName, lang: reqLng, scope, event: 'missing' })
447
+ MegaTracing.tracer.activeSpan()?.setAttributes({ 'mega.i18n.missing_key': key })
448
+ logger?.debug?.({ app: appName, lng: reqLng, scope, key }, 'i18n.missing_key')
449
+ // 설정된 **모든 available 언어** 파일에 자동 생성(ADR-164) — 키가 전 언어에 존재해야 다음 요청에서
450
+ // 재트리거되지 않는다(재오염 루프 차단). 값은 모든 언어 동일(defaultValue 또는 키) → 개발자가 이후 번역.
451
+ for (const lng of cfg.available) writer?.enqueue(lng, scope, key, value)
452
+ },
453
+ })
454
+ return { instance, initPromise }
455
+ }
456
+
457
+ /**
458
+ * Fastify 인스턴스에 i18n(`i18next`) 을 자동 등록한다 — 요청별 언어 결정·`req.t`·scope 번들·관측성.
459
+ *
460
+ * `i18n` 이 falsy 면 **미등록**(옵트인). session.js / multipart.js 형제 패턴. 호출 순서는 라우트 등록
461
+ * 이전 어디든 무방하다(onRequest hook + request 부착이라 라우트 순서와 무관).
462
+ *
463
+ * @param {import('fastify').FastifyInstance} fastify - 대상 앱 Fastify 인스턴스.
464
+ * @param {Object} opts
465
+ * @param {unknown} opts.i18n - `MegaI18nAppConfig`. falsy 면 미등록.
466
+ * @param {string} [opts.appName] - 앱 이름(메트릭 라벨·로그용).
467
+ * @param {{ debug?: Function, warn?: Function }} [opts.logger] - 흐름 길목 debug 로그(선택).
468
+ * @returns {I18nSummary}
469
+ */
470
+ export function registerI18n(fastify, { i18n, appName = '(unknown)', logger } = /** @type {any} */ ({})) {
471
+ const cfg = normalizeI18n(i18n)
472
+ if (cfg === null) {
473
+ return { enabled: false, default: 'en', fallback: 'en', available: [], cookieName: DEFAULT_I18N_COOKIE, autoComplete: false, instance: null }
474
+ }
475
+
476
+ // saveMissing writer — dev + localesDir 있을 때만(쓸 디렉터리 없으면 무의미). prod/디렉터리 없으면 null.
477
+ const writer = cfg.autoComplete.enabled && cfg.localesDir ? createMissingKeyWriter({ dir: cfg.localesDir, debounceMs: cfg.autoComplete.debounceMs, logger }) : null
478
+
479
+ const { instance, initPromise } = createI18nextInstance(cfg, { appName, writer, logger })
480
+
481
+ // init 완료를 첫 요청 전에 보장 — inline resources 면 사실상 즉시 완료되나 promise 를 ready 에서 await.
482
+ fastify.addHook('onReady', async () => {
483
+ await initPromise
484
+ })
485
+
486
+ // 요청 진입 — 쿠키로 언어 결정 → req.lang/req.t/req.setLocale/req.translations 부착(길목 로그).
487
+ fastify.addHook('onRequest', async (req, reply) => {
488
+ const lang = detectLocale(req.headers.cookie, { cookieName: cfg.cookieName, available: cfg.available, default: cfg.default })
489
+ const r = /** @type {any} */ (req)
490
+ r.lang = lang
491
+ // req.t — server scope 고정 번역기. 시그니처 `t(key, defaultValue?, params?)`(docs/03 §632, i18next 네이티브).
492
+ r.t = instance.getFixedT(lang, DEFAULT_SCOPE)
493
+ // req.translations(scope) — 해당 언어의 scope 번들(client endpoint·SPA 푸시용). 미지정 시 client.
494
+ r.translations = (/** @type {string} */ scope = 'client') => instance.getResourceBundle(lang, scope) ?? {}
495
+ // req.setLocale(lng) — 명시 언어 변경(ADR-038). 쿠키 발급 + 현재 요청 즉시 반영. available 외면 400.
496
+ r.setLocale = (/** @type {string} */ lng) => {
497
+ if (!cfg.available.includes(lng)) {
498
+ throw new MegaValidationError('i18n.unsupported_locale', `Unsupported locale '${lng}'. Available: [${cfg.available.join(', ')}].`, { details: { lng, available: cfg.available } })
499
+ }
500
+ const secure = req.protocol === 'https'
501
+ reply.header('set-cookie', serializeLocaleCookie(cfg.cookieName, lng, { secure }))
502
+ r.lang = lng
503
+ r.t = instance.getFixedT(lng, DEFAULT_SCOPE)
504
+ logger?.debug?.({ app: appName, lng }, 'i18n.setLocale')
505
+ return lng
506
+ }
507
+ // 활성 HTTP span 에 언어 기록(ADR-126). 옵트인 OFF 면 activeSpan undefined → no-op.
508
+ MegaTracing.tracer.activeSpan()?.setAttributes({ 'mega.i18n.lang': lang })
509
+ recordI18n({ app: appName, lang, event: 'request' })
510
+ logger?.debug?.({ app: appName, route: req.routeOptions?.url, lang }, 'i18n.detect')
511
+ })
512
+
513
+ // GET /i18n/translations — client scope 번들만 노출(ADR-039 — 서버 키 비노출). 옵트인 default-on.
514
+ if (cfg.exposeTranslations) {
515
+ // 보안 면제 — locale 번들은 공개 자원(SPA 부트스트랩). CSRF 는 GET 미대상이나 명시 면제로 의도 표명.
516
+ fastify.get(cfg.translationsPath, { config: { skipCsrf: true } }, async (req) => {
517
+ const r = /** @type {any} */ (req)
518
+ return { lang: r.lang, scope: 'client', translations: r.translations?.('client') ?? {} }
519
+ })
520
+ logger?.debug?.({ app: appName, path: cfg.translationsPath }, 'i18n.translations endpoint registered')
521
+ }
522
+
523
+ logger?.debug?.({ app: appName, default: cfg.default, available: cfg.available, autoComplete: cfg.autoComplete.enabled }, 'i18n.registered')
524
+ return { enabled: true, default: cfg.default, fallback: cfg.fallback, available: cfg.available, cookieName: cfg.cookieName, autoComplete: cfg.autoComplete.enabled && writer !== null, instance }
525
+ }
@@ -0,0 +1,63 @@
1
+ // @ts-check
2
+ export { MegaApp } from './mega-app.js'
3
+ export { MegaServer } from './mega-server.js'
4
+ export { Router, MegaRouteError } from './router.js'
5
+ export { MegaService } from './mega-service.js'
6
+ export { MegaCluster } from './mega-cluster.js'
7
+ export { loadRoutes } from './routes-loader.js'
8
+ // 중앙 부팅 orchestrator (ADR-123)
9
+ export { bootApp, buildBootContext } from './boot.js'
10
+ export { wrapEnvelope, errorEnvelope } from './envelope.js'
11
+ export { buildErrorHandler } from './error-mapper.js'
12
+ export { ajvErrorToValidationError } from './ajv-mapper.js'
13
+ export { loadAndValidateConfig } from './config-loader.js'
14
+ // 요청 ctx 빌더 — db/cache/bus 접근자 배선 (ADR-102)
15
+ export { buildHttpCtx, getHttpCtx, buildAdapterAccessors } from './ctx-builder.js'
16
+ // WS envelope + 컨트롤러 베이스 (ADR-015 / ADR-074)
17
+ export {
18
+ createWsMessage,
19
+ validateWsMessage,
20
+ parseWsMessage,
21
+ generateMessageId,
22
+ WS_MESSAGE_SCHEMA,
23
+ WS_PROTOCOL_VERSION,
24
+ WS_TYPE_PATTERN,
25
+ } from './ws-message.js'
26
+ export { MegaWebSocketController } from './ws-controller.js'
27
+ // WS upgrade 핸들오프 (embedded 종단, ADR-083/084)
28
+ export {
29
+ MegaWsConnection,
30
+ driveWsConnection,
31
+ createPlainCodec,
32
+ createAspCodec,
33
+ rejectUpgrade,
34
+ CLOSE_CODE_DECRYPT_FAILED,
35
+ CLOSE_CODE_INTERNAL_ERROR,
36
+ } from './ws-upgrade.js'
37
+ // Bridge↔Hub 12-타입 프로토콜 (ADR-033/059/097)
38
+ export { MegaHubLink } from './hub-link.js'
39
+ // WS per-message deflate 압축 (ADR-078)
40
+ export {
41
+ buildPerMessageDeflate,
42
+ checkCompressionConfig,
43
+ COMPRESSION_DEFAULTS,
44
+ } from './ws-compression.js'
45
+ export {
46
+ validateGlobalConfig,
47
+ validateAppConfig,
48
+ validateHostCollisions,
49
+ } from './config-validator.js'
50
+ // 보안 플러그인 자동 등록 — helmet/cors/rate-limit/csrf + ASP HTTP (ADR-127)
51
+ export { registerSecurityPlugins, DEFAULT_RATE_LIMIT } from './security.js'
52
+ // 세션 — 미들웨어 + 스토어 팩토리 (ADR-129/046)
53
+ export { registerSession, generateSid, readSession } from './session.js'
54
+ export { createSessionStore, SESSION_STORE_DRIVERS } from './session-store.js'
55
+ export { createSessionCleanupSchedule } from './session-cleanup-schedule.js'
56
+ // i18n — i18next 통합 + 쿠키 locale + scope 분리 (ADR-037/038/039/135)
57
+ export { registerI18n, normalizeI18n, detectLocale, loadLocaleResources, createMissingKeyWriter, DEFAULT_I18N_COOKIE, I18N_SCOPES } from './i18n.js'
58
+ // 템플릿 — EJS + ejs-mate 서버사이드 렌더 + reply.render/ctx.render + i18n 통합 (ADR-011/136)
59
+ export { registerTemplate, MegaTemplate, normalizeViews, resolveViewPath, renderView, VIEW_ENGINE } from './template.js'
60
+ // 정적 자산 — @fastify/static 옵트인 (ADR-071/139)
61
+ export { registerStaticAssets, normalizeStaticAssets, DEFAULT_STATIC_PREFIX } from './static-assets.js'
62
+ // OpenAPI/Swagger — @fastify/swagger + swagger-ui 옵트인 (ADR-070/140)
63
+ export { registerOpenapi, normalizeOpenapi, DEFAULT_OPENAPI_PATH } from './openapi.js'