mega-framework 0.1.5 → 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.
- package/bin/mega-ws-hub.js +2 -2
- package/package.json +32 -8
- package/sample/crud/.env +156 -8
- package/sample/crud/.env.example +153 -28
- package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
- package/sample/crud/.mega/journal/snapshot.json +261 -0
- package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
- package/sample/crud/apps/main/controllers/web-controller.js +7 -5
- package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
- package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
- package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
- package/sample/crud/apps/main/models/log-partition-model.js +105 -0
- package/sample/crud/apps/main/models/note-model.js +79 -0
- package/sample/crud/apps/main/models/user-level-model.js +24 -0
- package/sample/crud/apps/main/models/user-model.js +146 -0
- package/sample/crud/apps/main/models/user-type-model.js +21 -0
- package/sample/crud/apps/main/models/wallet-model.js +24 -0
- package/sample/crud/apps/main/routes/users.js +55 -10
- package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
- package/sample/crud/apps/main/services/auth-service.js +39 -24
- package/sample/crud/apps/main/services/log-partition-service.js +101 -0
- package/sample/crud/apps/main/services/note-service.js +6 -6
- package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
- package/sample/crud/apps/main/services/user-service.js +62 -21
- package/sample/crud/apps/main/views/auth/login.ejs +6 -6
- package/sample/crud/apps/main/views/auth/register.ejs +46 -5
- package/sample/crud/apps/main/views/users/edit.ejs +42 -5
- package/sample/crud/apps/main/views/users/list.ejs +6 -2
- package/sample/crud/apps/main/views/users/new.ejs +56 -4
- package/sample/crud/docs/log_partition_design.mm.md +23 -0
- package/sample/crud/mega.config.js +63 -3
- package/sample/crud/package.json +3 -3
- package/sample/crud/scripts/start-ws-hub.sh +2 -2
- package/sample/simple/package.json +2 -2
- package/src/adapters/adapter-manager.js +2 -1
- package/src/adapters/adapter-options.js +30 -0
- package/src/adapters/maria-adapter.js +26 -3
- package/src/adapters/mega-db-adapter.js +7 -1
- package/src/adapters/mongo-adapter.js +19 -1
- package/src/adapters/postgres-adapter.js +25 -2
- package/src/adapters/sqlite-adapter.js +20 -1
- package/src/cli/commands/new.js +13 -3
- package/src/cli/commands/scaffold.js +137 -33
- package/src/cli/generators/index.js +82 -2
- package/src/cli/index.js +478 -104
- package/src/core/ajv-mapper.js +27 -2
- package/src/core/boot.js +485 -237
- package/src/core/cluster-metrics.js +13 -4
- package/src/core/config-validator.js +25 -0
- package/src/core/ctx-builder.js +6 -2
- package/src/core/envelope.js +112 -12
- package/src/core/hub-link.js +65 -4
- package/src/core/i18n.js +11 -1
- package/src/core/index.js +6 -2
- package/src/core/mega-app.js +223 -481
- package/src/core/mega-cluster.js +54 -13
- package/src/core/mega-server.js +40 -9
- package/src/core/migration/dialect-registry.js +107 -0
- package/src/core/migration/dialects/README.md +62 -0
- package/src/core/migration/dialects/maria.js +496 -0
- package/src/core/migration/dialects/mongo.js +824 -0
- package/src/core/migration/dialects/postgres.js +563 -0
- package/src/core/migration/dialects/sqlite.js +476 -0
- package/src/core/migration/differ.js +456 -0
- package/src/core/migration/generate.js +508 -0
- package/src/core/migration/journal.js +167 -0
- package/src/core/migration/model-scan.js +84 -0
- package/src/core/migration/mongo-migration-db.js +97 -0
- package/src/core/migration/schema-builder.js +400 -0
- package/src/core/migration/schema-validator.js +315 -0
- package/src/core/migration-lock.js +205 -0
- package/src/core/migration-runner.js +166 -38
- package/src/core/multipart.js +28 -5
- package/src/core/pipeline.js +129 -0
- package/src/core/router.js +70 -65
- package/src/core/scope-registry.js +0 -1
- package/src/core/security.js +67 -9
- package/src/core/workers-manager.js +12 -1
- package/src/core/ws-cluster.js +10 -3
- package/src/core/ws-message.js +48 -4
- package/src/core/ws-presence.js +624 -0
- package/src/core/ws-roster.js +4 -1
- package/src/core/ws-upgrade.js +118 -12
- package/src/index.js +1 -1
- package/src/lib/hub-protocol.js +29 -0
- package/src/lib/mega-health.js +25 -4
- package/src/lib/mega-job-queue.js +98 -21
- package/src/lib/mega-job.js +29 -0
- package/src/lib/mega-logger.js +1 -1
- package/src/lib/mega-metrics.js +3 -12
- package/src/lib/mega-plugin.js +34 -3
- package/src/lib/mega-schedule.js +40 -22
- package/src/lib/mega-shutdown.js +162 -49
- package/src/lib/mega-tracing.js +66 -19
- package/src/lib/mega-worker.js +5 -1
- package/src/lib/otel-resource.js +36 -0
- package/src/{cli → lib}/ws-hub.js +51 -8
- package/src/models/crud-sql-builder.js +133 -0
- package/src/models/mega-model.js +82 -2
- package/src/models/model-crud.js +483 -0
- package/src/models/mongo-crud.js +285 -0
- package/templates/model/code-mongo.tpl +35 -0
- package/templates/model/code.tpl +15 -1
- package/templates/model/test-mongo.tpl +38 -0
- package/templates/model/test.tpl +4 -0
- package/types/adapters/adapter-manager.d.ts +95 -0
- package/types/adapters/adapter-options.d.ts +91 -0
- package/types/adapters/file-adapter.d.ts +94 -0
- package/types/adapters/file-session-adapter.d.ts +101 -0
- package/types/adapters/index.d.ts +20 -0
- package/types/adapters/maria-adapter.d.ts +115 -0
- package/types/adapters/mega-adapter.d.ts +215 -0
- package/types/adapters/mega-bus-adapter.d.ts +45 -0
- package/types/adapters/mega-cache-adapter.d.ts +47 -0
- package/types/adapters/mega-db-adapter.d.ts +47 -0
- package/types/adapters/mega-lock-adapter.d.ts +62 -0
- package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
- package/types/adapters/mega-session-adapter.d.ts +32 -0
- package/types/adapters/mongo-adapter.d.ts +139 -0
- package/types/adapters/nats-adapter.d.ts +108 -0
- package/types/adapters/postgres-adapter.d.ts +139 -0
- package/types/adapters/redis-adapter.d.ts +70 -0
- package/types/adapters/redis-session-adapter.d.ts +82 -0
- package/types/adapters/redlock-adapter.d.ts +149 -0
- package/types/adapters/registry.d.ts +46 -0
- package/types/adapters/sqlite-adapter.d.ts +106 -0
- package/types/auth/index.d.ts +24 -0
- package/types/cli/commands/console-cmd.d.ts +37 -0
- package/types/cli/commands/new.d.ts +16 -0
- package/types/cli/commands/routes.d.ts +36 -0
- package/types/cli/commands/scaffold.d.ts +78 -0
- package/types/cli/commands/test-cmd.d.ts +14 -0
- package/types/cli/generators/index.d.ts +112 -0
- package/types/cli/index.d.ts +249 -0
- package/types/cli/template-engine.d.ts +40 -0
- package/types/core/ajv-mapper.d.ts +27 -0
- package/types/core/boot.d.ts +233 -0
- package/types/core/cluster-metrics.d.ts +52 -0
- package/types/core/config-loader.d.ts +13 -0
- package/types/core/config-validator.d.ts +30 -0
- package/types/core/ctx-builder.d.ts +80 -0
- package/types/core/envelope.d.ts +79 -0
- package/types/core/error-mapper.d.ts +17 -0
- package/types/core/formbody.d.ts +41 -0
- package/types/core/hub-link.d.ts +264 -0
- package/types/core/i18n.d.ts +178 -0
- package/types/core/index.d.ts +28 -0
- package/types/core/mega-app.d.ts +529 -0
- package/types/core/mega-cluster.d.ts +104 -0
- package/types/core/mega-server.d.ts +91 -0
- package/types/core/mega-service.d.ts +31 -0
- package/types/core/migration/dialect-registry.d.ts +22 -0
- package/types/core/migration/dialects/maria.d.ts +99 -0
- package/types/core/migration/dialects/mongo.d.ts +89 -0
- package/types/core/migration/dialects/postgres.d.ts +117 -0
- package/types/core/migration/dialects/sqlite.d.ts +111 -0
- package/types/core/migration/differ.d.ts +47 -0
- package/types/core/migration/generate.d.ts +56 -0
- package/types/core/migration/journal.d.ts +52 -0
- package/types/core/migration/model-scan.d.ts +19 -0
- package/types/core/migration/mongo-migration-db.d.ts +7 -0
- package/types/core/migration/schema-builder.d.ts +197 -0
- package/types/core/migration/schema-validator.d.ts +20 -0
- package/types/core/migration-lock.d.ts +33 -0
- package/types/core/migration-runner.d.ts +101 -0
- package/types/core/multipart.d.ts +86 -0
- package/types/core/openapi.d.ts +62 -0
- package/types/core/pipeline.d.ts +92 -0
- package/types/core/router.d.ts +159 -0
- package/types/core/routes-loader.d.ts +21 -0
- package/types/core/scope-registry.d.ts +14 -0
- package/types/core/security.d.ts +77 -0
- package/types/core/services-loader.d.ts +27 -0
- package/types/core/session-cleanup-schedule.d.ts +19 -0
- package/types/core/session-store.d.ts +18 -0
- package/types/core/session.d.ts +77 -0
- package/types/core/static-assets.d.ts +73 -0
- package/types/core/template.d.ts +106 -0
- package/types/core/workers-manager.d.ts +79 -0
- package/types/core/ws-cluster.d.ts +208 -0
- package/types/core/ws-compression.d.ts +112 -0
- package/types/core/ws-controller.d.ts +65 -0
- package/types/core/ws-message.d.ts +106 -0
- package/types/core/ws-presence.d.ts +273 -0
- package/types/core/ws-roster.d.ts +96 -0
- package/types/core/ws-upgrade.d.ts +231 -0
- package/types/errors/config-error.d.ts +10 -0
- package/types/errors/http-errors.d.ts +120 -0
- package/types/errors/index.d.ts +3 -0
- package/types/errors/mega-error.d.ts +32 -0
- package/types/index.d.ts +39 -0
- package/types/lib/asp/config.d.ts +49 -0
- package/types/lib/asp/crypto.d.ts +43 -0
- package/types/lib/asp/errors.d.ts +30 -0
- package/types/lib/asp/nonce-cache.d.ts +52 -0
- package/types/lib/asp/plugin.d.ts +30 -0
- package/types/lib/asp/ws-terminator.d.ts +45 -0
- package/types/lib/env-mapper.d.ts +14 -0
- package/types/lib/hub-protocol.d.ts +106 -0
- package/types/lib/index.d.ts +22 -0
- package/types/lib/logger/telegram-core.d.ts +104 -0
- package/types/lib/logger/telegram-transport.d.ts +45 -0
- package/types/lib/mega-brute-force.d.ts +66 -0
- package/types/lib/mega-circuit-breaker.d.ts +241 -0
- package/types/lib/mega-cron.d.ts +66 -0
- package/types/lib/mega-hash.d.ts +32 -0
- package/types/lib/mega-health.d.ts +41 -0
- package/types/lib/mega-job-queue.d.ts +176 -0
- package/types/lib/mega-job-worker.d.ts +130 -0
- package/types/lib/mega-job.d.ts +138 -0
- package/types/lib/mega-logger.d.ts +45 -0
- package/types/lib/mega-metrics.d.ts +285 -0
- package/types/lib/mega-plugin.d.ts +245 -0
- package/types/lib/mega-retry.d.ts +85 -0
- package/types/lib/mega-schedule.d.ts +260 -0
- package/types/lib/mega-shutdown.d.ts +135 -0
- package/types/lib/mega-tracing.d.ts +224 -0
- package/types/lib/mega-worker.d.ts +127 -0
- package/types/lib/otel-resource.d.ts +16 -0
- package/types/lib/worker-runner/process-entry.d.ts +1 -0
- package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
- package/types/lib/worker-runner/thread-entry.d.ts +1 -0
- package/types/lib/ws-hub.d.ts +234 -0
- package/types/models/crud-sql-builder.d.ts +48 -0
- package/types/models/index.d.ts +1 -0
- package/types/models/mega-model.d.ts +138 -0
- package/types/models/model-crud.d.ts +82 -0
- package/types/models/mongo-crud.d.ts +59 -0
- package/types/test/index.d.ts +84 -0
- package/.env +0 -127
- package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
- package/sample/crud/apps/main/models/note.js +0 -71
- package/sample/crud/apps/main/models/user.js +0 -86
- package/sample/crud/package-lock.json +0 -5665
- package/sample/crud/yarn.lock +0 -2142
- package/sample/simple/package-lock.json +0 -1851
package/src/core/router.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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 —
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
348
|
-
*
|
|
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
|
-
|
|
359
|
-
|
|
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
|
/**
|
|
@@ -16,7 +16,6 @@ export const GLOBAL_ONLY_KEYS = Object.freeze([
|
|
|
16
16
|
'apps', // 활성 앱 whitelist (ADR-066)
|
|
17
17
|
'asp', // masterSecret 등 시크릿
|
|
18
18
|
'health', // /health, /health/ready
|
|
19
|
-
'tracing', // OpenTelemetry (ADR-077)
|
|
20
19
|
'plugins', // 명시 등록 배열 (ADR-079)
|
|
21
20
|
'jobs', // MegaJob 서브클래스 배열 — mega worker 가 소비 (ADR-123)
|
|
22
21
|
'schedules', // MegaSchedule 서브클래스 배열 — mega scheduler 가 소비 (ADR-123)
|
package/src/core/security.js
CHANGED
|
@@ -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
|
-
*
|
|
255
|
-
*
|
|
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
|
-
|
|
287
|
+
/** @type {URL} */
|
|
288
|
+
let origin
|
|
265
289
|
try {
|
|
266
|
-
|
|
290
|
+
origin = new URL(String(raw))
|
|
267
291
|
} catch {
|
|
268
292
|
// 파싱 불가능한 Origin = 위조 의심 → 거부(묵시 무시 아님, 명시 거부).
|
|
269
293
|
return false
|
|
270
294
|
}
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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) {
|
package/src/core/ws-cluster.js
CHANGED
|
@@ -150,7 +150,10 @@ export class MegaWsCluster {
|
|
|
150
150
|
this._heartbeatTimer.unref?.()
|
|
151
151
|
this._sweepTimer.unref?.()
|
|
152
152
|
// 신규 인스턴스 — 전원에게 즉시 heartbeat 요청(빠른 수렴) + 자기 현재 멤버 공지.
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
package/src/core/ws-message.js
CHANGED
|
@@ -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 !==
|
|
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
|
}
|