mega-framework 0.1.6 → 0.1.8

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 (248) hide show
  1. package/README.md +9 -0
  2. package/bin/mega-ws-hub.js +2 -2
  3. package/package.json +33 -9
  4. package/sample/crud/.env +10 -1
  5. package/sample/crud/.env.example +10 -1
  6. package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
  7. package/sample/crud/.mega/journal/snapshot.json +261 -0
  8. package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
  9. package/sample/crud/apps/main/controllers/web-controller.js +7 -5
  10. package/sample/crud/apps/main/locales/server/en.json +12 -1
  11. package/sample/crud/apps/main/locales/server/ko.json +12 -1
  12. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
  13. package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
  14. package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
  15. package/sample/crud/apps/main/models/log-partition-model.js +105 -0
  16. package/sample/crud/apps/main/models/note-model.js +79 -0
  17. package/sample/crud/apps/main/models/user-level-model.js +24 -0
  18. package/sample/crud/apps/main/models/user-model.js +146 -0
  19. package/sample/crud/apps/main/models/user-type-model.js +21 -0
  20. package/sample/crud/apps/main/models/wallet-model.js +24 -0
  21. package/sample/crud/apps/main/routes/users.js +55 -10
  22. package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
  23. package/sample/crud/apps/main/services/auth-service.js +39 -24
  24. package/sample/crud/apps/main/services/log-partition-service.js +101 -0
  25. package/sample/crud/apps/main/services/note-service.js +6 -6
  26. package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
  27. package/sample/crud/apps/main/services/user-service.js +62 -21
  28. package/sample/crud/apps/main/views/auth/login.ejs +6 -6
  29. package/sample/crud/apps/main/views/auth/register.ejs +46 -5
  30. package/sample/crud/apps/main/views/users/edit.ejs +42 -5
  31. package/sample/crud/apps/main/views/users/list.ejs +6 -2
  32. package/sample/crud/apps/main/views/users/new.ejs +56 -4
  33. package/sample/crud/docs/log_partition_design.mm.md +23 -0
  34. package/sample/crud/mega.config.js +10 -2
  35. package/sample/crud/package.json +3 -3
  36. package/sample/crud/scripts/start-ws-hub.sh +20 -6
  37. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  38. package/sample/simple/package.json +2 -2
  39. package/src/adapters/adapter-manager.js +2 -1
  40. package/src/adapters/adapter-options.js +44 -3
  41. package/src/adapters/file-adapter.js +9 -5
  42. package/src/adapters/file-session-adapter.js +4 -3
  43. package/src/adapters/maria-adapter.js +33 -7
  44. package/src/adapters/mega-cache-adapter.js +83 -6
  45. package/src/adapters/mega-db-adapter.js +10 -1
  46. package/src/adapters/mongo-adapter.js +40 -8
  47. package/src/adapters/postgres-adapter.js +33 -6
  48. package/src/adapters/redis-adapter.js +7 -3
  49. package/src/adapters/sqlite-adapter.js +26 -3
  50. package/src/cli/commands/console-cmd.js +3 -1
  51. package/src/cli/commands/new.js +13 -3
  52. package/src/cli/commands/scaffold.js +173 -33
  53. package/src/cli/generators/index.js +140 -3
  54. package/src/cli/index.js +437 -155
  55. package/src/cli/watch.js +188 -0
  56. package/src/core/ajv-mapper.js +30 -3
  57. package/src/core/boot.js +464 -245
  58. package/src/core/cluster-metrics.js +13 -4
  59. package/src/core/ctx-builder.js +65 -3
  60. package/src/core/envelope.js +119 -12
  61. package/src/core/hub-link.js +89 -18
  62. package/src/core/i18n.js +11 -1
  63. package/src/core/index.js +7 -3
  64. package/src/core/mega-app.js +253 -505
  65. package/src/core/mega-cluster.js +4 -1
  66. package/src/core/mega-server.js +40 -9
  67. package/src/core/migration/dialect-registry.js +107 -0
  68. package/src/core/migration/dialects/README.md +62 -0
  69. package/src/core/migration/dialects/maria.js +496 -0
  70. package/src/core/migration/dialects/mongo.js +824 -0
  71. package/src/core/migration/dialects/postgres.js +563 -0
  72. package/src/core/migration/dialects/sqlite.js +476 -0
  73. package/src/core/migration/differ.js +456 -0
  74. package/src/core/migration/generate.js +508 -0
  75. package/src/core/migration/journal.js +167 -0
  76. package/src/core/migration/model-scan.js +84 -0
  77. package/src/core/migration/mongo-migration-db.js +97 -0
  78. package/src/core/migration/schema-builder.js +400 -0
  79. package/src/core/migration/schema-validator.js +315 -0
  80. package/src/core/migration-lock.js +205 -0
  81. package/src/core/migration-runner.js +166 -38
  82. package/src/core/multipart.js +28 -5
  83. package/src/core/pipeline.js +131 -0
  84. package/src/core/router.js +70 -65
  85. package/src/core/scope-registry.js +1 -0
  86. package/src/core/security.js +70 -12
  87. package/src/core/session-store.js +14 -1
  88. package/src/core/workers-manager.js +12 -1
  89. package/src/core/ws-cluster.js +10 -3
  90. package/src/core/ws-message.js +48 -4
  91. package/src/core/ws-presence.js +636 -0
  92. package/src/core/ws-roster.js +50 -8
  93. package/src/core/ws-upgrade.js +223 -12
  94. package/src/index.js +1 -1
  95. package/src/lib/hub-protocol.js +29 -0
  96. package/src/lib/mega-circuit-breaker.js +5 -3
  97. package/src/lib/mega-health.js +35 -4
  98. package/src/lib/mega-job-queue.js +151 -34
  99. package/src/lib/mega-job.js +37 -1
  100. package/src/lib/mega-metrics.js +31 -13
  101. package/src/lib/mega-plugin.js +34 -3
  102. package/src/lib/mega-schedule.js +40 -22
  103. package/src/lib/mega-shutdown.js +114 -39
  104. package/src/lib/mega-tracing.js +66 -19
  105. package/src/lib/mega-worker.js +33 -6
  106. package/src/lib/otel-resource.js +36 -0
  107. package/src/{cli → lib}/ws-hub.js +139 -15
  108. package/src/models/crud-sql-builder.js +133 -0
  109. package/src/models/mega-model.js +82 -2
  110. package/src/models/model-crud.js +483 -0
  111. package/src/models/mongo-crud.js +285 -0
  112. package/templates/adr/code.tpl +23 -0
  113. package/templates/model/code-mongo.tpl +35 -0
  114. package/templates/model/code.tpl +15 -1
  115. package/templates/model/test-mongo.tpl +38 -0
  116. package/templates/model/test.tpl +4 -0
  117. package/types/adapters/adapter-manager.d.ts +95 -0
  118. package/types/adapters/adapter-options.d.ts +93 -0
  119. package/types/adapters/file-adapter.d.ts +105 -0
  120. package/types/adapters/file-session-adapter.d.ts +103 -0
  121. package/types/adapters/index.d.ts +20 -0
  122. package/types/adapters/maria-adapter.d.ts +117 -0
  123. package/types/adapters/mega-adapter.d.ts +215 -0
  124. package/types/adapters/mega-bus-adapter.d.ts +45 -0
  125. package/types/adapters/mega-cache-adapter.d.ts +73 -0
  126. package/types/adapters/mega-db-adapter.d.ts +50 -0
  127. package/types/adapters/mega-lock-adapter.d.ts +62 -0
  128. package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
  129. package/types/adapters/mega-session-adapter.d.ts +32 -0
  130. package/types/adapters/mongo-adapter.d.ts +150 -0
  131. package/types/adapters/nats-adapter.d.ts +108 -0
  132. package/types/adapters/postgres-adapter.d.ts +141 -0
  133. package/types/adapters/redis-adapter.d.ts +78 -0
  134. package/types/adapters/redis-session-adapter.d.ts +82 -0
  135. package/types/adapters/redlock-adapter.d.ts +149 -0
  136. package/types/adapters/registry.d.ts +46 -0
  137. package/types/adapters/sqlite-adapter.d.ts +112 -0
  138. package/types/auth/index.d.ts +24 -0
  139. package/types/cli/commands/console-cmd.d.ts +37 -0
  140. package/types/cli/commands/new.d.ts +16 -0
  141. package/types/cli/commands/routes.d.ts +36 -0
  142. package/types/cli/commands/scaffold.d.ts +78 -0
  143. package/types/cli/commands/test-cmd.d.ts +14 -0
  144. package/types/cli/generators/index.d.ts +122 -0
  145. package/types/cli/index.d.ts +234 -0
  146. package/types/cli/template-engine.d.ts +40 -0
  147. package/types/cli/watch.d.ts +59 -0
  148. package/types/core/ajv-mapper.d.ts +27 -0
  149. package/types/core/boot.d.ts +233 -0
  150. package/types/core/cluster-metrics.d.ts +52 -0
  151. package/types/core/config-loader.d.ts +13 -0
  152. package/types/core/config-validator.d.ts +30 -0
  153. package/types/core/ctx-builder.d.ts +103 -0
  154. package/types/core/envelope.d.ts +79 -0
  155. package/types/core/error-mapper.d.ts +17 -0
  156. package/types/core/formbody.d.ts +41 -0
  157. package/types/core/hub-link.d.ts +266 -0
  158. package/types/core/i18n.d.ts +178 -0
  159. package/types/core/index.d.ts +28 -0
  160. package/types/core/mega-app.d.ts +529 -0
  161. package/types/core/mega-cluster.d.ts +104 -0
  162. package/types/core/mega-server.d.ts +91 -0
  163. package/types/core/mega-service.d.ts +31 -0
  164. package/types/core/migration/dialect-registry.d.ts +22 -0
  165. package/types/core/migration/dialects/maria.d.ts +99 -0
  166. package/types/core/migration/dialects/mongo.d.ts +89 -0
  167. package/types/core/migration/dialects/postgres.d.ts +117 -0
  168. package/types/core/migration/dialects/sqlite.d.ts +111 -0
  169. package/types/core/migration/differ.d.ts +47 -0
  170. package/types/core/migration/generate.d.ts +56 -0
  171. package/types/core/migration/journal.d.ts +52 -0
  172. package/types/core/migration/model-scan.d.ts +19 -0
  173. package/types/core/migration/mongo-migration-db.d.ts +7 -0
  174. package/types/core/migration/schema-builder.d.ts +197 -0
  175. package/types/core/migration/schema-validator.d.ts +20 -0
  176. package/types/core/migration-lock.d.ts +33 -0
  177. package/types/core/migration-runner.d.ts +101 -0
  178. package/types/core/multipart.d.ts +86 -0
  179. package/types/core/openapi.d.ts +62 -0
  180. package/types/core/pipeline.d.ts +93 -0
  181. package/types/core/router.d.ts +159 -0
  182. package/types/core/routes-loader.d.ts +21 -0
  183. package/types/core/scope-registry.d.ts +14 -0
  184. package/types/core/security.d.ts +77 -0
  185. package/types/core/services-loader.d.ts +27 -0
  186. package/types/core/session-cleanup-schedule.d.ts +19 -0
  187. package/types/core/session-store.d.ts +25 -0
  188. package/types/core/session.d.ts +77 -0
  189. package/types/core/static-assets.d.ts +73 -0
  190. package/types/core/template.d.ts +106 -0
  191. package/types/core/workers-manager.d.ts +79 -0
  192. package/types/core/ws-cluster.d.ts +208 -0
  193. package/types/core/ws-compression.d.ts +112 -0
  194. package/types/core/ws-controller.d.ts +65 -0
  195. package/types/core/ws-message.d.ts +106 -0
  196. package/types/core/ws-presence.d.ts +273 -0
  197. package/types/core/ws-roster.d.ts +108 -0
  198. package/types/core/ws-upgrade.d.ts +260 -0
  199. package/types/errors/config-error.d.ts +10 -0
  200. package/types/errors/http-errors.d.ts +120 -0
  201. package/types/errors/index.d.ts +3 -0
  202. package/types/errors/mega-error.d.ts +32 -0
  203. package/types/index.d.ts +39 -0
  204. package/types/lib/asp/config.d.ts +49 -0
  205. package/types/lib/asp/crypto.d.ts +43 -0
  206. package/types/lib/asp/errors.d.ts +30 -0
  207. package/types/lib/asp/nonce-cache.d.ts +52 -0
  208. package/types/lib/asp/plugin.d.ts +30 -0
  209. package/types/lib/asp/ws-terminator.d.ts +45 -0
  210. package/types/lib/env-mapper.d.ts +14 -0
  211. package/types/lib/hub-protocol.d.ts +106 -0
  212. package/types/lib/index.d.ts +22 -0
  213. package/types/lib/logger/telegram-core.d.ts +104 -0
  214. package/types/lib/logger/telegram-transport.d.ts +45 -0
  215. package/types/lib/mega-brute-force.d.ts +66 -0
  216. package/types/lib/mega-circuit-breaker.d.ts +243 -0
  217. package/types/lib/mega-cron.d.ts +66 -0
  218. package/types/lib/mega-hash.d.ts +32 -0
  219. package/types/lib/mega-health.d.ts +48 -0
  220. package/types/lib/mega-job-queue.d.ts +188 -0
  221. package/types/lib/mega-job-worker.d.ts +130 -0
  222. package/types/lib/mega-job.d.ts +145 -0
  223. package/types/lib/mega-logger.d.ts +45 -0
  224. package/types/lib/mega-metrics.d.ts +285 -0
  225. package/types/lib/mega-plugin.d.ts +245 -0
  226. package/types/lib/mega-retry.d.ts +85 -0
  227. package/types/lib/mega-schedule.d.ts +260 -0
  228. package/types/lib/mega-shutdown.d.ts +135 -0
  229. package/types/lib/mega-tracing.d.ts +224 -0
  230. package/types/lib/mega-worker.d.ts +129 -0
  231. package/types/lib/otel-resource.d.ts +16 -0
  232. package/types/lib/worker-runner/process-entry.d.ts +1 -0
  233. package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
  234. package/types/lib/worker-runner/thread-entry.d.ts +1 -0
  235. package/types/lib/ws-hub.d.ts +259 -0
  236. package/types/models/crud-sql-builder.d.ts +48 -0
  237. package/types/models/index.d.ts +1 -0
  238. package/types/models/mega-model.d.ts +138 -0
  239. package/types/models/model-crud.d.ts +82 -0
  240. package/types/models/mongo-crud.d.ts +59 -0
  241. package/types/test/index.d.ts +84 -0
  242. package/.env +0 -127
  243. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
  244. package/sample/crud/apps/main/models/note.js +0 -71
  245. package/sample/crud/apps/main/models/user.js +0 -86
  246. package/sample/crud/package-lock.json +0 -5665
  247. package/sample/crud/yarn.lock +0 -2142
  248. package/sample/simple/package-lock.json +0 -1851
