mega-framework 0.1.6 → 0.1.7

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 (233) hide show
  1. package/bin/mega-ws-hub.js +2 -2
  2. package/package.json +32 -8
  3. package/sample/crud/.env +1 -1
  4. package/sample/crud/.env.example +1 -1
  5. package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
  6. package/sample/crud/.mega/journal/snapshot.json +261 -0
  7. package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
  8. package/sample/crud/apps/main/controllers/web-controller.js +7 -5
  9. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
  10. package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
  11. package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
  12. package/sample/crud/apps/main/models/log-partition-model.js +105 -0
  13. package/sample/crud/apps/main/models/note-model.js +79 -0
  14. package/sample/crud/apps/main/models/user-level-model.js +24 -0
  15. package/sample/crud/apps/main/models/user-model.js +146 -0
  16. package/sample/crud/apps/main/models/user-type-model.js +21 -0
  17. package/sample/crud/apps/main/models/wallet-model.js +24 -0
  18. package/sample/crud/apps/main/routes/users.js +55 -10
  19. package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
  20. package/sample/crud/apps/main/services/auth-service.js +39 -24
  21. package/sample/crud/apps/main/services/log-partition-service.js +101 -0
  22. package/sample/crud/apps/main/services/note-service.js +6 -6
  23. package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
  24. package/sample/crud/apps/main/services/user-service.js +62 -21
  25. package/sample/crud/apps/main/views/auth/login.ejs +6 -6
  26. package/sample/crud/apps/main/views/auth/register.ejs +46 -5
  27. package/sample/crud/apps/main/views/users/edit.ejs +42 -5
  28. package/sample/crud/apps/main/views/users/list.ejs +6 -2
  29. package/sample/crud/apps/main/views/users/new.ejs +56 -4
  30. package/sample/crud/docs/log_partition_design.mm.md +23 -0
  31. package/sample/crud/mega.config.js +3 -2
  32. package/sample/crud/package.json +1 -1
  33. package/sample/crud/scripts/start-ws-hub.sh +2 -2
  34. package/sample/simple/package.json +2 -2
  35. package/src/adapters/adapter-manager.js +2 -1
  36. package/src/adapters/adapter-options.js +30 -0
  37. package/src/adapters/maria-adapter.js +26 -3
  38. package/src/adapters/mega-db-adapter.js +7 -1
  39. package/src/adapters/mongo-adapter.js +19 -1
  40. package/src/adapters/postgres-adapter.js +25 -2
  41. package/src/adapters/sqlite-adapter.js +20 -1
  42. package/src/cli/commands/new.js +13 -3
  43. package/src/cli/commands/scaffold.js +137 -33
  44. package/src/cli/generators/index.js +82 -2
  45. package/src/cli/index.js +353 -100
  46. package/src/core/ajv-mapper.js +27 -2
  47. package/src/core/boot.js +464 -245
  48. package/src/core/cluster-metrics.js +13 -4
  49. package/src/core/ctx-builder.js +6 -2
  50. package/src/core/envelope.js +112 -12
  51. package/src/core/hub-link.js +65 -4
  52. package/src/core/i18n.js +11 -1
  53. package/src/core/index.js +6 -2
  54. package/src/core/mega-app.js +201 -463
  55. package/src/core/mega-cluster.js +4 -1
  56. package/src/core/mega-server.js +40 -9
  57. package/src/core/migration/dialect-registry.js +107 -0
  58. package/src/core/migration/dialects/README.md +62 -0
  59. package/src/core/migration/dialects/maria.js +496 -0
  60. package/src/core/migration/dialects/mongo.js +824 -0
  61. package/src/core/migration/dialects/postgres.js +563 -0
  62. package/src/core/migration/dialects/sqlite.js +476 -0
  63. package/src/core/migration/differ.js +456 -0
  64. package/src/core/migration/generate.js +508 -0
  65. package/src/core/migration/journal.js +167 -0
  66. package/src/core/migration/model-scan.js +84 -0
  67. package/src/core/migration/mongo-migration-db.js +97 -0
  68. package/src/core/migration/schema-builder.js +400 -0
  69. package/src/core/migration/schema-validator.js +315 -0
  70. package/src/core/migration-lock.js +205 -0
  71. package/src/core/migration-runner.js +166 -38
  72. package/src/core/multipart.js +28 -5
  73. package/src/core/pipeline.js +129 -0
  74. package/src/core/router.js +70 -65
  75. package/src/core/security.js +67 -9
  76. package/src/core/workers-manager.js +12 -1
  77. package/src/core/ws-cluster.js +10 -3
  78. package/src/core/ws-message.js +48 -4
  79. package/src/core/ws-presence.js +624 -0
  80. package/src/core/ws-roster.js +4 -1
  81. package/src/core/ws-upgrade.js +118 -12
  82. package/src/index.js +1 -1
  83. package/src/lib/hub-protocol.js +29 -0
  84. package/src/lib/mega-health.js +25 -4
  85. package/src/lib/mega-job-queue.js +98 -21
  86. package/src/lib/mega-job.js +29 -0
  87. package/src/lib/mega-metrics.js +3 -12
  88. package/src/lib/mega-plugin.js +34 -3
  89. package/src/lib/mega-schedule.js +40 -22
  90. package/src/lib/mega-shutdown.js +114 -39
  91. package/src/lib/mega-tracing.js +66 -19
  92. package/src/lib/mega-worker.js +5 -1
  93. package/src/lib/otel-resource.js +36 -0
  94. package/src/{cli → lib}/ws-hub.js +51 -8
  95. package/src/models/crud-sql-builder.js +133 -0
  96. package/src/models/mega-model.js +82 -2
  97. package/src/models/model-crud.js +483 -0
  98. package/src/models/mongo-crud.js +285 -0
  99. package/templates/model/code-mongo.tpl +35 -0
  100. package/templates/model/code.tpl +15 -1
  101. package/templates/model/test-mongo.tpl +38 -0
  102. package/templates/model/test.tpl +4 -0
  103. package/types/adapters/adapter-manager.d.ts +95 -0
  104. package/types/adapters/adapter-options.d.ts +91 -0
  105. package/types/adapters/file-adapter.d.ts +94 -0
  106. package/types/adapters/file-session-adapter.d.ts +101 -0
  107. package/types/adapters/index.d.ts +20 -0
  108. package/types/adapters/maria-adapter.d.ts +115 -0
  109. package/types/adapters/mega-adapter.d.ts +215 -0
  110. package/types/adapters/mega-bus-adapter.d.ts +45 -0
  111. package/types/adapters/mega-cache-adapter.d.ts +47 -0
  112. package/types/adapters/mega-db-adapter.d.ts +47 -0
  113. package/types/adapters/mega-lock-adapter.d.ts +62 -0
  114. package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
  115. package/types/adapters/mega-session-adapter.d.ts +32 -0
  116. package/types/adapters/mongo-adapter.d.ts +139 -0
  117. package/types/adapters/nats-adapter.d.ts +108 -0
  118. package/types/adapters/postgres-adapter.d.ts +139 -0
  119. package/types/adapters/redis-adapter.d.ts +70 -0
  120. package/types/adapters/redis-session-adapter.d.ts +82 -0
  121. package/types/adapters/redlock-adapter.d.ts +149 -0
  122. package/types/adapters/registry.d.ts +46 -0
  123. package/types/adapters/sqlite-adapter.d.ts +106 -0
  124. package/types/auth/index.d.ts +24 -0
  125. package/types/cli/commands/console-cmd.d.ts +37 -0
  126. package/types/cli/commands/new.d.ts +16 -0
  127. package/types/cli/commands/routes.d.ts +36 -0
  128. package/types/cli/commands/scaffold.d.ts +78 -0
  129. package/types/cli/commands/test-cmd.d.ts +14 -0
  130. package/types/cli/generators/index.d.ts +112 -0
  131. package/types/cli/index.d.ts +249 -0
  132. package/types/cli/template-engine.d.ts +40 -0
  133. package/types/core/ajv-mapper.d.ts +27 -0
  134. package/types/core/boot.d.ts +233 -0
  135. package/types/core/cluster-metrics.d.ts +52 -0
  136. package/types/core/config-loader.d.ts +13 -0
  137. package/types/core/config-validator.d.ts +30 -0
  138. package/types/core/ctx-builder.d.ts +80 -0
  139. package/types/core/envelope.d.ts +79 -0
  140. package/types/core/error-mapper.d.ts +17 -0
  141. package/types/core/formbody.d.ts +41 -0
  142. package/types/core/hub-link.d.ts +264 -0
  143. package/types/core/i18n.d.ts +178 -0
  144. package/types/core/index.d.ts +28 -0
  145. package/types/core/mega-app.d.ts +529 -0
  146. package/types/core/mega-cluster.d.ts +104 -0
  147. package/types/core/mega-server.d.ts +91 -0
  148. package/types/core/mega-service.d.ts +31 -0
  149. package/types/core/migration/dialect-registry.d.ts +22 -0
  150. package/types/core/migration/dialects/maria.d.ts +99 -0
  151. package/types/core/migration/dialects/mongo.d.ts +89 -0
  152. package/types/core/migration/dialects/postgres.d.ts +117 -0
  153. package/types/core/migration/dialects/sqlite.d.ts +111 -0
  154. package/types/core/migration/differ.d.ts +47 -0
  155. package/types/core/migration/generate.d.ts +56 -0
  156. package/types/core/migration/journal.d.ts +52 -0
  157. package/types/core/migration/model-scan.d.ts +19 -0
  158. package/types/core/migration/mongo-migration-db.d.ts +7 -0
  159. package/types/core/migration/schema-builder.d.ts +197 -0
  160. package/types/core/migration/schema-validator.d.ts +20 -0
  161. package/types/core/migration-lock.d.ts +33 -0
  162. package/types/core/migration-runner.d.ts +101 -0
  163. package/types/core/multipart.d.ts +86 -0
  164. package/types/core/openapi.d.ts +62 -0
  165. package/types/core/pipeline.d.ts +92 -0
  166. package/types/core/router.d.ts +159 -0
  167. package/types/core/routes-loader.d.ts +21 -0
  168. package/types/core/scope-registry.d.ts +14 -0
  169. package/types/core/security.d.ts +77 -0
  170. package/types/core/services-loader.d.ts +27 -0
  171. package/types/core/session-cleanup-schedule.d.ts +19 -0
  172. package/types/core/session-store.d.ts +18 -0
  173. package/types/core/session.d.ts +77 -0
  174. package/types/core/static-assets.d.ts +73 -0
  175. package/types/core/template.d.ts +106 -0
  176. package/types/core/workers-manager.d.ts +79 -0
  177. package/types/core/ws-cluster.d.ts +208 -0
  178. package/types/core/ws-compression.d.ts +112 -0
  179. package/types/core/ws-controller.d.ts +65 -0
  180. package/types/core/ws-message.d.ts +106 -0
  181. package/types/core/ws-presence.d.ts +273 -0
  182. package/types/core/ws-roster.d.ts +96 -0
  183. package/types/core/ws-upgrade.d.ts +231 -0
  184. package/types/errors/config-error.d.ts +10 -0
  185. package/types/errors/http-errors.d.ts +120 -0
  186. package/types/errors/index.d.ts +3 -0
  187. package/types/errors/mega-error.d.ts +32 -0
  188. package/types/index.d.ts +39 -0
  189. package/types/lib/asp/config.d.ts +49 -0
  190. package/types/lib/asp/crypto.d.ts +43 -0
  191. package/types/lib/asp/errors.d.ts +30 -0
  192. package/types/lib/asp/nonce-cache.d.ts +52 -0
  193. package/types/lib/asp/plugin.d.ts +30 -0
  194. package/types/lib/asp/ws-terminator.d.ts +45 -0
  195. package/types/lib/env-mapper.d.ts +14 -0
  196. package/types/lib/hub-protocol.d.ts +106 -0
  197. package/types/lib/index.d.ts +22 -0
  198. package/types/lib/logger/telegram-core.d.ts +104 -0
  199. package/types/lib/logger/telegram-transport.d.ts +45 -0
  200. package/types/lib/mega-brute-force.d.ts +66 -0
  201. package/types/lib/mega-circuit-breaker.d.ts +241 -0
  202. package/types/lib/mega-cron.d.ts +66 -0
  203. package/types/lib/mega-hash.d.ts +32 -0
  204. package/types/lib/mega-health.d.ts +41 -0
  205. package/types/lib/mega-job-queue.d.ts +176 -0
  206. package/types/lib/mega-job-worker.d.ts +130 -0
  207. package/types/lib/mega-job.d.ts +138 -0
  208. package/types/lib/mega-logger.d.ts +45 -0
  209. package/types/lib/mega-metrics.d.ts +285 -0
  210. package/types/lib/mega-plugin.d.ts +245 -0
  211. package/types/lib/mega-retry.d.ts +85 -0
  212. package/types/lib/mega-schedule.d.ts +260 -0
  213. package/types/lib/mega-shutdown.d.ts +135 -0
  214. package/types/lib/mega-tracing.d.ts +224 -0
  215. package/types/lib/mega-worker.d.ts +127 -0
  216. package/types/lib/otel-resource.d.ts +16 -0
  217. package/types/lib/worker-runner/process-entry.d.ts +1 -0
  218. package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
  219. package/types/lib/worker-runner/thread-entry.d.ts +1 -0
  220. package/types/lib/ws-hub.d.ts +234 -0
  221. package/types/models/crud-sql-builder.d.ts +48 -0
  222. package/types/models/index.d.ts +1 -0
  223. package/types/models/mega-model.d.ts +138 -0
  224. package/types/models/model-crud.d.ts +82 -0
  225. package/types/models/mongo-crud.d.ts +59 -0
  226. package/types/test/index.d.ts +84 -0
  227. package/.env +0 -127
  228. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
  229. package/sample/crud/apps/main/models/note.js +0 -71
  230. package/sample/crud/apps/main/models/user.js +0 -86
  231. package/sample/crud/package-lock.json +0 -5665
  232. package/sample/crud/yarn.lock +0 -2142
  233. package/sample/simple/package-lock.json +0 -1851
