mega-framework 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +127 -0
- package/.env.example +186 -0
- package/.prettierrc.json +8 -0
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/bin/mega-ws-hub.js +15 -0
- package/bin/mega.js +38 -0
- package/docker-compose.yml +201 -0
- package/eslint.config.js +57 -0
- package/infra/otel-collector-config.yaml +43 -0
- package/jsconfig.json +18 -0
- package/package.json +121 -0
- package/sample/crud/.env +18 -0
- package/sample/crud/.env.example +50 -0
- package/sample/crud/README.md +85 -0
- package/sample/crud/apps/main/app.config.js +114 -0
- package/sample/crud/apps/main/channels/chat-bus.js +115 -0
- package/sample/crud/apps/main/channels/chat-channel.js +145 -0
- package/sample/crud/apps/main/controllers/auth-controller.js +144 -0
- package/sample/crud/apps/main/controllers/cron-controller.js +34 -0
- package/sample/crud/apps/main/controllers/guide-controller.js +37 -0
- package/sample/crud/apps/main/controllers/jobs-controller.js +43 -0
- package/sample/crud/apps/main/controllers/logs-controller.js +35 -0
- package/sample/crud/apps/main/controllers/metrics-controller.js +22 -0
- package/sample/crud/apps/main/controllers/note-controller.js +116 -0
- package/sample/crud/apps/main/controllers/perf-controller.js +38 -0
- package/sample/crud/apps/main/controllers/redis-controller.js +36 -0
- package/sample/crud/apps/main/controllers/tracing-controller.js +43 -0
- package/sample/crud/apps/main/controllers/upload-controller.js +98 -0
- package/sample/crud/apps/main/controllers/user-controller.js +34 -0
- package/sample/crud/apps/main/controllers/web-controller.js +137 -0
- package/sample/crud/apps/main/controllers/worker-controller.js +57 -0
- package/sample/crud/apps/main/controllers/ws-controller.js +29 -0
- package/sample/crud/apps/main/jobs/email-job.js +72 -0
- package/sample/crud/apps/main/locales/client/en.json +3 -0
- package/sample/crud/apps/main/locales/client/ko.json +3 -0
- package/sample/crud/apps/main/locales/server/en.json +316 -0
- package/sample/crud/apps/main/locales/server/ko.json +316 -0
- package/sample/crud/apps/main/middleware/web-auth.js +40 -0
- package/sample/crud/apps/main/middleware/ws-auth.js +48 -0
- package/sample/crud/apps/main/migrations/20260606000001-create-users.js +27 -0
- package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +30 -0
- package/sample/crud/apps/main/models/note.js +71 -0
- package/sample/crud/apps/main/models/user.js +86 -0
- package/sample/crud/apps/main/public/css/app.css +101 -0
- package/sample/crud/apps/main/public/css/guide.css +137 -0
- package/sample/crud/apps/main/public/js/app.js +54 -0
- package/sample/crud/apps/main/public/js/perf.js +129 -0
- package/sample/crud/apps/main/public/js/theme-init.js +12 -0
- package/sample/crud/apps/main/public/js/upload-demo.js +63 -0
- package/sample/crud/apps/main/public/js/worker-demo.js +92 -0
- package/sample/crud/apps/main/public/js/ws-chat.js +161 -0
- package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/sample/crud/apps/main/public/vendor/highlight/github-dark.css +109 -0
- package/sample/crud/apps/main/public/vendor/highlight/github.css +118 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/README.md +19 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.d.ts +196 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.js +1187 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm_bg.wasm +0 -0
- package/sample/crud/apps/main/routes/auth.js +15 -0
- package/sample/crud/apps/main/routes/cron.js +14 -0
- package/sample/crud/apps/main/routes/guide.js +25 -0
- package/sample/crud/apps/main/routes/jobs.js +14 -0
- package/sample/crud/apps/main/routes/logs.js +28 -0
- package/sample/crud/apps/main/routes/metrics.js +13 -0
- package/sample/crud/apps/main/routes/notes.js +19 -0
- package/sample/crud/apps/main/routes/perf.js +47 -0
- package/sample/crud/apps/main/routes/redis.js +14 -0
- package/sample/crud/apps/main/routes/tracing.js +14 -0
- package/sample/crud/apps/main/routes/upload.js +16 -0
- package/sample/crud/apps/main/routes/users.js +54 -0
- package/sample/crud/apps/main/routes/web.js +23 -0
- package/sample/crud/apps/main/routes/worker.js +15 -0
- package/sample/crud/apps/main/routes/ws.js +30 -0
- package/sample/crud/apps/main/schedules/cron-counter-schedule.js +30 -0
- package/sample/crud/apps/main/services/auth-service.js +74 -0
- package/sample/crud/apps/main/services/cron-demo-service.js +66 -0
- package/sample/crud/apps/main/services/guide-service.js +145 -0
- package/sample/crud/apps/main/services/jobs-demo-service.js +83 -0
- package/sample/crud/apps/main/services/logs-demo-service.js +59 -0
- package/sample/crud/apps/main/services/metrics-demo-service.js +144 -0
- package/sample/crud/apps/main/services/note-service.js +75 -0
- package/sample/crud/apps/main/services/perf-service.js +302 -0
- package/sample/crud/apps/main/services/redis-demo-service.js +75 -0
- package/sample/crud/apps/main/services/tracing-demo-service.js +69 -0
- package/sample/crud/apps/main/services/upload-demo-service.js +48 -0
- package/sample/crud/apps/main/services/user-service.js +65 -0
- package/sample/crud/apps/main/views/auth/login.ejs +57 -0
- package/sample/crud/apps/main/views/auth/register.ejs +71 -0
- package/sample/crud/apps/main/views/cron/index.ejs +92 -0
- package/sample/crud/apps/main/views/guide/index.ejs +24 -0
- package/sample/crud/apps/main/views/guide/page.ejs +64 -0
- package/sample/crud/apps/main/views/home.ejs +82 -0
- package/sample/crud/apps/main/views/jobs/index.ejs +113 -0
- package/sample/crud/apps/main/views/layouts/main.ejs +112 -0
- package/sample/crud/apps/main/views/logs/index.ejs +80 -0
- package/sample/crud/apps/main/views/metrics/index.ejs +123 -0
- package/sample/crud/apps/main/views/notes/edit.ejs +45 -0
- package/sample/crud/apps/main/views/notes/list.ejs +74 -0
- package/sample/crud/apps/main/views/notes/new.ejs +45 -0
- package/sample/crud/apps/main/views/perf/index.ejs +90 -0
- package/sample/crud/apps/main/views/redis/index.ejs +65 -0
- package/sample/crud/apps/main/views/tracing/index.ejs +106 -0
- package/sample/crud/apps/main/views/upload/index.ejs +79 -0
- package/sample/crud/apps/main/views/users/edit.ejs +48 -0
- package/sample/crud/apps/main/views/users/list.ejs +81 -0
- package/sample/crud/apps/main/views/users/new.ejs +48 -0
- package/sample/crud/apps/main/views/worker/index.ejs +70 -0
- package/sample/crud/apps/main/views/ws/index.ejs +62 -0
- package/sample/crud/apps/main/workers/hash-worker.js +17 -0
- package/sample/crud/apps/main/workers/hash.task.js +22 -0
- package/sample/crud/ecosystem.config.cjs +9 -0
- package/sample/crud/mega.config.js +105 -0
- package/sample/crud/package-lock.json +5665 -0
- package/sample/crud/package.json +28 -0
- package/sample/crud/test/apps/main/auth-flow.integration.test.js +177 -0
- package/sample/crud/test/apps/main/auth-service.test.js +93 -0
- package/sample/crud/test/apps/main/chat-bus.test.js +101 -0
- package/sample/crud/test/apps/main/chat-channel.test.js +144 -0
- package/sample/crud/test/apps/main/cron-demo-service.test.js +93 -0
- package/sample/crud/test/apps/main/demo-flow.integration.test.js +386 -0
- package/sample/crud/test/apps/main/email-job.test.js +76 -0
- package/sample/crud/test/apps/main/guide-service.test.js +68 -0
- package/sample/crud/test/apps/main/hash-task.test.js +30 -0
- package/sample/crud/test/apps/main/jobs-demo-service.test.js +88 -0
- package/sample/crud/test/apps/main/logs-demo-service.test.js +85 -0
- package/sample/crud/test/apps/main/metrics-demo-service.test.js +90 -0
- package/sample/crud/test/apps/main/note-service.test.js +68 -0
- package/sample/crud/test/apps/main/perf-service.test.js +121 -0
- package/sample/crud/test/apps/main/perf.integration.test.js +202 -0
- package/sample/crud/test/apps/main/redis-demo-service.test.js +98 -0
- package/sample/crud/test/apps/main/tracing-demo-service.test.js +90 -0
- package/sample/crud/test/apps/main/upload-demo-service.test.js +61 -0
- package/sample/crud/test/apps/main/user-service.test.js +65 -0
- package/sample/crud/test/apps/main/ws-chat.integration.test.js +232 -0
- package/sample/crud/vitest.config.js +8 -0
- package/sample/crud/yarn.lock +2142 -0
- package/sample/simple/.env.example +15 -0
- package/sample/simple/README.md +52 -0
- package/sample/simple/apps/main/app.config.js +35 -0
- package/sample/simple/apps/main/controllers/pages-controller.js +22 -0
- package/sample/simple/apps/main/locales/client/en.json +3 -0
- package/sample/simple/apps/main/locales/client/ko.json +3 -0
- package/sample/simple/apps/main/locales/server/en.json +23 -0
- package/sample/simple/apps/main/locales/server/ko.json +23 -0
- package/sample/simple/apps/main/public/css/app.css +101 -0
- package/sample/simple/apps/main/public/hello.txt +1 -0
- package/sample/simple/apps/main/public/js/app.js +54 -0
- package/sample/simple/apps/main/public/js/theme-init.js +12 -0
- package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/sample/simple/apps/main/routes/index.js +9 -0
- package/sample/simple/apps/main/routes/pages.js +12 -0
- package/sample/simple/apps/main/views/index.ejs +56 -0
- package/sample/simple/apps/main/views/layouts/main.ejs +74 -0
- package/sample/simple/ecosystem.config.cjs +10 -0
- package/sample/simple/mega.config.js +27 -0
- package/sample/simple/package-lock.json +1851 -0
- package/sample/simple/package.json +25 -0
- package/sample/simple/test/apps/main/index.test.js +13 -0
- package/sample/simple/vitest.config.js +8 -0
- package/src/adapters/adapter-manager.js +305 -0
- package/src/adapters/adapter-options.js +208 -0
- package/src/adapters/file-adapter.js +350 -0
- package/src/adapters/file-session-adapter.js +363 -0
- package/src/adapters/index.js +38 -0
- package/src/adapters/maria-adapter.js +425 -0
- package/src/adapters/mega-adapter.js +511 -0
- package/src/adapters/mega-bus-adapter.js +81 -0
- package/src/adapters/mega-cache-adapter.js +94 -0
- package/src/adapters/mega-db-adapter.js +72 -0
- package/src/adapters/mega-lock-adapter.js +118 -0
- package/src/adapters/mega-log-sink-adapter.js +46 -0
- package/src/adapters/mega-session-adapter.js +72 -0
- package/src/adapters/mongo-adapter.js +396 -0
- package/src/adapters/nats-adapter.js +370 -0
- package/src/adapters/postgres-adapter.js +341 -0
- package/src/adapters/redis-adapter.js +331 -0
- package/src/adapters/redis-session-adapter.js +261 -0
- package/src/adapters/redlock-adapter.js +385 -0
- package/src/adapters/registry.js +157 -0
- package/src/adapters/sqlite-adapter.js +309 -0
- package/src/auth/index.js +103 -0
- package/src/cli/commands/console-cmd.js +56 -0
- package/src/cli/commands/new.js +101 -0
- package/src/cli/commands/routes.js +107 -0
- package/src/cli/commands/scaffold.js +120 -0
- package/src/cli/commands/test-cmd.js +45 -0
- package/src/cli/generators/index.js +368 -0
- package/src/cli/index.js +472 -0
- package/src/cli/template-engine.js +72 -0
- package/src/cli/ws-hub.js +582 -0
- package/src/core/ajv-mapper.js +80 -0
- package/src/core/boot.js +323 -0
- package/src/core/cluster-metrics.js +278 -0
- package/src/core/config-loader.js +115 -0
- package/src/core/config-validator.js +322 -0
- package/src/core/ctx-builder.js +253 -0
- package/src/core/envelope.js +88 -0
- package/src/core/error-mapper.js +116 -0
- package/src/core/formbody.js +69 -0
- package/src/core/hub-link.js +552 -0
- package/src/core/i18n.js +525 -0
- package/src/core/index.js +63 -0
- package/src/core/mega-app.js +1138 -0
- package/src/core/mega-cluster.js +232 -0
- package/src/core/mega-server.js +176 -0
- package/src/core/mega-service.js +41 -0
- package/src/core/migration-runner.js +196 -0
- package/src/core/multipart.js +282 -0
- package/src/core/openapi.js +114 -0
- package/src/core/router.js +388 -0
- package/src/core/routes-loader.js +57 -0
- package/src/core/scope-registry.js +53 -0
- package/src/core/security.js +275 -0
- package/src/core/services-loader.js +98 -0
- package/src/core/session-cleanup-schedule.js +57 -0
- package/src/core/session-store.js +55 -0
- package/src/core/session.js +414 -0
- package/src/core/static-assets.js +126 -0
- package/src/core/template.js +294 -0
- package/src/core/workers-manager.js +193 -0
- package/src/core/ws-compression.js +112 -0
- package/src/core/ws-controller.js +109 -0
- package/src/core/ws-message.js +176 -0
- package/src/core/ws-upgrade.js +445 -0
- package/src/errors/config-error.js +16 -0
- package/src/errors/http-errors.js +130 -0
- package/src/errors/index.js +19 -0
- package/src/errors/mega-error.js +34 -0
- package/src/eslint-plugin/index.js +15 -0
- package/src/eslint-plugin/no-direct-model-import.js +113 -0
- package/src/index.js +131 -0
- package/src/lib/asp/config.js +83 -0
- package/src/lib/asp/crypto.js +145 -0
- package/src/lib/asp/errors.js +49 -0
- package/src/lib/asp/nonce-cache.js +94 -0
- package/src/lib/asp/plugin.js +263 -0
- package/src/lib/asp/ws-terminator.js +101 -0
- package/src/lib/env-mapper.js +222 -0
- package/src/lib/hub-protocol.js +322 -0
- package/src/lib/index.js +42 -0
- package/src/lib/logger/telegram-core.js +150 -0
- package/src/lib/logger/telegram-transport.js +126 -0
- package/src/lib/mega-brute-force.js +225 -0
- package/src/lib/mega-circuit-breaker.js +412 -0
- package/src/lib/mega-cron.js +169 -0
- package/src/lib/mega-hash.js +179 -0
- package/src/lib/mega-health.js +91 -0
- package/src/lib/mega-job-queue.js +600 -0
- package/src/lib/mega-job-worker.js +295 -0
- package/src/lib/mega-job.js +140 -0
- package/src/lib/mega-logger.js +128 -0
- package/src/lib/mega-metrics.js +661 -0
- package/src/lib/mega-plugin.js +650 -0
- package/src/lib/mega-retry.js +95 -0
- package/src/lib/mega-schedule.js +507 -0
- package/src/lib/mega-shutdown.js +176 -0
- package/src/lib/mega-tracing.js +715 -0
- package/src/lib/mega-worker.js +653 -0
- package/src/lib/worker-runner/process-entry.js +30 -0
- package/src/lib/worker-runner/task-dispatch.js +72 -0
- package/src/lib/worker-runner/thread-entry.js +26 -0
- package/src/models/index.js +7 -0
- package/src/models/mega-model.js +151 -0
- package/src/test/index.js +288 -0
- package/templates/adapter/code.tpl +40 -0
- package/templates/adapter/test.tpl +13 -0
- package/templates/app/app.config.tpl +10 -0
- package/templates/app/route.tpl +10 -0
- package/templates/app/test.tpl +13 -0
- package/templates/channel/code.tpl +38 -0
- package/templates/channel/test.tpl +19 -0
- package/templates/controller/code.tpl +16 -0
- package/templates/controller/route.tpl +9 -0
- package/templates/controller/test.tpl +14 -0
- package/templates/job/code.tpl +23 -0
- package/templates/job/test.tpl +17 -0
- package/templates/locale/code.tpl +3 -0
- package/templates/locale/test.tpl +13 -0
- package/templates/middleware/code.tpl +13 -0
- package/templates/middleware/test.tpl +11 -0
- package/templates/migration/code.tpl +20 -0
- package/templates/migration/test.tpl +14 -0
- package/templates/model/code.tpl +21 -0
- package/templates/model/test.tpl +29 -0
- package/templates/project/app.config.tpl +8 -0
- package/templates/project/app.config.views.tpl +37 -0
- package/templates/project/ecosystem.config.tpl +10 -0
- package/templates/project/env.tpl +12 -0
- package/templates/project/gitignore.tpl +8 -0
- package/templates/project/locales/client/en.json.tpl +3 -0
- package/templates/project/locales/client/ko.json.tpl +3 -0
- package/templates/project/locales/server/en.json.tpl +17 -0
- package/templates/project/locales/server/ko.json.tpl +17 -0
- package/templates/project/mega.config.tpl +11 -0
- package/templates/project/package.tpl +25 -0
- package/templates/project/public/css/app.css +101 -0
- package/templates/project/public/js/app.js +54 -0
- package/templates/project/public/js/theme-init.js +12 -0
- package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/templates/project/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/templates/project/readme.tpl +48 -0
- package/templates/project/route.test.tpl +13 -0
- package/templates/project/route.test.views.tpl +15 -0
- package/templates/project/route.tpl +10 -0
- package/templates/project/route.views.tpl +10 -0
- package/templates/project/views/index.ejs.tpl +58 -0
- package/templates/project/views/layout.ejs.tpl +73 -0
- package/templates/project/vitest.config.tpl +8 -0
- package/templates/route/code.tpl +11 -0
- package/templates/route/test.tpl +26 -0
- package/templates/schedule/code.tpl +19 -0
- package/templates/schedule/test.tpl +17 -0
- package/templates/service/code.tpl +18 -0
- package/templates/service/test.tpl +17 -0
- package/templates/worker/code.tpl +14 -0
- package/templates/worker/task.tpl +13 -0
- package/templates/worker/test.tpl +18 -0
- package/vitest.config.js +33 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaApp 서버사이드 템플릿 자동 등록 — `ejs` + `ejs-mate` 통합 (per ADR-011).
|
|
4
|
+
*
|
|
5
|
+
* # 무엇인가 (중학생용 설명)
|
|
6
|
+
* 서버가 HTML 페이지를 만들어 브라우저로 보내고 싶을 때, 매번 문자열을 손으로 잇는 건 번거롭고 위험하다
|
|
7
|
+
* (사용자 값이 그대로 들어가면 XSS). 템플릿 엔진은 `views/posts/show.ejs` 같은 틀 파일에 데이터를 꽂아
|
|
8
|
+
* 완성된 HTML 을 돌려준다. 본 모듈은 검증된 엔진 `ejs`(표현식 그대로)와 `ejs-mate`(레이아웃·파셜·블록
|
|
9
|
+
* 1급 지원)를 앱 config 의 `views` 키 기반으로 자동 등록하고, 그 위에 **i18n 노출**(`t`/`lang`)·**관측성**
|
|
10
|
+
* (트레이싱 span·메트릭)·**보안**(XSS auto-escape·경로 탐색 차단)을 얹는다.
|
|
11
|
+
*
|
|
12
|
+
* # 동작 한눈에 (i18n.js / multipart.js 형제 패턴)
|
|
13
|
+
* 1. `registerTemplate(fastify, { views })` — `views.dir` 이 있을 때만 옵트인 등록(없으면 reply.render 미정의).
|
|
14
|
+
* 2. `reply.render(view, data, opts)` 데코레이터 — 정본 `res.render`(docs/03 §618). 렌더 후
|
|
15
|
+
* `Content-Type: text/html` 로 send. data 에 요청별 `t`/`lang`(i18n) 자동 병합.
|
|
16
|
+
* 3. `MegaTemplate#render(view, data, opts)` — 순수 렌더(send 없이 HTML 문자열, docs/03 §892).
|
|
17
|
+
* 4. ctx-builder 가 `ctx.render` 를 reply.render 로 위임(canonical handler `(req, res, ctx)`).
|
|
18
|
+
*
|
|
19
|
+
* # 엔진 = EJS + ejs-mate (ADR-011)
|
|
20
|
+
* - **EJS**: `<%= value %>` 는 HTML escape(XSS 기본 방어), `<%- value %>` 는 raw(명시적 신뢰 시만).
|
|
21
|
+
* - **ejs-mate**: 템플릿 내 `<% layout('layouts/main') %>`·`<%- partial('partials/header') %>`·
|
|
22
|
+
* `block(...)` 지원. 레이아웃·파셜 lookup 은 `settings.views`(= 뷰 루트) 기준(ejs-mate `renderFile`).
|
|
23
|
+
*
|
|
24
|
+
* # 위치 관례 (02-architecture §11)
|
|
25
|
+
* `views.dir`(뷰 루트, 예 `apps/<name>/views`) 기준. 레이아웃 `<dir>/layouts/main.ejs`, 파셜
|
|
26
|
+
* `<dir>/partials/`. i18n `localesDir` 선례처럼 dir 은 config 로 명시받는다(런타임은 앱 디렉터리를 모름).
|
|
27
|
+
*
|
|
28
|
+
* # 보안 (12-performance-security / multipart.js sanitize 패턴 정합)
|
|
29
|
+
* - **XSS**: EJS `<%=` auto-escape 가 기본. raw 출력(`<%-`)은 호출자가 신뢰 데이터에만 쓴다(엔진 표준).
|
|
30
|
+
* - **경로 탐색(path traversal) 차단**: view/layout 이름을 뷰 루트 기준으로 resolve 한 뒤 그 경로가
|
|
31
|
+
* **뷰 루트 내부**인지 검증한다. `../` 로 루트를 벗어나면 즉시 거부(400 `template.invalid_view`).
|
|
32
|
+
* 동적(사용자 입력) view 명이 임의 파일을 읽는 LFI 를 막는 이중 방어.
|
|
33
|
+
*
|
|
34
|
+
* # 트레이싱·메트릭 (ADR-126 / ADR-131 인프라 재사용)
|
|
35
|
+
* - 트레이싱: 렌더를 `MegaTracing.span('mega.template.render', …)` 로 감싸 `mega.template.{view,layout,bytes}`
|
|
36
|
+
* 속성을 박는다. 옵트인 OFF 면 0 비용 no-op.
|
|
37
|
+
* - 메트릭: `recordTemplate({ result, durationMs, bytes })` — rendered/error + 렌더 시간·바이트 분포.
|
|
38
|
+
* view 이름은 라벨에 안 넣는다(카디널리티·경로 노출 방지 — upload/i18n 패턴 정합).
|
|
39
|
+
*
|
|
40
|
+
* @module core/template
|
|
41
|
+
* @see ADR-011 (EJS + ejs-mate 채택), ADR-136
|
|
42
|
+
* @see https://github.com/JacksonTian/ejs-mate (ejs-mate v4 — express engine signature)
|
|
43
|
+
* @see https://ejs.co/ (EJS)
|
|
44
|
+
*/
|
|
45
|
+
import { resolve, sep, extname } from 'node:path'
|
|
46
|
+
import { promisify } from 'node:util'
|
|
47
|
+
import ejsMate from 'ejs-mate'
|
|
48
|
+
import { MegaValidationError, MegaInternalError } from '../errors/http-errors.js'
|
|
49
|
+
import * as MegaTracing from '../lib/mega-tracing.js'
|
|
50
|
+
import { recordTemplate } from '../lib/mega-metrics.js'
|
|
51
|
+
|
|
52
|
+
/** 고정 뷰 엔진·확장자 (ADR-011 — EJS). ejs-mate `settings['view engine']` 과 일치시킨다. */
|
|
53
|
+
export const VIEW_ENGINE = 'ejs'
|
|
54
|
+
|
|
55
|
+
/** 레이아웃 디렉터리 기본 관례 (02-architecture §11 — `views/layouts/`). */
|
|
56
|
+
export const DEFAULT_LAYOUT_DIR = 'layouts'
|
|
57
|
+
|
|
58
|
+
/** 파셜 디렉터리 기본 관례 (02-architecture §11 — `views/partials/`). */
|
|
59
|
+
export const DEFAULT_PARTIALS_DIR = 'partials'
|
|
60
|
+
|
|
61
|
+
/** ejs-mate `renderFile(file, options, cb)` 를 Promise 로 — 콜백 1패스, this 바인딩 없음(standalone). */
|
|
62
|
+
const renderFileAsync = promisify(
|
|
63
|
+
/** @type {(file: string, options: object, cb: (err: any, html?: string) => void) => void} */ (
|
|
64
|
+
ejsMate
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* i18n 미등록 앱의 템플릿 `t` 폴백 — ctx-builder passthroughT 와 동형. 번역 없이 defaultValue/key 반환.
|
|
70
|
+
* 템플릿에서 `<%= t('user.greeting', '안녕') %>` 가 i18n 옵트인 안 한 앱에서도 깨지지 않게.
|
|
71
|
+
* @param {string} key @param {unknown} [defaultValue] @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function passthroughT(key, defaultValue) {
|
|
74
|
+
return typeof defaultValue === 'string' ? defaultValue : key
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 앱 `views` config 를 정규화한다. `dir` 이 없으면 null(옵트인 미활성 — i18n normalizeI18n 패턴 정합).
|
|
79
|
+
*
|
|
80
|
+
* @param {unknown} views - 앱 config 의 `views` 키.
|
|
81
|
+
* @returns {{ dir: string, layoutDir: string, partialsDir: string, defaultLayout: string|null, cache: boolean }|null}
|
|
82
|
+
*/
|
|
83
|
+
export function normalizeViews(views) {
|
|
84
|
+
if (!views || typeof views !== 'object') return null
|
|
85
|
+
const c = /** @type {Record<string, any>} */ (views)
|
|
86
|
+
if (typeof c.dir !== 'string' || c.dir.length === 0) return null
|
|
87
|
+
|
|
88
|
+
// dir 은 절대경로로 고정 — 상대경로면 프로세스 cwd 기준 resolve(경로 탐색 검증의 기준점이 흔들리지 않게).
|
|
89
|
+
const dir = resolve(c.dir)
|
|
90
|
+
const layoutDir =
|
|
91
|
+
typeof c.layoutDir === 'string' && c.layoutDir.length > 0 ? c.layoutDir : DEFAULT_LAYOUT_DIR
|
|
92
|
+
const partialsDir =
|
|
93
|
+
typeof c.partialsDir === 'string' && c.partialsDir.length > 0
|
|
94
|
+
? c.partialsDir
|
|
95
|
+
: DEFAULT_PARTIALS_DIR
|
|
96
|
+
// defaultLayout: 앱 전역 기본 레이아웃(02-architecture §11 `layouts/main`). 없으면 레이아웃 없이 렌더.
|
|
97
|
+
const defaultLayout =
|
|
98
|
+
typeof c.defaultLayout === 'string' && c.defaultLayout.length > 0 ? c.defaultLayout : null
|
|
99
|
+
// cache: 컴파일 캐시. 명시 우선, 없으면 prod ON(반복 디스크 읽기·컴파일 회피)·dev OFF(편집 즉시 반영).
|
|
100
|
+
const cache = c.cache !== undefined ? c.cache === true : process.env.NODE_ENV === 'production'
|
|
101
|
+
|
|
102
|
+
return { dir, layoutDir, partialsDir, defaultLayout, cache }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* view/layout 이름을 뷰 루트 기준 절대 경로로 풀고, 그 경로가 **뷰 루트 내부**인지 검증한다(경로 탐색 차단).
|
|
107
|
+
* 확장자가 없으면 `.ejs` 를 붙인다. 루트를 벗어나면 400 throw(LFI 방어 — multipart sanitize 이중 방어 정합).
|
|
108
|
+
*
|
|
109
|
+
* @param {string} dir - 뷰 루트(절대경로).
|
|
110
|
+
* @param {string} name - view 또는 layout 상대 이름(예 'posts/show', 'layouts/main').
|
|
111
|
+
* @param {'view'|'layout'} kind - 에러 메시지 구분용.
|
|
112
|
+
* @returns {string} 검증된 절대 경로.
|
|
113
|
+
* @throws {MegaValidationError} 뷰 루트를 벗어나면 `template.invalid_view`.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveViewPath(dir, name, kind = 'view') {
|
|
116
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
117
|
+
throw new MegaValidationError(
|
|
118
|
+
'template.invalid_view',
|
|
119
|
+
`render: ${kind} name must be a non-empty string.`,
|
|
120
|
+
{ details: { kind, name } },
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
const withExt = extname(name) ? name : `${name}.${VIEW_ENGINE}`
|
|
124
|
+
const abs = resolve(dir, withExt)
|
|
125
|
+
// 뷰 루트 내부 검증 — 정확히 dir 자신은 파일이 아니므로 `dir + sep` 접두만 허용(경로 탐색 거부).
|
|
126
|
+
if (abs !== dir && !abs.startsWith(dir + sep)) {
|
|
127
|
+
throw new MegaValidationError(
|
|
128
|
+
'template.invalid_view',
|
|
129
|
+
`render: ${kind} '${name}' escapes the views root (path traversal blocked).`,
|
|
130
|
+
{
|
|
131
|
+
details: { kind, name, root: dir },
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return abs
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 순수 렌더 — 뷰를 HTML 문자열로 만든다(send 없이). `MegaTemplate#render` 본체이자 reply.render 의 코어.
|
|
140
|
+
* 트레이싱 span + 메트릭을 여기서 1회 기록한다(reply.render·ctx.render 가 공유).
|
|
141
|
+
*
|
|
142
|
+
* @param {{ dir: string, layoutDir: string, partialsDir: string, defaultLayout: string|null, cache: boolean }} cfg
|
|
143
|
+
* @param {string} view - 뷰 루트 기준 상대 이름(예 'posts/show').
|
|
144
|
+
* @param {object} [data] - 템플릿 로컬(데이터). 예약 키 `t`/`lang`/`settings`/`cache` 는 프레임워크가 채운다.
|
|
145
|
+
* @param {{ layout?: string|false }} [opts] - `layout` 으로 레이아웃 override(false 면 레이아웃 강제 해제).
|
|
146
|
+
* @param {{ app?: string }} [meta] - 메트릭 app 라벨.
|
|
147
|
+
* @returns {Promise<string>} 렌더된 HTML.
|
|
148
|
+
* @throws {MegaValidationError} 경로 탐색 거부(400). @throws {MegaInternalError} 렌더 실패(500).
|
|
149
|
+
*/
|
|
150
|
+
export async function renderView(cfg, view, data = {}, opts = {}, meta = {}) {
|
|
151
|
+
const start = process.hrtime.bigint()
|
|
152
|
+
const viewPath = resolveViewPath(cfg.dir, view, 'view')
|
|
153
|
+
|
|
154
|
+
// 레이아웃 결정 — opts.layout 우선(false=강제 해제), 미지정이면 앱 defaultLayout. 둘 다 없으면 템플릿
|
|
155
|
+
// 내부 `<% layout() %>` 호출에 위임(ejs-mate). 지정 시 경로 탐색 검증.
|
|
156
|
+
/** @type {string|null} */
|
|
157
|
+
let layoutFile = null
|
|
158
|
+
if (opts.layout === false) {
|
|
159
|
+
layoutFile = null
|
|
160
|
+
} else if (typeof opts.layout === 'string') {
|
|
161
|
+
resolveViewPath(cfg.dir, opts.layout, 'layout') // 검증만(ejs-mate 가 settings.views 기준 재lookup)
|
|
162
|
+
layoutFile = opts.layout
|
|
163
|
+
} else if (cfg.defaultLayout) {
|
|
164
|
+
resolveViewPath(cfg.dir, cfg.defaultLayout, 'layout')
|
|
165
|
+
layoutFile = cfg.defaultLayout
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ejs-mate 호출 옵션 — data 가 곧 템플릿 로컬(ejs.renderFile data=options). 예약 키는 프레임워크가 덮어쓴다.
|
|
169
|
+
// settings.views = [dir] → layout/partial lookup 기준. _layoutFile(locals) 은 ejs-mate 가 우선 검사(외부 지정).
|
|
170
|
+
/** @type {Record<string, any>} */
|
|
171
|
+
const options = {
|
|
172
|
+
...data,
|
|
173
|
+
settings: { views: [cfg.dir], 'view engine': VIEW_ENGINE },
|
|
174
|
+
cache: cfg.cache,
|
|
175
|
+
filename: viewPath,
|
|
176
|
+
}
|
|
177
|
+
if (layoutFile !== null) {
|
|
178
|
+
options.locals = { ...(options.locals ?? {}), _layoutFile: layoutFile }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return MegaTracing.span('mega.template.render', async (span) => {
|
|
182
|
+
span?.setAttributes({
|
|
183
|
+
'mega.template.view': view,
|
|
184
|
+
'mega.template.layout': layoutFile ?? '(none)',
|
|
185
|
+
})
|
|
186
|
+
let html
|
|
187
|
+
try {
|
|
188
|
+
html = await renderFileAsync(viewPath, options)
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// 렌더 실패(문법 오류·파일 없음·참조 변수 throw)는 비치명적 묻기 금지 — 500 으로 wrap 후 상위 전파.
|
|
191
|
+
const durationMs = Number(process.hrtime.bigint() - start) / 1e6
|
|
192
|
+
recordTemplate({ app: meta.app, result: 'error', durationMs })
|
|
193
|
+
span?.setAttributes({ 'mega.template.error': true })
|
|
194
|
+
throw new MegaInternalError('template.render_failed', `Failed to render view '${view}'.`, {
|
|
195
|
+
details: { view, layout: layoutFile },
|
|
196
|
+
cause: err,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
const bytes = Buffer.byteLength(html ?? '', 'utf8')
|
|
200
|
+
const durationMs = Number(process.hrtime.bigint() - start) / 1e6
|
|
201
|
+
span?.setAttributes({ 'mega.template.bytes': bytes })
|
|
202
|
+
recordTemplate({ app: meta.app, result: 'rendered', durationMs, bytes })
|
|
203
|
+
return html ?? ''
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 정본 `MegaTemplate`(docs/03 §892 / 06-class-diagrams) — 옵트인 뷰 config 를 감싼 thin wrapper.
|
|
209
|
+
* registerTemplate 가 앱당 1개 만들어 reply.render 가 위임한다. i18n/MegaTracing 처럼 본체는 모듈 함수형.
|
|
210
|
+
*/
|
|
211
|
+
export class MegaTemplate {
|
|
212
|
+
/** @param {unknown} views - 앱 `views` config. @param {string} [appName] - 메트릭 app 라벨. */
|
|
213
|
+
constructor(views, appName) {
|
|
214
|
+
const cfg = normalizeViews(views)
|
|
215
|
+
if (cfg === null) {
|
|
216
|
+
throw new MegaInternalError(
|
|
217
|
+
'template.not_configured',
|
|
218
|
+
'MegaTemplate: views.dir is required (opt-in template needs a views root).',
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
/** @type {{ dir: string, layoutDir: string, partialsDir: string, defaultLayout: string|null, cache: boolean }} */
|
|
222
|
+
this._cfg = cfg
|
|
223
|
+
/** @type {string|undefined} */
|
|
224
|
+
this._app = appName
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 뷰를 HTML 문자열로 렌더(send 없이) — docs/03 §892.
|
|
229
|
+
* @param {string} viewPath - 뷰 루트 기준 상대 이름(예 'posts/show').
|
|
230
|
+
* @param {object} [data]
|
|
231
|
+
* @param {{ layout?: string|false }} [opts]
|
|
232
|
+
* @returns {Promise<string>}
|
|
233
|
+
*/
|
|
234
|
+
render(viewPath, data, opts) {
|
|
235
|
+
return renderView(this._cfg, viewPath, data, opts, { app: this._app })
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* MegaApp 에 템플릿을 옵트인 등록한다. `views.dir` 이 있을 때만 `reply.render` 데코레이터를 단다(없으면
|
|
241
|
+
* 미등록 — render 호출 시 Fastify 가 'reply.render is not a function' 으로 fail-fast, 옵트인 명시).
|
|
242
|
+
*
|
|
243
|
+
* `reply.render` 는 정본 `res.render(view, data, { layout })`(docs/03 §618):
|
|
244
|
+
* - 요청별 i18n `t`/`lang`(`req.t`/`req.lang`, i18n 미등록이면 passthrough)을 data 에 자동 병합.
|
|
245
|
+
* - 렌더 후 `Content-Type: text/html; charset=utf-8` 로 send. 체이닝용으로 reply 반환.
|
|
246
|
+
*
|
|
247
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
248
|
+
* @param {object} args
|
|
249
|
+
* @param {unknown} [args.views] - 앱 `views` config(`{ dir, layoutDir, partialsDir, defaultLayout, cache }`).
|
|
250
|
+
* @param {string} [args.appName] - 메트릭·로그 라벨.
|
|
251
|
+
* @param {{ debug?: Function }} [args.logger]
|
|
252
|
+
* @returns {{ enabled: boolean, template: MegaTemplate|null }}
|
|
253
|
+
*/
|
|
254
|
+
export function registerTemplate(
|
|
255
|
+
fastify,
|
|
256
|
+
{ views, appName = '(unknown)', logger } = /** @type {any} */ ({}),
|
|
257
|
+
) {
|
|
258
|
+
const cfg = normalizeViews(views)
|
|
259
|
+
if (cfg === null) {
|
|
260
|
+
return { enabled: false, template: null }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const template = new MegaTemplate(views, appName)
|
|
264
|
+
|
|
265
|
+
// reply.render — 정본 res.render. this = reply 인스턴스, this.request 로 요청별 i18n 로컬 접근.
|
|
266
|
+
fastify.decorateReply(
|
|
267
|
+
'render',
|
|
268
|
+
async function (
|
|
269
|
+
/** @type {string} */ view,
|
|
270
|
+
/** @type {object} */ data,
|
|
271
|
+
/** @type {any} */ opts,
|
|
272
|
+
) {
|
|
273
|
+
const reply = /** @type {any} */ (this)
|
|
274
|
+
const req = /** @type {any} */ (reply.request)
|
|
275
|
+
// i18n 로컬 자동 노출 — 사용자 data 가 우선(명시 override 허용), 미지정이면 요청 t/lang.
|
|
276
|
+
const locals = { lang: req.lang ?? null, t: req.t ?? passthroughT, ...(data ?? {}) }
|
|
277
|
+
logger?.debug?.({ app: appName, view, requestId: req.id }, 'template.render enter')
|
|
278
|
+
const html = await template.render(view, locals, opts)
|
|
279
|
+
logger?.debug?.(
|
|
280
|
+
{ app: appName, view, bytes: Buffer.byteLength(html, 'utf8') },
|
|
281
|
+
'template.render done',
|
|
282
|
+
)
|
|
283
|
+
reply.header('content-type', 'text/html; charset=utf-8')
|
|
284
|
+
reply.send(html)
|
|
285
|
+
return reply
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
logger?.debug?.(
|
|
290
|
+
{ app: appName, dir: cfg.dir, defaultLayout: cfg.defaultLayout, cache: cfg.cache },
|
|
291
|
+
'template.registered',
|
|
292
|
+
)
|
|
293
|
+
return { enabled: true, template }
|
|
294
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MegaWorkersManager — 전역 `MegaWorker` 풀 매니저 (ADR-124).
|
|
4
|
+
*
|
|
5
|
+
* adapter-manager 와 같은 **모듈 레벨 싱글톤** 패턴이다 — `config.workers`(Global-only, MegaWorker
|
|
6
|
+
* 서브클래스 배열)로부터 풀을 인스턴스화·start 하고, `ctx.workers.<name>` lookup 으로 노출하며, graceful
|
|
7
|
+
* shutdown 시 LIFO 로 정리한다. 워커는 `static name` 으로 전역 키잉(앱 별명 스코프 밖 — adapters 의 db/cache/bus
|
|
8
|
+
* 와 달리 워커는 글로벌 공유 자원).
|
|
9
|
+
*
|
|
10
|
+
* # 부팅 배선 (boot.js prepareRuntime)
|
|
11
|
+
* 1. `buildWorkers(global, { projectRoot })` — `config.workers` 각 클래스 `new WorkerClass({ projectRoot })`.
|
|
12
|
+
* 동시에 `'workers:stop'` MegaShutdown hook 등록(어댑터 hook 보다 나중 등록 = 더 먼저 정리 — 워커가
|
|
13
|
+
* 어댑터를 쓸 수 있으니 어댑터 disconnect 전에 멈춤).
|
|
14
|
+
* 2. `startAll()` — 모든 풀 start(스레드/프로세스 spawn). 하나라도 실패하면 이미 start 된 것 LIFO 정리 후 throw.
|
|
15
|
+
* 3. `contextProxy()` — `ctx.workers` 로 줄 Proxy(미등록 이름 접근 시 fail-fast).
|
|
16
|
+
*
|
|
17
|
+
* @module core/workers-manager
|
|
18
|
+
*/
|
|
19
|
+
import { MegaConfigError } from '../errors/config-error.js'
|
|
20
|
+
import { MegaShutdown } from '../lib/mega-shutdown.js'
|
|
21
|
+
import { MegaWorker } from '../lib/mega-worker.js'
|
|
22
|
+
|
|
23
|
+
/** shutdown hook 이름 — 재빌드/리셋 시 중복 등록 방지용 고정 키. */
|
|
24
|
+
const SHUTDOWN_HOOK = 'workers:stop'
|
|
25
|
+
|
|
26
|
+
/** @type {Map<string, MegaWorker>} static name → 워커 인스턴스. */
|
|
27
|
+
let workers = new Map()
|
|
28
|
+
/** @type {string[]} 빌드(=start) 순서 — stop LIFO 용. */
|
|
29
|
+
let order = []
|
|
30
|
+
/** @type {any} `ctx.workers` 로 재사용하는 Proxy(빌드 시 1회 생성). */
|
|
31
|
+
let proxy = null
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `config.workers`(MegaWorker 서브클래스 배열)로부터 워커 풀 인스턴스를 만든다(아직 start 안 함).
|
|
35
|
+
* config-validator 가 배열·클래스 shape 를 먼저 검증하지만, 여기서도 MegaWorker 서브클래스·이름 중복을
|
|
36
|
+
* 부팅 시점에 fail-fast(형제 register 패턴).
|
|
37
|
+
*
|
|
38
|
+
* @param {{ workers?: Array<typeof MegaWorker> }} [globalConfig] - mega.config.js default export.
|
|
39
|
+
* @param {{ projectRoot?: string, registerShutdownHook?: boolean }} [opts]
|
|
40
|
+
* @returns {void}
|
|
41
|
+
* @throws {MegaConfigError} MegaWorker 서브클래스가 아니거나 `static name` 중복일 때.
|
|
42
|
+
*/
|
|
43
|
+
export function buildWorkers(globalConfig, { projectRoot, registerShutdownHook = true } = {}) {
|
|
44
|
+
const list = globalConfig?.workers ?? []
|
|
45
|
+
for (const WorkerClass of list) {
|
|
46
|
+
if (typeof WorkerClass !== 'function' || !(WorkerClass.prototype instanceof MegaWorker)) {
|
|
47
|
+
throw new MegaConfigError(
|
|
48
|
+
'worker.invalid_class',
|
|
49
|
+
`config.workers — each element must be a subclass of MegaWorker (got ${typeof WorkerClass}).`,
|
|
50
|
+
{ details: { type: typeof WorkerClass } },
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
// new 시점에 static 설정(taskFile/mode/poolSize) fail-fast(생성자 검증).
|
|
54
|
+
const instance = new /** @type {any} */ (WorkerClass)({ projectRoot })
|
|
55
|
+
const name = instance.name
|
|
56
|
+
if (workers.has(name)) {
|
|
57
|
+
throw new MegaConfigError(
|
|
58
|
+
'worker.duplicate_name',
|
|
59
|
+
`config.workers — duplicate worker name '${name}' (static name must be unique).`,
|
|
60
|
+
{ details: { name } },
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
workers.set(name, instance)
|
|
64
|
+
order.push(name)
|
|
65
|
+
}
|
|
66
|
+
if (registerShutdownHook) {
|
|
67
|
+
MegaShutdown.unregister(SHUTDOWN_HOOK)
|
|
68
|
+
MegaShutdown.register(SHUTDOWN_HOOK, async () => stopAll())
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 모든 워커 풀을 start 한다(스레드/프로세스 spawn). 하나라도 실패하면 이미 start 된 풀을 등록 역순(LIFO)으로
|
|
74
|
+
* stop 한 뒤 원래 에러를 throw(fail-fast + leak 방지 — adapter-manager connectAll 패턴).
|
|
75
|
+
*
|
|
76
|
+
* @param {{ logger?: { debug?: Function, info?: Function, warn?: Function } }} [opts]
|
|
77
|
+
* @returns {Promise<void>}
|
|
78
|
+
*/
|
|
79
|
+
export async function startAll({ logger } = {}) {
|
|
80
|
+
/** @type {string[]} start 성공 순서(LIFO cleanup 용). */
|
|
81
|
+
const started = []
|
|
82
|
+
try {
|
|
83
|
+
for (const name of order) {
|
|
84
|
+
const w = /** @type {MegaWorker} */ (workers.get(name))
|
|
85
|
+
await w.start()
|
|
86
|
+
started.push(name)
|
|
87
|
+
logger?.info?.({ worker: name, mode: w.mode, poolSize: w.poolSize }, 'worker pool started')
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
for (let i = started.length - 1; i >= 0; i--) {
|
|
91
|
+
const w = /** @type {MegaWorker} */ (workers.get(started[i]))
|
|
92
|
+
try {
|
|
93
|
+
await w.stop()
|
|
94
|
+
} catch (cleanupErr) {
|
|
95
|
+
logger?.warn?.({ err: cleanupErr, worker: started[i] }, 'worker cleanup stop failed during startAll abort (continuing)')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw err
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `static name` 으로 워커 풀 조회. 미등록이면 fail-fast(silent X — adapter-manager.get 정합).
|
|
104
|
+
* @param {string} name @returns {MegaWorker} @throws {MegaConfigError}
|
|
105
|
+
*/
|
|
106
|
+
export function get(name) {
|
|
107
|
+
const w = workers.get(name)
|
|
108
|
+
if (w === undefined) {
|
|
109
|
+
throw new MegaConfigError(
|
|
110
|
+
'worker.not_registered',
|
|
111
|
+
`No worker registered for name '${name}'. Registered: [${order.join(', ') || '(none)'}]. Add the MegaWorker subclass to mega.config.js 'workers'.`,
|
|
112
|
+
{ details: { name, registered: [...order] } },
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
return w
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 등록 여부(Boolean — has*, 컨벤션). @param {string} name @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
export function has(name) {
|
|
122
|
+
return workers.has(name)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 등록된 워커 메타 스냅샷(디버그/CLI). @returns {Array<{ name: string, mode: string, poolSize: number, started: boolean }>}
|
|
127
|
+
*/
|
|
128
|
+
export function list() {
|
|
129
|
+
return order.map((name) => {
|
|
130
|
+
const w = /** @type {MegaWorker} */ (workers.get(name))
|
|
131
|
+
return { name, mode: w.mode, poolSize: w.poolSize, started: w.isStarted }
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `ctx.workers` 로 줄 객체 — `ctx.workers.<name>.run(task)`. 미등록 이름 접근은 fail-fast.
|
|
137
|
+
* 동일 Proxy 를 boot ctx·요청 ctx 가 공유한다(워커는 글로벌 자원이라 요청·앱 무관).
|
|
138
|
+
* @returns {Record<string, MegaWorker>}
|
|
139
|
+
*/
|
|
140
|
+
export function contextProxy() {
|
|
141
|
+
if (proxy === null) {
|
|
142
|
+
proxy = new Proxy(
|
|
143
|
+
{},
|
|
144
|
+
{
|
|
145
|
+
get(_t, prop) {
|
|
146
|
+
if (typeof prop !== 'string') return undefined
|
|
147
|
+
return get(prop) // 미등록 → worker.not_registered throw.
|
|
148
|
+
},
|
|
149
|
+
has(_t, prop) {
|
|
150
|
+
return typeof prop === 'string' && has(prop)
|
|
151
|
+
},
|
|
152
|
+
ownKeys() {
|
|
153
|
+
return [...order]
|
|
154
|
+
},
|
|
155
|
+
getOwnPropertyDescriptor(_t, prop) {
|
|
156
|
+
if (typeof prop === 'string' && has(prop)) {
|
|
157
|
+
return { enumerable: true, configurable: true, value: get(prop) }
|
|
158
|
+
}
|
|
159
|
+
return undefined
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return proxy
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 모든 워커 풀을 graceful stop 한다 — 등록 **역순(LIFO)**. 개별 실패는 비치명적(warn 후 계속).
|
|
169
|
+
* @param {{ logger?: { debug?: Function, warn?: Function } }} [opts]
|
|
170
|
+
* @returns {Promise<void>}
|
|
171
|
+
*/
|
|
172
|
+
export async function stopAll({ logger } = {}) {
|
|
173
|
+
for (let i = order.length - 1; i >= 0; i--) {
|
|
174
|
+
const w = /** @type {MegaWorker} */ (workers.get(order[i]))
|
|
175
|
+
try {
|
|
176
|
+
await w.stop()
|
|
177
|
+
logger?.debug?.({ worker: order[i] }, 'worker pool stopped')
|
|
178
|
+
} catch (err) {
|
|
179
|
+
logger?.warn?.({ err, worker: order[i] }, 'worker pool stop failed (continuing shutdown)')
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 테스트용 reset — 인스턴스 비우고 shutdown hook 해제(워커는 stop 안 함 — 호출자가 먼저 stopAll).
|
|
186
|
+
* @returns {void}
|
|
187
|
+
*/
|
|
188
|
+
export function _reset() {
|
|
189
|
+
workers = new Map()
|
|
190
|
+
order = []
|
|
191
|
+
proxy = null
|
|
192
|
+
MegaShutdown.unregister(SHUTDOWN_HOOK)
|
|
193
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* WebSocket per-message deflate 압축 옵션 정규화 (ADR-078).
|
|
4
|
+
*
|
|
5
|
+
* 두 곳이 같은 스키마를 쓴다(04-data-models §1.1/§2.1):
|
|
6
|
+
* - App-only `websocket.compression` — 브라우저↔Bridge(embedded WS, MegaApp `_ensureWss`)
|
|
7
|
+
* - Global `wsHub.compression` — Bridge↔Hub link (MegaWsHub 서버 + MegaHubLink 클라)
|
|
8
|
+
*
|
|
9
|
+
* 두 typedef(`MegaWebsocketCompressionConfig` / `MegaWsHubCompressionConfig`)의 6 필드는
|
|
10
|
+
* 그대로 `ws` 패키지의 `perMessageDeflate` 옵션명과 1:1 매핑된다(검증: node_modules/ws/lib/
|
|
11
|
+
* permessage-deflate.js — threshold/serverNoContextTakeover/clientNoContextTakeover/
|
|
12
|
+
* serverMaxWindowBits/concurrencyLimit). `enabled` 만 우리 게이트 — false 면 압축 비활성
|
|
13
|
+
* (`perMessageDeflate: false`, ws 기본값).
|
|
14
|
+
*
|
|
15
|
+
* # threshold 의미 (ws 소스 확인)
|
|
16
|
+
* threshold 는 **context takeover 가 비활성일 때만** 적용된다(sender.js: no_context_takeover
|
|
17
|
+
* negotiate 시 `rsv1 = byteLength >= threshold`). ADR-078 이 두 NoContextTakeover 디폴트를 true 로
|
|
18
|
+
* 둔 이유 — threshold 가 실제로 동작하고 연결당 메모리도 절감.
|
|
19
|
+
*
|
|
20
|
+
* # negotiate 실패 = 자동 raw fallback
|
|
21
|
+
* `permessage-deflate` 확장은 양쪽이 모두 협상해야 활성(RFC 7692). 한쪽이 OFF(`false`)면 협상되지
|
|
22
|
+
* 않아 자동으로 raw 통신으로 떨어진다 — 별도 fallback 코드 불필요(ws 내장 동작).
|
|
23
|
+
*
|
|
24
|
+
* @module core/ws-compression
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 압축 설정 — App `websocket.compression` 과 Global `wsHub.compression` 공통 스키마 (ADR-078).
|
|
29
|
+
* 04-data-models 의 `MegaWebsocketCompressionConfig` / `MegaWsHubCompressionConfig` 와 동일.
|
|
30
|
+
* @typedef {Object} WsCompressionConfig
|
|
31
|
+
* @property {boolean} enabled - 디폴트 false. 옵트인 게이트.
|
|
32
|
+
* @property {number} [threshold] - byte. 디폴트 1024. 미만은 압축 우회(NoContextTakeover 시).
|
|
33
|
+
* @property {boolean} [serverNoContextTakeover] - 디폴트 true (연결당 메모리 절감).
|
|
34
|
+
* @property {boolean} [clientNoContextTakeover] - 디폴트 true.
|
|
35
|
+
* @property {number} [serverMaxWindowBits] - 디폴트 10. 범위 9~15 (벗어나면 부팅 throw).
|
|
36
|
+
* @property {number} [concurrencyLimit] - 디폴트 10.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ADR-078 디폴트값. `enabled` 외 5 필드는 `ws` perMessageDeflate 에 그대로 전달된다.
|
|
41
|
+
* @type {Readonly<{ enabled: boolean, threshold: number, serverNoContextTakeover: boolean, clientNoContextTakeover: boolean, serverMaxWindowBits: number, concurrencyLimit: number }>}
|
|
42
|
+
*/
|
|
43
|
+
export const COMPRESSION_DEFAULTS = Object.freeze({
|
|
44
|
+
enabled: false,
|
|
45
|
+
threshold: 1024,
|
|
46
|
+
serverNoContextTakeover: true,
|
|
47
|
+
clientNoContextTakeover: true,
|
|
48
|
+
serverMaxWindowBits: 10,
|
|
49
|
+
concurrencyLimit: 10,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/** serverMaxWindowBits 허용 범위 (zlib windowBits 최소 9 ~ RFC 7692 최대 15, ADR-078). */
|
|
53
|
+
export const SERVER_MAX_WINDOW_BITS_MIN = 9
|
|
54
|
+
export const SERVER_MAX_WINDOW_BITS_MAX = 15
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 압축 설정 검증 — 위반 시 사람이 읽을 에러 메시지, 통과 시 null 반환(throw 안 함).
|
|
58
|
+
*
|
|
59
|
+
* 호출부가 컨텍스트(MegaConfigError vs 일반 Error)에 맞게 wrap 한다 — config-validator 는
|
|
60
|
+
* MegaConfigError 로, MegaApp/MegaWsHub 직접 생성 경로는 일반 Error 로.
|
|
61
|
+
*
|
|
62
|
+
* @param {Partial<WsCompressionConfig>} [config] - MegaWebsocketCompressionConfig | MegaWsHubCompressionConfig.
|
|
63
|
+
* @param {string} [label='websocket.compression'] - 에러 메시지 prefix (예: 'wsHub.compression').
|
|
64
|
+
* @returns {string | null} 위반 메시지 또는 null.
|
|
65
|
+
*/
|
|
66
|
+
export function checkCompressionConfig(config, label = 'websocket.compression') {
|
|
67
|
+
if (!config || typeof config !== 'object') return null
|
|
68
|
+
if (config.threshold !== undefined) {
|
|
69
|
+
if (typeof config.threshold !== 'number' || Number.isNaN(config.threshold) || config.threshold < 0) {
|
|
70
|
+
return `${label}.threshold must be >= 0. Got ${config.threshold}`
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (config.serverMaxWindowBits !== undefined) {
|
|
74
|
+
const v = config.serverMaxWindowBits
|
|
75
|
+
if (
|
|
76
|
+
typeof v !== 'number' ||
|
|
77
|
+
!Number.isInteger(v) ||
|
|
78
|
+
v < SERVER_MAX_WINDOW_BITS_MIN ||
|
|
79
|
+
v > SERVER_MAX_WINDOW_BITS_MAX
|
|
80
|
+
) {
|
|
81
|
+
return `${label}.serverMaxWindowBits must be in [${SERVER_MAX_WINDOW_BITS_MIN}, ${SERVER_MAX_WINDOW_BITS_MAX}]. Got ${v}`
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 압축 설정 → `ws` WebSocket(Server) 의 `perMessageDeflate` 값으로 변환.
|
|
89
|
+
*
|
|
90
|
+
* - `enabled !== true` → `false` (압축 비활성, ws 기본값과 동일).
|
|
91
|
+
* - `enabled === true` → 5 필드를 디폴트로 채운 perMessageDeflate 옵션 객체.
|
|
92
|
+
*
|
|
93
|
+
* 부팅(config-validator)에서 이미 검증되지만, 직접 생성 경로(single 모드·테스트)를 위해 여기서도
|
|
94
|
+
* 방어적으로 검증한다 — 위반 시 throw(silent 금지).
|
|
95
|
+
*
|
|
96
|
+
* @param {Partial<WsCompressionConfig>} [config] - MegaWebsocketCompressionConfig | MegaWsHubCompressionConfig.
|
|
97
|
+
* @param {string} [label='websocket.compression'] - throw 메시지 prefix.
|
|
98
|
+
* @returns {false | { threshold: number, serverNoContextTakeover: boolean, clientNoContextTakeover: boolean, serverMaxWindowBits: number, concurrencyLimit: number }}
|
|
99
|
+
* @throws {Error} threshold 음수 / serverMaxWindowBits 범위 위반.
|
|
100
|
+
*/
|
|
101
|
+
export function buildPerMessageDeflate(config, label = 'websocket.compression') {
|
|
102
|
+
if (!config || config.enabled !== true) return false
|
|
103
|
+
const violation = checkCompressionConfig(config, label)
|
|
104
|
+
if (violation) throw new Error(violation)
|
|
105
|
+
return {
|
|
106
|
+
threshold: config.threshold ?? COMPRESSION_DEFAULTS.threshold,
|
|
107
|
+
serverNoContextTakeover: config.serverNoContextTakeover ?? COMPRESSION_DEFAULTS.serverNoContextTakeover,
|
|
108
|
+
clientNoContextTakeover: config.clientNoContextTakeover ?? COMPRESSION_DEFAULTS.clientNoContextTakeover,
|
|
109
|
+
serverMaxWindowBits: config.serverMaxWindowBits ?? COMPRESSION_DEFAULTS.serverMaxWindowBits,
|
|
110
|
+
concurrencyLimit: config.concurrencyLimit ?? COMPRESSION_DEFAULTS.concurrencyLimit,
|
|
111
|
+
}
|
|
112
|
+
}
|