@@ -250,20 +250,29 @@ export function collectCluster({ timeoutMs = 2500, _cluster = cluster, _proc = p
250
250
  }
251
251
  const timer = setTimeout(() => {
252
252
  cleanup()
253
- // 마스터 무응답 → 로컬 폴백(이 워커 메트릭만이라도).
253
+ // 마스터 무응답 → 로컬 폴백(이 워커 메트릭만이라도). 폴백마저 실패하면 빈 응답으로 강등하되
254
+ // 묵히지 않고 알린다 — 스크레이프가 조용히 빈 값이 되는 원인을 추적 가능하게.
254
255
  Promise.resolve(MegaMetrics.collect())
255
256
  .then(resolve)
256
- .catch(() => resolve(''))
257
+ .catch((err) => {
258
+ console.warn('[mega-cluster-metrics] local metrics fallback failed (returning empty):', /** @type {any} */ (err)?.message ?? err)
259
+ resolve('')
260
+ })
257
261
  }, timeoutMs)
258
262
  timer.unref?.()
259
263
  _proc.on('message', onMsg)
260
264
  try {
261
265
  _proc.send({ type: MSG_REQUEST, id })
262
- } catch {
266
+ } catch (sendErr) {
263
267
  cleanup()
268
+ // 마스터 IPC 채널 단절 — 로컬 폴백으로 강등(원인을 묵히지 않는다).
269
+ console.warn('[mega-cluster-metrics] request to primary failed (falling back to local):', /** @type {any} */ (sendErr)?.message ?? sendErr)
264
270
  Promise.resolve(MegaMetrics.collect())
265
271
  .then(resolve)
266
- .catch(() => resolve(''))
272
+ .catch((err) => {
273
+ console.warn('[mega-cluster-metrics] local metrics fallback failed (returning empty):', /** @type {any} */ (err)?.message ?? err)
274
+ resolve('')
275
+ })
267
276
  }
268
277
  })