@@ -22,9 +22,10 @@
22
22
  // 명명 import — AJV 8 은 ESM/TS 정합을 위해 `Ajv` named export 를 제공한다(default 는 TS 에서
23
23
  // construct 불가로 잡힘). `ValidateFunction` 등 타입도 같은 모듈에서 가져온다.
24
24
  import { Ajv } from 'ajv'
25
+ import ajvFormats from 'ajv-formats'
25
26
  import { MegaError } from '../errors/mega-error.js'
26
27
  import { MegaWebSocketController } from './ws-controller.js'
27
- import { getHttpCtx } from './ctx-builder.js'
28
+ import { buildHttpPipeline, wrapPreHandler } from './pipeline.js'
28
29
 
29
30
  const HTTP_METHODS = Object.freeze(['get', 'post', 'put', 'patch', 'delete', 'head', 'options'])
30
31
 
@@ -35,6 +36,10 @@ const HTTP_METHODS = Object.freeze(['get', 'post', 'put', 'patch', 'delete', 'he
35
36
  * 우리 dispatch 경로에서 payload 만 검증하므로 직접 컴파일·실행한다.
36
37
  */
37
38
  const wsAjv = new Ajv({ allErrors: true })
39
+ // HTTP 검증(Fastify 네이티브 @fastify/ajv-compiler)은 ajv-formats 가 배선돼 `format:'email'` 등이
40
+ // 동작하는데, WS 쪽만 빠지면 같은 스키마가 WS 등록에서 부팅 throw 되는 비대칭이 생긴다(ADR-192).
41
+ // CJS default 인터롭 — 런타임 default 는 플러그인 함수지만 TS(nodenext)가 모듈 객체로 봐 cast.
42
+ ;/** @type {(ajv: Ajv) => unknown} */ (/** @type {unknown} */ (ajvFormats))(wsAjv)
38
43
 
39
44
  /**
40
45
  * `router.ws({ schemas })` 의 type 별 JSON Schema 를 **등록(부팅) 시점에 사전 컴파일**한다
@@ -119,6 +124,10 @@ export class Router {
119
124
  this._sourceFile = ctx.sourceFile
120
125
  /** @type {import('./mega-app.js').MegaApp | null} */
121
126
  this._app = ctx.app ?? null
127
+ /** @type {Function[]} 파일 레벨 transform — 이 Router(=라우트 파일)로 이후 등록되는 라우트에 합성 (ADR-194). */
128
+ this._fileTransforms = []
129
+ /** @type {Function[]} 파일 레벨 after — 위와 동일 스코프 (ADR-194). */
130
+ this._fileAfters = []
122
131
  this.http = this._buildHttpProxy()
123
132
  }
124
133
 
@@ -176,22 +185,26 @@ export class Router {
176
185
  const transform = this._validateMiddlewareArray(opts.transform, 'transform', method, path)
177
186
  const after = this._validateMiddlewareArray(opts.after, 'after', method, path)
178
187
 
188
+ // before/transform/after 합성은 Pipeline 모듈이 정본이다 (ADR-185) — arity 흡수·ctx 주입·
189
+ // after 에러 정책을 한곳에서 보장하고, describe() 로 라우트별 체인을 introspection 한다.
190
+ // 파일 레벨 transform/after(router.use stage 옵션, ADR-194)를 라우트 슬롯 뒤에 합성 —
191
+ // ADR-021 순서(transform/after: 라우트 → 파일). 앱/전역 슬롯은 MegaApp onRoute 가 잇는다.
192
+ const pipeline = buildHttpPipeline({
193
+ app: this._app,
194
+ method,
195
+ path,
196
+ handler,
197
+ before,
198
+ transform: [...transform, ...this._fileTransforms],
199
+ after: [...after, ...this._fileAfters],
200
+ })
201
+
179
202
  // 동적으로 schema/preHandler/preSerialization/onResponse 를 덧붙이므로 permissive 타입.
180
203
  /** @type {Record<string, any>} */
181
204
  const routeOpts = {
182
205
  method: method.toUpperCase(),
183
206
  url: path,
184
- handler: async (
185
- /** @type {import('fastify').FastifyRequest} */ req,
186
- /** @type {import('fastify').FastifyReply} */ reply,
187
- ) => {
188
- // canonical 핸들러 시그니처 (req, res, ctx) (ADR-074, docs/03 §581). ctx 는 요청 단위로 만들어
189
- // app/log/requestId/req/reply + db/cache/bus 접근자(ADR-102)를 노출. 기존 (req, reply) 핸들러는
190
- // 3번째 인자를 무시하므로 하위 호환. getHttpCtx 는 요청당 1회 캐싱이라 글로벌 미들웨어가 먼저
191
- // 만든 ctx 를 그대로 이어받는다(ADR-134 — 미들웨어→핸들러 ctx 공유).
192
- const ctx = getHttpCtx({ app: this._app, req, reply })
193
- return handler(req, reply, ctx)
194
- },
207
+ handler: pipeline.handler,
195
208
  }
196
209
 
197
210
  // schema (ADR-019) 그대로 Fastify 에 전달
@@ -209,55 +222,19 @@ export class Router {
209
222
  if (meta.deprecated === true) routeOpts.schema.deprecated = true
210
223
  }
211
224
 
212
- // before — Fastify preHandler 체인. 미들웨어를 arity-2 async 래퍼로 감싼다.
213
- //
214
- // 래핑하나: Fastify 는 async preHandler 의 arity 가 3 이상이면 3번째 인자를 콜백 `done` 으로
215
- // 오해해 "Async function has too many arguments" 로 **등록을 거부**한다. 그런데 인증 가드
216
- // `requireAuth`/`requireRole`(ADR-143) 시그니처가 `(req, reply, ctx)` 로 arity 3 다 — 글로벌
217
- // 미들웨어 경로(ADR-134)에서 ctx 를 받기 위함. 라우트 `before` 는 ctx 를 주입하지 않으므로(=undefined,
218
- // 가드는 req.session 으로 동작) arity-2 래퍼 `(req, reply)` 로 감싸면 Fastify 계약에 맞으면서 ADR-143 이
219
- // 문서화한 `{ before: [requireAuth] }` 가 실제로 동작한다. 래퍼는 각각 독립 preHandler 라 순서·reply
220
- // 단락(앞 미들웨어가 응답 전송 시 이후 미들웨어·핸들러 skip) 의미는 그대로다(ADR-156).
221
- if (before.length > 0) {
222
- routeOpts.preHandler = before.map(
223
- (fn) =>
224
- /** @param {import('fastify').FastifyRequest} req @param {import('fastify').FastifyReply} reply */
225
- async (req, reply) => fn(req, reply),
226
- )
227
- }
225
+ // before — preHandler / transform preSerialization 이른 단계 / after — onResponse.
226
+ // 합성·arity 처리·에러 정책은 전부 Pipeline 산출물(ADR-091/156/185 — pipeline.js 가 정본).
227
+ if (pipeline.preHandler) routeOpts.preHandler = pipeline.preHandler
228
+ if (pipeline.preSerialization) routeOpts.preSerialization = pipeline.preSerialization
229
+ if (pipeline.onResponse) routeOpts.onResponse = pipeline.onResponse
228
230
 
229
- // transform preSerialization 이른 단계. Fastify preSerialization 훅의 payload 를 변환.
230
- if (transform.length > 0) {
231
- routeOpts.preSerialization = async (
232
- /** @type {import('fastify').FastifyRequest} */ req,
233
- /** @type {import('fastify').FastifyReply} */ reply,
234
- /** @type {any} */ payload,
235
- ) => {
236
- let current = payload
237
- for (const fn of transform) {
238
- current = await fn(req, reply, current)
239
- }
240
- return current
241
- }
242
- }
243
-
244
- // after — onResponse 단계 (응답 전송 후). throw 시 warn 로그 + 응답 영향 없음 (ADR-091).
245
- if (after.length > 0) {
246
- routeOpts.onResponse = async (
247
- /** @type {import('fastify').FastifyRequest} */ req,
248
- /** @type {import('fastify').FastifyReply} */ reply,
249
- ) => {
250
- for (const fn of after) {
251
- try {
252
- await fn(req, reply)
253
- } catch (err) {
254
- // ADR-091: silent fallback 금지 — warn 로그.
255
- const log = req.log ?? console
256
- log.warn?.({ err, hook: 'after', method, path }, `after middleware threw — ignored (response already sent)`)
257
- }
258
- }
259
- }
260
- }
231
+ // 라우트별 체인 introspection 보관 (ADR-185) 디버그·CLI(`mega routes` 확장)용.
232
+ // _megaHttpPipelines Fastify 타입에 없는 우리 전용 보관소 — 부착 시 cast.
233
+ const fastifyWithPipelines = /** @type {import('fastify').FastifyInstance & { _megaHttpPipelines?: Array<() => Object> }} */ (
234
+ this._fastify
235
+ )
236
+ if (!fastifyWithPipelines._megaHttpPipelines) fastifyWithPipelines._megaHttpPipelines = []
237
+ fastifyWithPipelines._megaHttpPipelines.push(pipeline.describe)
261
238
 
262
239
  // routeOpts 는 동적 조립(method 대문자화·옵션 키 추가)이라 RouteOptions 로 캐스팅해 전달.
263
240
  this._fastify.route(/** @type {import('fastify').RouteOptions} */ (routeOpts))
@@ -344,19 +321,47 @@ export class Router {
344
321
  }
345
322
 
346
323
  /**
347
- * 파일 전체 적용 미들웨어 (router.use). fastify.addHook 으로 등록.
348
- * 실행 순서: 전역 → 앱 → 파일(router.use) → 라우트(before opts) → handler.
324
+ * 파일 전체 적용 미들웨어 (router.use, ADR-194 stage 3종).
325
+ *
326
+ * - `stage: 'before'`(디폴트) — fastify preHandler 훅 등록. 실행 순서: 전역 → 앱 → 파일(use)
327
+ * → 라우트(before opts) → handler. 라우트 `before` 와 동일하게 arity-2 래퍼 + ctx 주입 —
328
+ * raw 로 넘기면 arity-3 미들웨어(`requireAuth` 등 canonical `(req, reply, ctx)`)가 Fastify
329
+ * async hook arity 검사(`FST_ERR_HOOK_INVALID_ASYNC_HANDLER`)에 걸려 부팅이 거부된다(ADR-156).
330
+ * - `stage: 'transform'` — 응답 raw data 변환 `(req, reply, payload) => payload`. 이 Router
331
+ * (=라우트 파일)로 **이후 등록되는** 라우트의 transform 체인 뒤에 합성된다(라우트 → 파일,
332
+ * ADR-021). envelope wrap 전 단계.
333
+ * - `stage: 'after'` — 응답 전송 후 side-effect `(req, reply)`. 동일 스코프로 after 체인 뒤에
334
+ * 합성(라우트 → 파일). throw 는 warn 로그 후 무시(ADR-091).
335
+ *
336
+ * 세 stage 모두 **호출 이후 등록되는 라우트**에만 적용된다 — 파일 상단(라우트 등록 전)에서
337
+ * 호출할 것.
338
+ *
349
339
  * @param {Function} middleware
340
+ * @param {{ stage?: 'before' | 'transform' | 'after' }} [opts]
350
341
  */
351
- use(middleware) {
342
+ use(middleware, opts = {}) {
352
343
  if (typeof middleware !== 'function') {
353
344
  throw new MegaRouteError(
354
345
  'route.invalid_use',
355
346
  `router.use: middleware must be a function. Got: ${typeof middleware}.`,
356
347
  )
357
348
  }
358
- // 사용자 제공 범용 미들웨어 — Fastify preHandler 훅 시그니처로 캐스팅.
359
- this._fastify.addHook('preHandler', /** @type {any} */ (middleware))
349
+ const stage = opts.stage ?? 'before'
350
+ if (stage !== 'before' && stage !== 'transform' && stage !== 'after') {
351
+ throw new MegaRouteError(
352
+ 'route.invalid_use_stage',
353
+ `router.use: opts.stage must be 'before' | 'transform' | 'after'. Got: '${String(stage)}'.`,
354
+ )
355
+ }
356
+ if (stage === 'transform') {
357
+ this._fileTransforms.push(middleware)
358
+ return
359
+ }
360
+ if (stage === 'after') {
361
+ this._fileAfters.push(middleware)
362
+ return
363
+ }
364
+ this._fastify.addHook('preHandler', wrapPreHandler(middleware, this._app))
360
365
  }
361
366
 
362
367
  /**
@@ -243,16 +243,39 @@ function registerCsrfGuard(fastify, { hosts, logger, appName }) {
243
243
  logger?.debug?.({ app: appName, origin: req.headers.origin ?? req.headers.referer }, 'security.csrf origin mismatch')
244
244
  throw new MegaForbiddenError(
245
245
  'csrf.origin_mismatch',
246
- 'CSRF: Origin header does not match an allowed host (ADR-051).',
246
+ 'CSRF: Origin header does not match the request origin or an allowed host (scheme+host+port, ADR-051/186).',
247
247
  { details: { rule: 'origin_mismatch' } },
248
248
  )
249
249
  }
250
250
  })
251
251
  }
252
252
 
253
+ /** 스킴별 기본 포트 — Origin/Host 의 생략 포트 정규화용(WHATWG URL 은 기본 포트를 빈 문자열로 돌려준다). */
254
+ const DEFAULT_PORTS = Object.freeze({ 'http:': '80', 'https:': '443' })
255
+
253
256
  /**
254
- * CSRF Origin 검증(ADR-051)Origin/Referer hostname 요청 Host 또는 도메인과 일치하는가.
255
- * Origin·Referer 없으면(비브라우저 API 클라) 통과 — CSRF 는 브라우저 쿠키 공격이라 해당 없음.
257
+ * URL 실효 포트 명시 포트, 생략 스킴 기본 포트(http=80/https=443). 미지원 스킴은 빈 문자열.
258
+ * @param {URL} u @returns {string}
259
+ */
260
+ function effectivePort(u) {
261
+ if (u.port !== '') return u.port
262
+ return DEFAULT_PORTS[/** @type {'http:'|'https:'} */ (u.protocol)] ?? ''
263
+ }
264
+
265
+ /**
266
+ * CSRF Origin 검증(ADR-051/186) — Origin/Referer 의 **출처(스킴+hostname+포트)** 가 요청 자신 또는
267
+ * 앱 도메인 allowlist 와 일치하는가. hostname 단독 비교는 http→https 강등 출처·포트 교차 출처를
268
+ * 통과시키므로(F5 audit S-1) 스킴·포트까지 본다(생략 포트는 스킴 기본 포트로 정규화).
269
+ *
270
+ * - Origin·Referer 둘 다 없으면(비브라우저 API 클라) 통과 — CSRF 는 브라우저 쿠키 공격이라 해당 없음.
271
+ * - 동일 출처: Origin 의 스킴이 `req.protocol`, hostname·포트가 Host 헤더와 일치해야 한다.
272
+ * ⚠️ TLS 종료 프록시 뒤에서는 `server.trustProxy`(ADR-181)를 켜야 `req.protocol` 이 https 로
273
+ * 잡힌다 — 미설정 시 https Origin 이 거부된다(fail-closed: 오설정을 조용히 통과시키지 않음).
274
+ * - allowlist(`app.config.hosts`) 두 형태:
275
+ * `'https://admin.example.com[:port]'`(스킴 포함) = 그 출처와 정확 일치.
276
+ * `'admin.example.com[:port]'`(스킴 생략) = 요청과 같은 스킴만 허용, 포트 생략 = 그 스킴의 기본 포트.
277
+ * (포트 불문 entry 는 두지 않는다 — 자기 도메인 entry 가 포트 검사를 무력화하는 구멍이 된다. 앱 자신의
278
+ * 출처는 Host 헤더가 포트를 포함하므로 위 동일 출처 검사가 비표준 포트도 커버한다.)
256
279
  *
257
280
  * @param {import('fastify').FastifyRequest} req
258
281
  * @param {string[]} hosts - 앱 도메인 allowlist(`app.config.hosts`).
@@ -261,15 +284,50 @@ function registerCsrfGuard(fastify, { hosts, logger, appName }) {
261
284
  function isOriginAllowed(req, hosts) {
262
285
  const raw = req.headers.origin ?? req.headers.referer
263
286
  if (!raw) return true // 비브라우저 클라 — Origin 없음.
264
- let originHost
287
+ /** @type {URL} */
288
+ let origin
265
289
  try {
266
- originHost = new URL(String(raw)).hostname
290
+ origin = new URL(String(raw))
267
291
  } catch {
268
292
  // 파싱 불가능한 Origin = 위조 의심 → 거부(묵시 무시 아님, 명시 거부).
269
293
  return false
270
294
  }
271
- const reqHostname = String(req.headers.host ?? '').split(':')[0]
272
- if (originHost === reqHostname) return true // 동일 출처.
273
- // 앱 도메인 allowlist 와도 비교(host:port 형태면 hostname 만).
274
- return hosts.some((h) => originHost === String(h).split(':')[0])
295
+ const reqProto = `${req.protocol}:`
296
+ const originPort = effectivePort(origin)
297
+
298
+ // 동일 출처 스킴·hostname·포트 전부 일치. Host 헤더가 없거나 호스트로 못 읽히면 동일 출처 판정을
299
+ // 건너뛰고 allowlist 로만 판단한다(통과 아님 — fail-closed 방향).
300
+ const hostHeader = String(req.headers.host ?? '')
301
+ if (hostHeader.length > 0) {
302
+ try {
303
+ const reqUrl = new URL(`${reqProto}//${hostHeader}`)
304
+ if (origin.protocol === reqProto && origin.hostname === reqUrl.hostname && originPort === effectivePort(reqUrl)) {
305
+ return true
306
+ }
307
+ } catch {
308
+ // 변조/형식오류 Host 헤더 — 동일 출처 비교 불가. 아래 allowlist 비교로 진행(매치 없으면 거부).
309
+ }
310
+ }
311
+
312
+ // 앱 도메인 allowlist.
313
+ for (const h of hosts) {
314
+ const entry = String(h)
315
+ try {
316
+ if (entry.includes('://')) {
317
+ // 스킴 포함 entry — 출처 정확 일치(교차 스킴 허용은 운영자가 명시적으로만).
318
+ const u = new URL(entry)
319
+ if (origin.protocol === u.protocol && origin.hostname === u.hostname && originPort === effectivePort(u)) return true
320
+ } else {
321
+ // 스킴 생략 entry — 요청 스킴 강제(https 앱에 http 출처 불허). 포트 생략 = 그 스킴의 기본 포트
322
+ // (포트 불문으로 두면 자기 도메인 entry 가 포트 검사를 무력화한다 — 비표준 포트는 entry 에 명시).
323
+ if (origin.protocol !== reqProto) continue
324
+ const u = new URL(`${reqProto}//${entry}`)
325
+ if (origin.hostname === u.hostname && originPort === effectivePort(u)) return true
326
+ }
327
+ } catch {
328
+ // 형식 오류 entry 는 "매치 실패"로 간주하고 다음 entry 로 — 조용한 통과가 아니라 거부 방향(fail-closed).
329
+ continue
330
+ }
331
+ }
332
+ return false
275
333
  }
@@ -65,7 +65,8 @@ export function buildWorkers(globalConfig, { projectRoot, registerShutdownHook =
65
65
  }
66
66
  if (registerShutdownHook) {
67
67
  MegaShutdown.unregister(SHUTDOWN_HOOK)
68
- MegaShutdown.register(SHUTDOWN_HOOK, async () => stopAll())
68
+ // 'workers' stage — 잡/앱 정리(워커를 수 있는 단계) 뒤·어댑터 disconnect 앞에 풀을 내린다.
69
+ MegaShutdown.register(SHUTDOWN_HOOK, async () => stopAll(), { stage: 'workers' })
69
70
  }
70
71
  }
71
72
 
@@ -132,6 +133,15 @@ export function list() {
132
133
  })
133
134
  }
134
135
 
136
+ /**
137
+ * Proxy get 트랩이 throw 하면 안 되는 "암묵 조회" string 키 모음. `await`(then)·`JSON.stringify`(toJSON)·
138
+ * 문자열/원시값 변환(toString/valueOf)·inspect 류(constructor/inspect)가 일반 객체에 으레 조회하는 키라,
139
+ * 미등록 fail-fast 대상(오타)과 구분해 undefined 를 돌려준다. 같은 이름이 실제로 등록돼 있으면 그대로
140
+ * 해석한다. `then` 은 Symbol 이 아니라 **string 키**다 — Promise resolution 이 string 'then' 을 조회하므로
141
+ * 이 가드가 없으면 `await ctx.workers`/`await ctx.services` 가 not_registered 로 죽는다.
142
+ */
143
+ export const PROXY_PROTOCOL_KEYS = new Set(['then', 'catch', 'finally', 'toJSON', 'toString', 'valueOf', 'constructor', 'inspect'])
144
+
135
145
  /**
136
146
  * `ctx.workers` 로 줄 객체 — `ctx.workers.<name>.run(task)`. 미등록 이름 접근은 fail-fast.
137
147
  * 동일 Proxy 를 boot ctx·요청 ctx 가 공유한다(워커는 글로벌 자원이라 요청·앱 무관).
@@ -144,6 +154,7 @@ export function contextProxy() {
144
154
  {
145
155
  get(_t, prop) {
146
156
  if (typeof prop !== 'string') return undefined
157
+ if (!has(prop) && PROXY_PROTOCOL_KEYS.has(prop)) return undefined // 암묵 조회 키 — throw 금지.
147
158
  return get(prop) // 미등록 → worker.not_registered throw.
148
159
  },
149
160
  has(_t, prop) {
@@ -150,7 +150,10 @@ export class MegaWsCluster {
150
150
  this._heartbeatTimer.unref?.()
151
151
  this._sweepTimer.unref?.()
152
152
  // 신규 인스턴스 — 전원에게 즉시 heartbeat 요청(빠른 수렴) + 자기 현재 멤버 공지.
153
- await this._publishRoster({ op: ROSTER_OP.SYNC_REQUEST }).catch(() => {})
153
+ // 실패는 비치명적(heartbeat 주기에 자연 수렴)이나 묵히지 않는다 — roster 수렴 지연 관측용.
154
+ await this._publishRoster({ op: ROSTER_OP.SYNC_REQUEST }).catch((err) =>
155
+ this._log?.warn?.({ err, app: this._appName }, 'ws-cluster roster sync_request publish failed'),
156
+ )
154
157
  await this._publishHeartbeat()
155
158
  }
156
159
 
@@ -249,11 +252,15 @@ export class MegaWsCluster {
249
252
  // graceful: 자기 멤버를 LEAVE 로 공지(crash 가 아닌 정상 종료라 즉시 정리되게).
250
253
  if (this._rosterDriver === 'nats' && this._localMembers.size > 0) {
251
254
  for (const sessionId of this._localMembers.keys()) {
252
- await this._publishRoster({ op: ROSTER_OP.REMOVE, sessionId }).catch(() => {})
255
+ // 실패해도 종료는 계속(TTL sweep 정리) 단 타 인스턴스에 stale presence 가 TTL 까지 남으므로 알린다.
256
+ await this._publishRoster({ op: ROSTER_OP.REMOVE, sessionId }).catch((err) =>
257
+ this._log?.warn?.({ err, app: this._appName, sessionId }, 'ws-cluster graceful leave publish failed (stale until TTL)'),
258
+ )
253
259
  }
254
260
  }
255
261
  for (const sub of this._subs) {
256
- await sub.unsubscribe().catch(() => {})
262
+ // 종료 중 unsubscribe 실패는 비치명적(연결 자체가 곧 닫힘) 묵히지 않고 debug 로 남긴다.
263
+ await sub.unsubscribe().catch((err) => this._log?.debug?.({ err, app: this._appName }, 'ws-cluster unsubscribe failed (stopping)'))
257
264
  }
258
265
  this._subs = []
259
266
  this._localMembers.clear()
@@ -22,6 +22,47 @@ import { randomBytes } from 'node:crypto'
22
22
  /** 현 프로토콜 버전 (04-data-models §6.2 `v: { const: 1 }`). */