269
278
  }
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { MegaConfigError } from '../errors/config-error.js'
20
20
  import * as AdapterManager from '../adapters/adapter-manager.js'
21
- import { contextProxy as workersContext } from './workers-manager.js'
21
+ import { contextProxy as workersContext, PROXY_PROTOCOL_KEYS } from './workers-manager.js'
22
22
  import { tracer as megaTracer } from '../lib/mega-tracing.js'
23
23
 
24
24
  /** ctx 도메인 함수 ↔ app.config 별명 맵 키. */
@@ -37,6 +37,9 @@ function passthroughT(key, defaultValue) {
37
37
  /** 요청당 ctx 를 캐싱하는 비열거 키. 글로벌 미들웨어와 라우트 핸들러가 같은 ctx 를 공유하도록 한다. */
38
38
  const HTTP_CTX_KEY = Symbol('mega.httpCtx')
39
39
 
40
+ /** 요청당 lazy ctx 프록시 캐싱 키 — 미들웨어·핸들러가 같은 프록시 객체를 받도록 한다 (ADR-214). */
41
+ const LAZY_CTX_KEY = Symbol('mega.lazyHttpCtx')
42
+
40
43
  /**
41
44
  * `ctx.services.<name>` lazy DI proxy (ADR-148) — 요청별 서비스 인스턴스화·캐시.
42
45
  *
@@ -94,7 +97,11 @@ function buildServicesProxy(ctx, app) {
94
97
  {},
95
98
  {
96
99
  get(_t, prop) {
97
- if (typeof prop !== 'string') return undefined // Symbol(then 등) — non-thenable 유지
100
+ if (typeof prop !== 'string') return undefined // Symbol(toStringTag 등) — 해석 대상 아님.
101
+ // 'then'/'toJSON' 등은 await·JSON.stringify·inspect 가 **string 키**로 암묵 조회한다 — 미등록
102
+ // 상태에서 throw 하면 `await ctx.services` 같은 정상 코드가 not_registered 로 죽는다. 등록된
103
+ // 이름이면(우연히 겹쳐도) 그대로 해석한다.
104
+ if (!registry.has(prop) && PROXY_PROTOCOL_KEYS.has(prop)) return undefined
98
105
  return resolve(prop)
99
106
  },
100
107
  has(_t, prop) {
@@ -248,6 +255,61 @@ export function getHttpCtx({ app, req, reply }) {
248
255
  const cached = /** @type {any} */ (req)[HTTP_CTX_KEY]
249
256
  if (cached) return cached
250
257
  const ctx = buildHttpCtx({ app, req, reply })
251
- Object.defineProperty(req, HTTP_CTX_KEY, { value: ctx, enumerable: false, configurable: true })
258
+ // 심볼 직접 대입 (G1 H-2, ADR-214) 심볼 키는 어차피 Object.keys/spread/JSON 안 잡히므로
259
+ // 비열거 defineProperty 의 실익이 없고, defineProperty 는 hidden class 전이를 유발해 17배 비싸다(실측).
260
+ ;/** @type {any} */ (req)[HTTP_CTX_KEY] = ctx
252
261
  return ctx
253
262
  }