23
23
  export const WS_PROTOCOL_VERSION = 1
24
24
 
25
+ /**
26
+ * 이 서버가 지원하는 WS envelope 프로토콜 버전 목록. 버전은 선형 누적 계약 — vN 지원 = v1..vN 전부
27
+ * 지원. 협상은 클라이언트 제안과의 **최고 상호 버전**을 채택한다(v2 도입 시 [1, 2] 로 확장).
28
+ * @type {ReadonlyArray<number>}
29
+ */
30
+ export const SUPPORTED_WS_PROTOCOL_VERSIONS = Object.freeze([WS_PROTOCOL_VERSION])
31
+
32
+ /** WS 버전 협상 subprotocol 토큰 형식 — `mega.v<양의 정수>` (예: 'mega.v1'). */
33
+ export const WS_SUBPROTOCOL_PATTERN = /^mega\.v([1-9][0-9]*)$/
34
+
35
+ /**
36
+ * Sec-WebSocket-Protocol 제안 목록에서 envelope 프로토콜 버전을 협상한다.
37
+ *
38
+ * 규칙 (구버전 클라/서버 혼합 롤링 배포 안전):
39
+ * - `mega.v<N>` 토큰이 하나도 없으면 → **레거시 폴백 v1** (`{ version: 1, subprotocol: undefined }`)
40
+ * — subprotocol 미사용 클라(현 WASM MegaSocket·브라우저 기본)는 전부 v1 로 취급한다.
41
+ * - 상호 지원 버전이 있으면 최고 버전 채택 → `{ version: N, subprotocol: 'mega.v<N>' }`
42
+ * (서버가 핸드셰이크 응답 `Sec-WebSocket-Protocol` 로 확정 통지 — 클라는 `WebSocket.protocol` 로 읽는다).
43
+ * - `mega.v*` 를 제안했지만 상호 버전이 없으면(클라가 미래 버전만 지원) → `null`
44
+ * — 호출부는 subprotocol 없이 수락한다(클라 입장에선 "서버가 내 버전을 못 함" = v1 폴백 신호.
45
+ * v1 을 못 하는 클라이언트는 스스로 닫는다). 핸드셰이크를 거부하지 않아 하위호환이 절대 깨지지 않는다.
46
+ *
47
+ * @param {Iterable<string>} offered - 클라이언트가 제안한 subprotocol 토큰들(ws 는 Set 을 전달).
48
+ * @param {ReadonlyArray<number>} [supported] - 서버 지원 버전 목록.
49
+ * @returns {{ version: number, subprotocol: string | undefined } | null} 협상 결과, 상호 버전 없으면 null.
50
+ */
51
+ export function negotiateWsProtocol(offered, supported = SUPPORTED_WS_PROTOCOL_VERSIONS) {
52
+ let sawMegaToken = false
53
+ let best = 0
54
+ for (const token of offered ?? []) {
55
+ const m = typeof token === 'string' ? WS_SUBPROTOCOL_PATTERN.exec(token) : null
56
+ if (!m) continue // mega.* 외 토큰(앱 자체 subprotocol 등)은 협상 대상이 아니다.
57
+ sawMegaToken = true
58
+ const version = Number(m[1])
59
+ if (supported.includes(version) && version > best) best = version
60
+ }
61
+ if (!sawMegaToken) return { version: WS_PROTOCOL_VERSION, subprotocol: undefined } // 레거시 v1 폴백.
62
+ if (best === 0) return null // mega.v* 제안은 있었으나 상호 버전 없음.
63
+ return { version: best, subprotocol: `mega.v${best}` }
64
+ }
65
+
25
66
  /**
26
67
  * 메시지 `type` / `error.code` 패턴 — `domain.action[.result]` (ADR-016, §6.2/§6.3).
27
68
  * 점(`.`)이 최소 1개 강제 → 베이스 메서드명(`onMessage` 등, 점 없음) 과 절대 충돌하지 않음.
@@ -129,12 +170,14 @@ function isPlainObject(v) {
129
170
  * `details` 로 그대로 실어 보낼 수 있도록 (ADR-075 배열 표준).
130
171
  *
131
172
  * @param {any} msg
173
+ * @param {{ version?: number }} [opts] - `version` = 이 연결에서 협상된 envelope 버전
174
+ * ({@link negotiateWsProtocol}). 미지정 시 v1 — 협상 없는 기존 호출부와 하위호환.
132
175
  * @returns {string[]} 위반 메시지 목록 (없으면 빈 배열).
133
176
  */