263
+
264
+ /**
265
+ * **lazy** HTTP ctx — 실제 ctx 빌드를 첫 속성 접근 시점까지 미루는 요청당 프록시 (G1 H-1, ADR-214).
266
+ *
267
+ * hot path 단일 최대 프레임워크 비용이 "핸들러가 ctx 를 안 써도 매 요청 무조건 buildHttpCtx"
268
+ * (1,615 B + ≈0.7 µs/req, CPU 2.3% — audit-G1 실측)였다. 이 프록시는 생성 비용이 객체 1개 수준이고,
269
+ * 핸들러·미들웨어가 ctx 의 속성을 **처음 만질 때만** {@link getHttpCtx} 로 실 ctx 를 만든다(이후 캐시).
270
+ *
271
+ * 호환성: canonical 시그니처 `(req, reply, ctx)` 는 그대로 — 모든 핸들러가 여전히 3번째 인자를 받고,
272
+ * 참조하는 순간부터는 기존과 동일한 실 ctx 표면(ADR-134 요청당 공유 포함)이다. 프록시 자체도 요청당
273
+ * 1개로 캐시되어 미들웨어와 핸들러가 **동일 객체**(`===`)를 받는다. 모든 트랩이 실 ctx 로 위임되므로
274
+ * `in`/spread/`Object.keys`/getter·setter(`ctx.user=`) 동작이 보존된다.
275
+ *
276
+ * @param {object} args
277
+ * @param {import('./mega-app.js').MegaApp | null} args.app
278
+ * @param {import('fastify').FastifyRequest} args.req
279
+ * @param {import('fastify').FastifyReply} args.reply
280
+ * @returns {Record<string, any>}
281
+ */
282
+ export function getLazyHttpCtx({ app, req, reply }) {
283
+ const cached = /** @type {any} */ (req)[LAZY_CTX_KEY]
284
+ if (cached) return cached
285
+ /** 첫 트랩에서 실 ctx 를 만들고 이후 재사용 (getHttpCtx 가 req 단위 캐시를 겸한다). */
286
+ const real = () => getHttpCtx({ app, req, reply })
287
+ const proxy = new Proxy(
288
+ {},
289
+ {
290
+ get(_t, prop) {
291
+ return Reflect.get(real(), prop)
292
+ },
293
+ set(_t, prop, value) {
294
+ return Reflect.set(real(), prop, value)
295
+ },
296
+ has(_t, prop) {
297
+ return Reflect.has(real(), prop)
298
+ },
299
+ ownKeys() {
300
+ return Reflect.ownKeys(real())
301
+ },
302
+ getOwnPropertyDescriptor(_t, prop) {
303
+ return Object.getOwnPropertyDescriptor(real(), prop)
304
+ },
305
+ defineProperty(_t, prop, desc) {
306
+ return Reflect.defineProperty(real(), prop, desc)
307
+ },
308
+ deleteProperty(_t, prop) {
309
+ return Reflect.deleteProperty(real(), prop)
310
+ },
311
+ },
312
+ )
313
+ ;/** @type {any} */ (req)[LAZY_CTX_KEY] = proxy
314
+ return proxy
315
+ }
@@ -12,6 +12,33 @@
12
12
  /** request 시작 시간 기록용 symbol (meta.took_ms 계산). reply 객체에 부착. */
13
13
  export const REPLY_START_SYMBOL = Symbol('mega.reply.start')
14
14
 
15
+ /**
16
+ * 프레임워크가 만든 envelope 임을 표시하는 비열거 마킹 symbol.
17
+ *
18
+ * 이중 wrap 방지 판별을 키 모양 추측(duck-typing)이 아니라 이 마킹으로 한다 — 핸들러가 우연히
19
+ * `{ ok, data }` 모양의 도메인 데이터(예: 외부 API 프록시 응답)를 반환해도 정상적으로 data 로
20
+ * 감싸진다. 비열거라 JSON 직렬화·spread 에는 나타나지 않는다.
21
+ */
22
+ export const ENVELOPE_MARK = Symbol('mega.envelope')
23
+
24
+ /**
25
+ * envelope 객체에 마킹을 부착한다.
26
+ *
27
+ * 심볼 **직접 대입** (G1 M-1, ADR-214) — `Object.defineProperty` 대비 17배 빠르고(136→8 ns/op 실측)
28
+ * allocation 도 작다. 심볼 키는 어차피 `JSON.stringify`/`Object.keys` 에 안 나오므로 와이어·열거
29
+ * 동작은 동일하다. 의미 차이는 하나 — spread 복사(`{...env}`)가 마크를 함께 복사한다. 복사본도
30
+ * "이미 envelope" 로 취급되는데, 이는 이중 wrap 방지(ADR-184)에 오히려 부합한다(복사 후 필드를
31
+ * 고쳐 재전송하는 패턴도 envelope 그대로 나간다 — 의도된 동작으로 확정, ADR-214).
32
+ *
33
+ * @template {Record<string, any>} T
34
+ * @param {T} env
35
+ * @returns {T} 같은 객체 (마킹됨).
36
+ */
37
+ function markEnvelope(env) {
38
+ ;/** @type {any} */ (env)[ENVELOPE_MARK] = true
39
+ return env
40
+ }
41
+
15
42
  /**
16
43
  * 자동 envelope (ADR-018). preSerialization 훅의 마지막 단계에서 호출되어
17
44
  * raw data → `{ ok:true, data, meta }`.
@@ -29,11 +56,11 @@ export const REPLY_START_SYMBOL = Symbol('mega.reply.start')
29
56
  */
30
57
  export function wrapEnvelope(req, reply, payload) {
31
58
  if (isEnvelope(payload)) return payload
32
- return {
59
+ return markEnvelope({
33
60
  ok: true,
34
61
  data: payload,
35
62
  meta: buildMeta(req, reply),
36
- }
63
+ })
37
64
  }
38
65
 
39
66
  /**
@@ -44,7 +71,7 @@ export function wrapEnvelope(req, reply, payload) {
44
71
  * @returns {{ ok: false, error: { code: string, message: string, details?: any }, meta: Object }}
45
72
  */