134
- export function validateWsMessage(msg) {
177
+ export function validateWsMessage(msg, { version = WS_PROTOCOL_VERSION } = {}) {
135
178
  if (!isPlainObject(msg)) return ['message must be a non-null object']
136
179
  const errors = []
137
- if (msg.v !== WS_PROTOCOL_VERSION) errors.push(`v must be ${WS_PROTOCOL_VERSION}`)
180
+ if (msg.v !== version) errors.push(`v must be ${version}`)
138
181
  if (typeof msg.id !== 'string') errors.push('id must be a string')
139
182
  if (typeof msg.type !== 'string' || !WS_TYPE_PATTERN.test(msg.type)) {
140
183
  errors.push(`type must match ${WS_TYPE_PATTERN.source}`)
@@ -154,10 +197,11 @@ export function validateWsMessage(msg) {
154
197
  * JSON 문자열 → 검증된 WS 메시지 envelope. 파싱/검증 실패 시 throw (silent 금지).
155
198
  *
156
199
  * @param {string} json - wire 평문 JSON (ASP 복호화 후 또는 `P:` 평문).
200
+ * @param {{ version?: number }} [opts] - 협상된 envelope 버전(미지정 시 v1, {@link validateWsMessage}).
157
201
  * @returns {Object} 검증 통과한 envelope.
158
202
  * @throws {Error} JSON 파싱 실패 또는 schema 위반 (메시지에 사유 포함).
159
203
  */
160
- export function parseWsMessage(json) {
204
+ export function parseWsMessage(json, opts) {
161
205
  let obj
162
206
  try {
163
207
  obj = JSON.parse(json)
@@ -168,7 +212,7 @@ export function parseWsMessage(json) {
168
212
  { cause: err },
169
213
  )
170
214
  }
171
- const errors = validateWsMessage(obj)
215
+ const errors = validateWsMessage(obj, opts)
172
216
  if (errors.length > 0) {
173
217
  throw new Error(`parseWsMessage: invalid WS message — ${errors.join('; ')}`)
174
218
  }