46
73
  export function errorEnvelope(req, reply, error) {
47
- return {
74
+ return markEnvelope({
48
75
  ok: false,
49
76
  error: {
50
77
  code: error.code,
@@ -52,22 +79,102 @@ export function errorEnvelope(req, reply, error) {
52
79
  ...(error.details !== undefined ? { details: error.details } : {}),
53
80
  },
54
81
  meta: buildMeta(req, reply),
55
- }
82
+ })
56
83
  }
57
84
 
58
85
  /**
59
- * 이미 envelope 모양인지 판별 — `ok: boolean` + (`data` | `error`) 키 보유.
86
+ * 이미 envelope 인지 판별.
87
+ *
88
+ * 1차 = {@link ENVELOPE_MARK} 마킹(프레임워크 산출물 — wrapEnvelope/errorEnvelope 만 부착).
89
+ * 2차 = **수동 error envelope 하위호환**: `before` 가드 등에서 `reply.send({ ok:false, error })` 로
90
+ * 직접 보낸 에러 응답만 모양으로 통과시킨다. 성공 모양(`{ ok:true, data }`)의 duck-typing 통과는
91
+ * 제거 — 그런 모양의 도메인 데이터(외부 API 프록시 등)는 이제 정상적으로 data 로 감싸진다(오탐 제거).
92
+ *
60
93
  * @param {any} payload
61
94
  * @returns {boolean}
62
95
  */
63
96
  function isEnvelope(payload) {
64
- return (
65
- payload != null &&
66
- typeof payload === 'object' &&
67
- !Array.isArray(payload) &&
68
- typeof payload.ok === 'boolean' &&
69
- ('data' in payload || 'error' in payload)
70
- )
97
+ if (payload == null || typeof payload !== 'object' || Array.isArray(payload)) return false
98
+ if (payload[ENVELOPE_MARK] === true) return true
99
+ return payload.ok === false && 'error' in payload
100
+ }
101
+
102
+ /** envelope `error` 필드의 JSON Schema (ADR-014/075 details object | array 자유형). */
103
+ const ERROR_FIELD_SCHEMA = Object.freeze({
104
+ type: 'object',
105
+ properties: { code: { type: 'string' }, message: { type: 'string' }, details: {} },
106
+ additionalProperties: true,
107
+ })
108
+
109
+ /** envelope `meta` 필드의 JSON Schema (향후 meta 확장에 닫히지 않게 additionalProperties 허용). */
110
+ const META_FIELD_SCHEMA = Object.freeze({
111
+ type: 'object',
112
+ properties: { request_id: { type: 'string' }, took_ms: { type: 'number' } },
113
+ additionalProperties: true,
114
+ })
115
+
116
+ /**
117
+ * 라우트 `schema.response` 를 envelope 모양으로 합성한다.
118
+ *
119
+ * 배경: 자동 envelope(ADR-018)는 preSerialization 마지막에 raw data 를 `{ ok, data, meta }` 로
120
+ * 감싸는데, 사용자가 raw data 모양의 response schema 를 선언하면 Fastify 직렬화기가 **envelope 를
121
+ * 그 raw 스키마로 직렬화해 ok/data/meta 가 통째로 제거**된다(silent 데이터 소실). 그래서 등록
122
+ * 시점(onRoute)에 각 상태코드 스키마를 `{ ok, data: <사용자 스키마>, error, meta }` 로 재작성한다 —
123
+ * 사용자는 계속 raw data 모양만 선언하고(ADR-091), strict 직렬화(ADR-020: 선언 외 필드 제거)는
124
+ * data 안에서 그대로 동작하며, OpenAPI 명세(@fastify/swagger 가 schema 수집)도 실제 와이어 모양과
125
+ * 일치하게 된다.
126
+ *
127
+ * 합성 제외(원본 유지) 3종:
128
+ * - `type: 'string'` 스키마 — 문자열 payload 는 Fastify 가 preSerialization(envelope wrap) 자체를
129
+ * 건너뛰므로(raw 전송, 예: /metrics) envelope 모양으로 바꾸면 오히려 직렬화가 깨진다.
130
+ * - 이미 envelope 모양(`properties.ok` 보유) — 사용자가 envelope 전체를 직접 선언한 경우.
131
+ * - 스키마가 object 가 아닌 항목 — 알 수 없는 형식은 건드리지 않는다(fail-safe).
132
+ *
133
+ * Fastify v5 의 media-type 형식(`{ content: { 'application/json': { schema } } }`)은 내부 schema
134
+ * 에 같은 규칙을 적용한다.
135
+ *
136
+ * @param {Record<string, any>} response - 라우트 `schema.response` (상태코드/패턴 → JSON Schema).
137
+ * @returns {Record<string, any>} envelope 모양으로 합성된 새 response 객체 (원본 불변).
138
+ */
139
+ export function synthesizeEnvelopeResponseSchema(response) {
140
+ /** @type {Record<string, any>} */
141
+ const out = {}
142
+ for (const [code, entry] of Object.entries(response)) {
143
+ out[code] = synthesizeEntry(entry)
144
+ }
145
+ return out
146
+ }
147
+
148
+ /**
149
+ * response 항목 1개를 envelope 모양으로 합성 (제외 규칙은 {@link synthesizeEnvelopeResponseSchema}).
150
+ * @param {any} entry - 상태코드별 스키마 또는 `{ content }` media-type 형식.
151
+ * @returns {any}
152
+ */
153
+ function synthesizeEntry(entry) {
154
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) return entry
155
+ // Fastify v5 media-type 형식 — content 의 각 schema 에 동일 규칙 적용.
156
+ if (entry.content && typeof entry.content === 'object') {
157
+ /** @type {Record<string, any>} */
158
+ const content = {}
159
+ for (const [mediaType, def] of Object.entries(entry.content)) {
160
+ content[mediaType] =
161
+ def && typeof def === 'object' && 'schema' in def ? { ...def, schema: synthesizeEntry(def.schema) } : def
162
+ }
163
+ return { ...entry, content }
164
+ }
165
+ if (entry.type === 'string') return entry // 문자열 payload 는 envelope 우회(raw 전송) — 원본 유지.
166
+ if (entry.properties && typeof entry.properties === 'object' && 'ok' in entry.properties) return entry // 이미 envelope 모양.
167
+ return {
168
+ type: 'object',
169
+ properties: {
170
+ ok: { type: 'boolean' },
171
+ data: entry,
172
+ error: ERROR_FIELD_SCHEMA,
173
+ meta: META_FIELD_SCHEMA,
174
+ },
175
+ required: ['ok'],
176
+ additionalProperties: false,
177
+ }
71
178
  }
72
179
 
73
180
  /**
@@ -28,6 +28,7 @@ import { WebSocket } from 'ws'
28
28
  import {
29
29
  HUB_MESSAGE_TYPES,
30
30
  CLOSE_CODE_DRAIN,
31
+ HUB_PROTOCOL_VERSION,
31
32
  createHubMessage,
32
33
  parseHubMessage,
33
34
  } from '../lib/hub-protocol.js'
@@ -84,8 +85,11 @@ export class MegaHubLink {
84
85
  * 스키마 — hub 서버와 양쪽이 협상해야 활성(RFC 7692). 디폴트 OFF. 잘못된 값은 즉시 throw.
85
86
  * @param {{ warn?: Function, debug?: Function, info?: Function, error?: Function }} [opts.logger]
86
87
  * @param {typeof import('ws').WebSocket} [opts.WebSocketCtor] - 테스트 주입용 ws 생성자.
88
+ * @param {number} [opts.maxBufferedBytes=16777216] - 송신 버퍼(bufferedAmount) 상한(바이트). 초과 시
89
+ * 백프레셔로 보고 소켓을 닫고(1013) 송신을 명시 실패시킨다 — hub 가 느릴 때 bridge 힙이 무한
90
+ * 적재되는 것을 막는다. 재연결(retry) 설정 시 자동 회복.
87
91
  */
88
- constructor({ url, token, bridgeId, instanceId, capabilities, retry, connectTimeoutMs, compression, logger, WebSocketCtor } = {}) {
92
+ constructor({ url, token, bridgeId, instanceId, capabilities, retry, connectTimeoutMs, compression, logger, WebSocketCtor, maxBufferedBytes } = {}) {
89
93
  if (typeof url !== 'string' || url.length === 0) {
90
94
  throw new Error('MegaHubLink: url is required (MegaBridgeHubConfig.url).')
91
95
  }
@@ -106,6 +110,8 @@ export class MegaHubLink {
106
110
  this._retry = retry && typeof retry === 'object' ? retry : null
107
111
  /** 한 attempt 의 register_ok 대기 한도(M4). reconnect 에서도 동일 적용. @type {number} */
108
112
  this._connectTimeoutMs = Number.isFinite(connectTimeoutMs) ? Number(connectTimeoutMs) : 10_000
113
+ /** 송신 버퍼(bufferedAmount) 상한 — 초과 시 백프레셔로 소켓을 닫는다(기본 16 MiB). @type {number} */
114
+ this._maxBufferedBytes = Number.isFinite(maxBufferedBytes) && Number(maxBufferedBytes) > 0 ? Number(maxBufferedBytes) : 16 * 1024 * 1024
109
115
  // Bridge↔Hub link 압축(ADR-078). enabled=false → false. 잘못된 값은 생성자에서 즉시 throw.
110
116
  /** @type {false | Object} ws 클라이언트 perMessageDeflate 로 전달(_connectOnce). */
111
117
  this._perMessageDeflate = buildPerMessageDeflate(compression, 'wsHub.compression')
@@ -128,6 +134,14 @@ export class MegaHubLink {
128
134
  this._isReconnecting = false
129
135
  /** close() 가 진행 중인 재시도(백오프 대기)를 취소하기 위한 컨트롤러. @type {AbortController | null} */
130
136
  this._abort = null
137
+ /** register_ok 로 협상된 hub 프로토콜 버전(등록 전 null, 레거시 hub 면 1). @type {number | null} */
138
+ this._negotiatedProtocolVersion = null
139
+ /**
140
+ * 레거시 hub 모드 — REGISTER 의 `protocolVersion` 필드를 모르는 구버전 hub(strict 스키마가
141
+ * `hub.invalid_message` 로 거부)로 판정되면 true. 이후 register 는 필드 없이(v1) 보낸다.
142
+ * @type {boolean}
143
+ */
144
+ this._isLegacyHub = false
131
145
  }
132
146
 
133
147
  /** hub 가 부여한 hubId (register 전엔 null). */
@@ -140,6 +154,11 @@ export class MegaHubLink {
140
154
  return this._isRegistered
141
155
  }
142
156
 
157
+ /** 협상된 hub 프로토콜 버전 (register 전엔 null, 레거시 hub 면 1). */
158
+ get negotiatedProtocolVersion() {
159
+ return this._negotiatedProtocolVersion
160
+ }
161
+
143
162
  /** 소켓이 OPEN(=1) 인지. */
144
163
  get isOpen() {
145
164
  return this._ws?.readyState === 1
@@ -170,7 +189,9 @@ export class MegaHubLink {
170
189
  /**
171
190
  * hub 연결 + REGISTER 핸드셰이크. register_ok 수신 시 resolve.
172
191
  *
173
- * `retry` 옵션이 있으면 최초 연결도 지수 백오프로 재시도한다(ADR-098). 없으면 단발 시도.
192
+ * 초기 연결은 retry 유무와 무관하게 **단 1회** 시도한다. `retry` 옵션이 있으면 실패 시 백그라운드
193
+ * 재연결(지수 백오프)로 전환하고 reject 한다 — 성공 시 RECONNECTED, 소진 시 RECONNECT_FAILED emit.
194
+ * 호출부(boot)는 reject 를 warn 으로 받고 부팅을 계속한다(허브 다운이 부팅을 막지 않는 계약).
174
195
  *
175
196
  * @param {Object} [opts]
176
197
  * @param {number} [opts.connectTimeoutMs] - register_ok 대기 한도 override(M4). **이 값은 인스턴스에
@@ -184,21 +205,38 @@ export class MegaHubLink {
184
205
  const timeout = Number.isFinite(connectTimeoutMs) ? Number(connectTimeoutMs) : this._connectTimeoutMs
185
206
  this._connectTimeoutMs = timeout
186
207
  if (!this._retry) {
187
- return this._connectOnce({ connectTimeoutMs: timeout })
208
+ try {
209
+ return await this._connectOnce({ connectTimeoutMs: timeout })
210
+ } catch (err) {
211
+ // 레거시 hub 폴백(1회) — protocolVersion 필드 거부 판정 후 v1 register 로 즉시 재시도.
212
+ // retry 미설정 경로 전용(백오프 경로는 withRetry 가 non-fatal 을 자동 재시도).
213
+ if (err instanceof Error && err.message === 'hub.legacy_register_fallback') {
214
+ return this._connectOnce({ connectTimeoutMs: timeout })
215
+ }
216
+ throw err
217
+ }
218
+ }
219
+ // retry 활성 — 초기 연결은 **단 1회**만 시도하고, 실패하면 백그라운드 재연결로 전환한 뒤 throw 한다.
220
+ // 예전엔 withRetry 가 최초 연결까지 백오프 전체를 await 해 호출부(boot 의 cluster-transport step)가
221
+ // 분 단위로 블로킹됐다(crud retry 기준 약 4.7분) — "허브 다운이어도 boot 를 막지 않는다" 는 호출부
222
+ // 계약과 정반대 동작. 이제 호출부는 즉시 실패를 보고(warn) 부팅을 계속하고, 백그라운드 _reconnect 가
223
+ // 같은 retry 백오프로 재시도한다 — 성공 시 RECONNECTED 이벤트가 presence 재동기화를 트리거하고,
224
+ // 소진 시 RECONNECT_FAILED 를 emit 한다. 치명 에러(인증 거부 등 fatal)는 재시도 무의미라 그대로 전파.
225
+ try {
226
+ return await this._connectOnce({ connectTimeoutMs: timeout })
227
+ } catch (err) {
228
+ // 레거시 hub 폴백(1회) — protocolVersion 필드 거부 판정 후 v1 register 로 즉시 재시도.
229
+ if (err instanceof Error && err.message === 'hub.legacy_register_fallback') {
230
+ try {
231
+ return await this._connectOnce({ connectTimeoutMs: timeout })
232
+ } catch (err2) {
233
+ if (retryUnlessFatal({ error: err2 })) void this._reconnect()
234
+ throw err2
235
+ }
236
+ }
237
+ if (retryUnlessFatal({ error: err })) void this._reconnect()
238
+ throw err
188
239
  }
189
- // retry 활성 — 최초 연결도 백오프 재시도. AbortError(인증 실패 등) 는 재시도하지 않는다.
190
- this._abort = new AbortController()
191
- return withRetry(() => this._connectOnce({ connectTimeoutMs: timeout }), {
192
- ...this._retry,
193
- signal: this._abort.signal,
194
- shouldRetry: retryUnlessFatal,
195
- onFailedAttempt: (ctx) => {
196
- this._log?.warn?.(
197
- { bridgeId: this._bridgeId, attempt: ctx.attemptNumber, retriesLeft: ctx.retriesLeft },
198
- 'hub-link connect attempt failed — backing off',
199
- )
200
- },
201
- })
202
240
  }
203
241
 
204
242
  /**
@@ -246,7 +284,7 @@ export class MegaHubLink {
246
284
  }
247
285
 
248
286
  ws.on('open', () => {
249
- this._log?.debug?.({ bridgeId: this._bridgeId, url: this._url }, 'hub-link open → REGISTER')
287
+ this._log?.debug?.({ bridgeId: this._bridgeId, url: this._url, legacyHub: this._isLegacyHub }, 'hub-link open → REGISTER')
250
288
  try {
251
289
  this._sendEnvelope(
252
290
  createHubMessage({
@@ -255,6 +293,9 @@ export class MegaHubLink {
255
293
  instanceId: this._instanceId,
256
294
  token: this._token,
257
295
  capabilities: this._capabilities,
296
+ // 버전 협상 — 최대 지원 버전을 싣는다. 레거시 hub(필드를 모르는 strict 스키마)로
297
+ // 판정된 뒤에는 싣지 않는다(v1 고정 — 아래 hub.invalid_message 폴백 참조).
298
+ ...(this._isLegacyHub ? {} : { protocolVersion: HUB_PROTOCOL_VERSION }),
258
299
  },
259
300
  }),
260
301
  )
@@ -281,13 +322,32 @@ export class MegaHubLink {
281
322
  didRegister = true
282
323
  this._hubId = msg.payload.hubId
283
324
  this._heartbeatMs = msg.payload.heartbeatMs
325
+ // 협상 결과 — hub 가 채택한 버전(echo-on-request). 부재 = v1(레거시 hub 또는 v1 고정 register).
326
+ this._negotiatedProtocolVersion = msg.payload.protocolVersion ?? 1
284
327
  this._startHeartbeat()
285
- this._log?.info?.({ bridgeId: this._bridgeId, hubId: this._hubId }, 'hub-link registered')
328
+ this._log?.info?.(
329
+ { bridgeId: this._bridgeId, hubId: this._hubId, protocolVersion: this._negotiatedProtocolVersion },
330
+ 'hub-link registered',
331
+ )
286
332
  finish(resolve, { hubId: this._hubId, heartbeatMs: this._heartbeatMs })
287
333
  return
288
334
  }
289
335
  if (msg.type === T.ERROR) {
290
336
  const code = msg.error?.code ?? 'hub.error'
337
+ // 레거시 hub 폴백: protocolVersion 필드를 모르는 구버전 hub 는 strict 스키마가 register 를
338
+ // `hub.invalid_message` 로 거부한다(연결은 유지하지만 attempt 는 종료). 한 번만 레거시로
339
+ // 전환해 필드 없는 v1 register 로 재시도한다 — non-fatal reject 라 retry 경로(withRetry)가
340
+ // 자동 재시도하고, retry 미설정 connect() 도 1회 폴백 재시도한다.
341
+ if (code === 'hub.invalid_message' && !this._isLegacyHub) {
342
+ this._isLegacyHub = true
343
+ this._log?.info?.(
344
+ { bridgeId: this._bridgeId },
345
+ 'hub-link register rejected as invalid — assuming legacy hub (no protocolVersion), falling back to v1 register',
346
+ )
347
+ finish(reject, new Error('hub.legacy_register_fallback'))
348
+ ws.close()
349
+ return
350
+ }
291
351
  this._log?.warn?.({ bridgeId: this._bridgeId, code }, 'hub-link registration rejected')
292
352
  // 인증 실패 등 등록 거부는 재시도해도 같은 결과 — fatal 마킹으로 백오프 중단(ADR-098).
293
353
  finish(reject, new HubRegistrationError(`hub registration failed: ${code}`))
@@ -484,6 +544,17 @@ export class MegaHubLink {
484
544
  if (this._ws?.readyState !== 1) {
485
545
  throw new Error('MegaHubLink: socket is not open.')
486
546
  }
547
+ // 백프레셔 가드: hub 가 느려 송신 버퍼가 상한을 넘으면 더 쌓지 않는다 — bridge 힙이 무한 증가해
548
+ // OOM 으로 가는 것을 막는다. 소켓을 닫아 재연결(retry 설정 시) 경로로 회복시키고, 이번 송신은
549
+ // 명시 실패로 알린다(silent drop 금지 — 호출부 broadcast/join 이 warn 으로 표면화).
550
+ if (this._ws.bufferedAmount > this._maxBufferedBytes) {
551
+ this._log?.warn?.(
552
+ { bufferedAmount: this._ws.bufferedAmount, max: this._maxBufferedBytes },
553
+ 'hub-link send buffer exceeded — closing socket (backpressure)',
554
+ )
555
+ this._ws.close(1013, 'backpressure: send buffer exceeded')
556
+ throw new Error('MegaHubLink: send buffer exceeded (backpressure) — socket closed for recovery.')
557
+ }
487
558
  this._ws.send(JSON.stringify(envelope))
488
559
  }
489
560
 
package/src/core/i18n.js CHANGED
@@ -34,6 +34,7 @@
34
34
  * 한 언어에만 있는 키(예: en 만 있고 ko 없음)를 나머지 언어에 **fallback 값 우선**으로 채운다 — i18next 가
35
35
  * fallback 으로 찾아 missingKeyHandler 가 안 터지는 공백을 메운다. **자동 기입은 `NODE_ENV==='development'`
36
36
  * 에서만** — test/CI/prod/미설정에선 off 라 추적 locale 파일을 디스크 기입하지 않는다(ADR-164, 테스트 오염 방지).
37
+ * production 은 명시 `autoComplete.enabled:true` 도 **강제 off + warn**(ADR-186) — dev config 복사 사고 차단.
37
38
  *
38
39
  * # 트레이싱·메트릭 (ADR-126 / ADR-131 인프라 재사용)
39
40
  * - 트레이싱: onRequest 가 활성 HTTP span 에 `mega.i18n.lang` 기록. 옵트인 OFF 면 0 비용 no-op.
@@ -123,7 +124,10 @@ export function normalizeI18n(i18n) {
123
124
  // 자동 기입은 **명시적 개발 모드**(`NODE_ENV==='development'`)에서만 — test/CI/미설정(undefined)에서 켜면
124
125
  // vitest 부팅이 추적된 locale 파일을 디스크 기입해 오염시킨다(ADR-164). 샘플은 (a)로 dev=`NODE_ENV=development`,
125
126
  // start=`production` 이라 정확히 맞물린다. 명시 `autoComplete.enabled` 가 있으면 그것을 우선.
126
- const autoEnabled = ac.enabled !== undefined ? ac.enabled === true : process.env.NODE_ENV === 'development'
127
+ // **production 강제 off**(ADR-186) dev config prod 로 복사돼 명시 `enabled:true` 남아도
128
+ // saveMissing 디스크 기입(ADR-163 locale 오염 함정)이 켜지지 않는다. 덮어쓴 사실은 registerI18n 이 warn.
129
+ const requestedAuto = ac.enabled !== undefined ? ac.enabled === true : process.env.NODE_ENV === 'development'
130
+ const autoEnabled = process.env.NODE_ENV === 'production' ? false : requestedAuto
127
131
  const debounceMs = Number.isInteger(ac.debounceMs) && ac.debounceMs >= 0 ? ac.debounceMs : DEFAULT_DEBOUNCE_MS
128
132
 
129
133
  // localesDir: 명시 우선, 없으면 autoComplete.dir. 디렉터리 로딩·saveMissing 쓰기의 기준 경로.
@@ -473,6 +477,12 @@ export function registerI18n(fastify, { i18n, appName = '(unknown)', logger } =
473
477
  return { enabled: false, default: 'en', fallback: 'en', available: [], cookieName: DEFAULT_I18N_COOKIE, autoComplete: false, instance: null }
474
478
  }
475
479
 
480
+ // prod 강제 off 표면화(ADR-186) — 명시 `autoComplete.enabled:true` 를 normalizeI18n 이 production 에서
481
+ // 덮어썼으면 조용히 무시하지 않고 warn 으로 드러낸다(오설정 인지 — P4 정합).
482
+ if (process.env.NODE_ENV === 'production' && /** @type {any} */ (i18n)?.autoComplete?.enabled === true) {
483
+ logger?.warn?.({ app: appName }, 'i18n.autoComplete is forced off in production — saveMissing writes locale files to disk and is dev-only (ADR-164/186). Remove autoComplete.enabled:true from the production config.')
484
+ }
485
+
476
486
  // saveMissing writer — dev + localesDir 있을 때만(쓸 디렉터리 없으면 무의미). prod/디렉터리 없으면 null.
477
487
  const writer = cfg.autoComplete.enabled && cfg.localesDir ? createMissingKeyWriter({ dir: cfg.localesDir, debounceMs: cfg.autoComplete.debounceMs, logger }) : null
478
488
 
package/src/core/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
  export { MegaApp } from './mega-app.js'
3
+ // WS presence/hub 협력자 — MegaApp 이 위임하는 연결 인덱스·hub link·cluster/roster 동기화 전담
4
+ export { MegaWsPresence } from './ws-presence.js'
3
5
  export { MegaServer } from './mega-server.js'
4
6
  export { Router, MegaRouteError } from './router.js'
5
7
  export { MegaService } from './mega-service.js'
@@ -7,12 +9,14 @@ export { MegaCluster } from './mega-cluster.js'
7
9
  export { loadRoutes } from './routes-loader.js'
8
10
  // 중앙 부팅 orchestrator (ADR-123)
9
11
  export { bootApp, buildBootContext } from './boot.js'
10
- export { wrapEnvelope, errorEnvelope } from './envelope.js'
12
+ export { wrapEnvelope, errorEnvelope, synthesizeEnvelopeResponseSchema, ENVELOPE_MARK } from './envelope.js'
13
+ // HTTP 라이프사이클 Pipeline — before/transform/after 합성 정본 (ADR-185)
14
+ export { buildHttpPipeline, wrapPreHandler, composeTransform, composeAfter } from './pipeline.js'
11
15
  export { buildErrorHandler } from './error-mapper.js'
12
- export { ajvErrorToValidationError } from './ajv-mapper.js'
16
+ export { ajvErrorToValidationError, MAX_VALIDATION_DETAILS } from './ajv-mapper.js'
13
17
  export { loadAndValidateConfig } from './config-loader.js'
14
18
  // 요청 ctx 빌더 — db/cache/bus 접근자 배선 (ADR-102)
15
- export { buildHttpCtx, getHttpCtx, buildAdapterAccessors } from './ctx-builder.js'
19
+ export { buildHttpCtx, getHttpCtx, getLazyHttpCtx, buildAdapterAccessors } from './ctx-builder.js'
16
20
  // WS envelope + 컨트롤러 베이스 (ADR-015 / ADR-074)
17
21
  export {
18
22
  createWsMessage,