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
package/package.json
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mega-framework",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20.6.0"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.js",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://git.nyx-zone.com/mega/mega-framework.git"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.js",
|
|
16
|
+
"./lib": "./src/lib/index.js",
|
|
17
|
+
"./errors": "./src/errors/index.js",
|
|
18
|
+
"./auth": "./src/auth/index.js",
|
|
19
|
+
"./test": "./src/test/index.js",
|
|
20
|
+
"./eslint-plugin": "./src/eslint-plugin/index.js",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src/**/*.js",
|
|
25
|
+
"bin/**/*.js",
|
|
26
|
+
"templates/**",
|
|
27
|
+
"sample/**",
|
|
28
|
+
"infra/**",
|
|
29
|
+
"docker-compose.yml",
|
|
30
|
+
".env",
|
|
31
|
+
".env.example",
|
|
32
|
+
"eslint.config.js",
|
|
33
|
+
"vitest.config.js",
|
|
34
|
+
"jsconfig.json",
|
|
35
|
+
".prettierrc.json",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"CHANGELOG.md"
|
|
39
|
+
],
|
|
40
|
+
"bin": {
|
|
41
|
+
"mega": "bin/mega.js",
|
|
42
|
+
"mega-ws-hub": "bin/mega-ws-hub.js"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public",
|
|
46
|
+
"registry": "https://registry.npmjs.org/"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:watch": "vitest",
|
|
51
|
+
"test:coverage": "vitest run --coverage",
|
|
52
|
+
"lint": "eslint src test",
|
|
53
|
+
"typecheck": "tsc -p jsconfig.json --noEmit",
|
|
54
|
+
"format": "prettier -w src test packages",
|
|
55
|
+
"infra:up": "docker compose up -d --wait",
|
|
56
|
+
"infra:down": "docker compose down -v",
|
|
57
|
+
"infra:logs": "docker compose logs -f",
|
|
58
|
+
"infra:reset": "docker compose down -v && docker compose up -d --wait",
|
|
59
|
+
"test:integration": "npm run infra:up && vitest run test/integration && npm run infra:down",
|
|
60
|
+
"test:unit": "vitest run test/unit",
|
|
61
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm run test:unit",
|
|
62
|
+
"release": "bash scripts/publish.sh"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@eslint/js": "^9.39.4",
|
|
66
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
67
|
+
"@types/node": "^25.9.1",
|
|
68
|
+
"@types/opossum": "^8.1.9",
|
|
69
|
+
"@types/pg": "^8.20.0",
|
|
70
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
71
|
+
"eslint": "^9.0.0",
|
|
72
|
+
"eslint-plugin-promise": "^7.3.0",
|
|
73
|
+
"globals": "^14.0.0",
|
|
74
|
+
"prettier": "^3.0.0",
|
|
75
|
+
"typescript": "^5.9.3",
|
|
76
|
+
"vitest": "^4.1.8"
|
|
77
|
+
},
|
|
78
|
+
"license": "MIT",
|
|
79
|
+
"private": false,
|
|
80
|
+
"dependencies": {
|
|
81
|
+
"@fastify/cookie": "^11.0.2",
|
|
82
|
+
"@fastify/cors": "^11.2.0",
|
|
83
|
+
"@fastify/formbody": "^8.0.2",
|
|
84
|
+
"@fastify/csrf-protection": "^7.1.0",
|
|
85
|
+
"@fastify/helmet": "^13.0.2",
|
|
86
|
+
"@fastify/multipart": "^10.0.0",
|
|
87
|
+
"@fastify/rate-limit": "^10.3.0",
|
|
88
|
+
"@fastify/static": "^9.1.3",
|
|
89
|
+
"@fastify/swagger": "^9.7.0",
|
|
90
|
+
"@fastify/swagger-ui": "^5.2.6",
|
|
91
|
+
"@opentelemetry/api": "^1.9.1",
|
|
92
|
+
"@opentelemetry/exporter-prometheus": "^0.218.0",
|
|
93
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
|
94
|
+
"@opentelemetry/exporter-zipkin": "^2.7.1",
|
|
95
|
+
"@opentelemetry/resources": "^2.7.1",
|
|
96
|
+
"@opentelemetry/sdk-metrics": "^2.7.1",
|
|
97
|
+
"@opentelemetry/sdk-trace-base": "^2.7.1",
|
|
98
|
+
"@opentelemetry/semantic-conventions": "^1.41.1",
|
|
99
|
+
"ajv": "^8.20.0",
|
|
100
|
+
"better-sqlite3": "^12.10.0",
|
|
101
|
+
"commander": "^15.0.0",
|
|
102
|
+
"croner": "^10.0.1",
|
|
103
|
+
"ejs": "^6.0.1",
|
|
104
|
+
"ejs-mate": "^4.0.0",
|
|
105
|
+
"fastify": "^5.8.5",
|
|
106
|
+
"i18next": "^26.3.1",
|
|
107
|
+
"ioredis": "^5.11.0",
|
|
108
|
+
"mariadb": "^3.5.2",
|
|
109
|
+
"mongodb": "^6.21.0",
|
|
110
|
+
"nats": "^2.29.3",
|
|
111
|
+
"opossum": "^9.0.0",
|
|
112
|
+
"p-retry": "^8.0.0",
|
|
113
|
+
"pg": "^8.21.0",
|
|
114
|
+
"pino": "^9.14.0",
|
|
115
|
+
"pino-pretty": "^13.1.3",
|
|
116
|
+
"pino-roll": "^3.1.0",
|
|
117
|
+
"prompts": "^2.4.2",
|
|
118
|
+
"redlock": "5.0.0-beta.2",
|
|
119
|
+
"ws": "^8.21.0"
|
|
120
|
+
}
|
|
121
|
+
}
|
package/sample/crud/.env
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
PORT=4102
|
|
2
|
+
DATABASE_URL=postgres://mega:dkTkqkfl12@localhost:5432/mega_test
|
|
3
|
+
MEGA_CLUSTER_WORKERS=8
|
|
4
|
+
SESSION_SECRET=Zz4VoSzf0sYMEoqASu8G_wx5l3uKi2MlHsxDK3MSkoE
|
|
5
|
+
REDIS_SESSION_URL=redis://:dkTkqkfl12@localhost:6379/0
|
|
6
|
+
REDIS_RATE_URL=redis://:dkTkqkfl12@localhost:6379/2
|
|
7
|
+
REDIS_DEMO_URL=redis://:dkTkqkfl12@localhost:6379/1
|
|
8
|
+
REDIS_LOCK_URL=redis://:dkTkqkfl12@localhost:6379/3
|
|
9
|
+
MONGO_URL=mongodb://mega:dkTkqkfl12@localhost:27017/mega_test?authSource=admin
|
|
10
|
+
NATS_JOBS_URL=nats://localhost:4222
|
|
11
|
+
ASP_MASTER_SECRET=demo-asp-master-7Qe2mWzR1tYbN8sLpKvX0cAfH4dG6jU
|
|
12
|
+
MEGA_OTEL_ENABLED=true
|
|
13
|
+
MEGA_OTEL_SERVICE_NAME=sample-crud
|
|
14
|
+
MEGA_OTEL_ENDPOINT=http://localhost:4318/v1/traces
|
|
15
|
+
MEGA_OTEL_EXPORTER=otlp
|
|
16
|
+
MEGA_OTEL_SAMPLING_RATIO=1.0
|
|
17
|
+
MEGA_OTEL_ZIPKIN_API=http://localhost:9411/api/v2
|
|
18
|
+
DEMO_UPLOAD_DIR=var/uploads
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# sample-crud 환경변수 (.env.example) — 복사해서 .env 로 쓰고 실제 값 채우기. .env 는 git 에 안 올림.
|
|
2
|
+
|
|
3
|
+
# HTTP 포트
|
|
4
|
+
PORT=3000
|
|
5
|
+
|
|
6
|
+
# cluster 워커 프로세스 수(ADR-154) — 정수 N 또는 max(CPU 코어 수). 미설정/1=단일 프로세스.
|
|
7
|
+
# MEGA_CLUSTER_WORKERS=max
|
|
8
|
+
|
|
9
|
+
# Postgres 연결 (필수) — services.databases.primary.url 이 읽는다.
|
|
10
|
+
# 예: docker compose 의 postgres
|
|
11
|
+
DATABASE_URL=postgres://mega:change-me@localhost:5432/mega_test
|
|
12
|
+
|
|
13
|
+
# 세션 쿠키 HMAC 서명 시크릿 (필수, ADR-155) — server.sessionSecret 이 읽는다. 충분히 긴 랜덤 문자열.
|
|
14
|
+
SESSION_SECRET=change-me-to-a-long-random-secret
|
|
15
|
+
|
|
16
|
+
# Redis 연결 (필수, ADR-155/157) — 세션 store·brute-force·/demo/redis·cron·jobs 데모 캐시 + 분산 락(redlock).
|
|
17
|
+
# DB 인덱스를 분리해 키 충돌을 피한다(/0 세션, /1 demo, /2 rate, /3 lock).
|
|
18
|
+
REDIS_SESSION_URL=redis://:change-me@localhost:6379/0
|
|
19
|
+
REDIS_RATE_URL=redis://:change-me@localhost:6379/2
|
|
20
|
+
REDIS_DEMO_URL=redis://:change-me@localhost:6379/1
|
|
21
|
+
# 분산 락(redlock, ADR-113) — services.caches.lock 이 읽는다. /demo/cron 스케줄의 클러스터 중복방지(leader election).
|
|
22
|
+
REDIS_LOCK_URL=redis://:change-me@localhost:6379/3
|
|
23
|
+
|
|
24
|
+
# NATS 연결 (필수, ADR-119) — services.buses.jobs.url 이 읽는다. /demo/jobs 잡 큐(EmailJob)의 JetStream 백엔드.
|
|
25
|
+
# `mega worker` 프로세스가 소비하고 웹은 enqueue 한다. JetStream 활성 NATS 서버 필요(nats-server -js).
|
|
26
|
+
NATS_JOBS_URL=nats://localhost:4222
|
|
27
|
+
|
|
28
|
+
# MongoDB 연결 (필수, ADR-157) — services.databases.mongo.url 이 읽는다. /demo/notes 데모 컬렉션의 백엔드.
|
|
29
|
+
# url path 의 dbName(mega_test)을 어댑터가 추출한다. authSource 는 인증 DB(보통 admin).
|
|
30
|
+
MONGO_URL=mongodb://mega:change-me@localhost:27017/mega_test?authSource=admin
|
|
31
|
+
|
|
32
|
+
# ASP WebSocket 데모 masterSecret (필수, ADR-158) — asp.masterSecret 이 읽는다. /ws/chat 의 E:/P:
|
|
33
|
+
# 프레임 키 유도에 쓰인다. ASP 는 클라이언트(WASM MegaSocket)도 같은 secret 으로 키를 만드는 공유-키
|
|
34
|
+
# 구조라 데모 페이지가 이 값을 브라우저에 주입한다 — 운영 secret 과 섞이지 않게 데모 전용 값을 둔다.
|
|
35
|
+
ASP_MASTER_SECRET=change-me-to-a-demo-asp-secret
|
|
36
|
+
|
|
37
|
+
# OpenTelemetry 트레이싱 (선택, ADR-104/126/163) — /demo/tracing 데모가 요청별 trace_id 를 만들고 Zipkin 으로
|
|
38
|
+
# 잇는다. 미설정/false 면 트레이싱 OFF(데모는 비활성 안내를 보여줌). docker 의 otel-collector(:4318 OTLP HTTP)·
|
|
39
|
+
# zipkin(:9411) 가동 시 true 로 켠다. exporter otlp → collector → zipkin 경로로 span 이 전달된다.
|
|
40
|
+
MEGA_OTEL_ENABLED=false
|
|
41
|
+
MEGA_OTEL_SERVICE_NAME=sample-crud
|
|
42
|
+
MEGA_OTEL_ENDPOINT=http://localhost:4318/v1/traces
|
|
43
|
+
MEGA_OTEL_EXPORTER=otlp
|
|
44
|
+
MEGA_OTEL_SAMPLING_RATIO=1.0
|
|
45
|
+
# Zipkin UI API base — /demo/tracing 이 trace 딥링크 base(.../api/v2 를 떼 UI 루트)로 쓴다.
|
|
46
|
+
MEGA_OTEL_ZIPKIN_API=http://localhost:9411/api/v2
|
|
47
|
+
|
|
48
|
+
# 업로드 저장 디렉터리 (선택, ADR-163) — /demo/upload 이 파일을 저장할 위치. 상대경로는 프로젝트 루트
|
|
49
|
+
# (cwd) 기준, 절대경로면 그대로. 미설정 시 var/uploads(.gitignore). 다운로드도 같은 위치에서 소스로 읽는다.
|
|
50
|
+
DEMO_UPLOAD_DIR=var/uploads
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# sample-crud
|
|
2
|
+
|
|
3
|
+
MEGA-FRAMEWORK 의 **postgres CRUD baseline** 샘플. 단일 리소스(`users`)로 정본 계층 흐름을 보여준다:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
route → controller → ctx.services.user(자동 DI) → UserService → User 모델 → postgres
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- **모델** `apps/main/models/user.js` — `MegaModel`, 계측 SQL(`this.query`)로 CRUD. ORM 없음(ADR-009).
|
|
10
|
+
- **서비스** `apps/main/services/user-service.js` — 비즈니스 로직 + 404/409/400 에러 매핑. 자동 DI 이름 `user`(ADR-148).
|
|
11
|
+
- **컨트롤러** `apps/main/controllers/user-controller.js` — 정적 메서드, `ctx.services.user` 경유(ADR-022). 도메인 데이터만 반환 → 프레임워크가 envelope `{ ok, data, meta }` 로 감쌈(ADR-018/147).
|
|
12
|
+
- **라우트** `apps/main/routes/users.js` — REST 5종(자동 로딩).
|
|
13
|
+
- **마이그레이션** `apps/main/migrations/*.js` — `mega migrate` 러너로 실행(ADR-149).
|
|
14
|
+
|
|
15
|
+
## 준비
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 1) 의존성(레포 내부 참조: file:../..)
|
|
19
|
+
npm install
|
|
20
|
+
|
|
21
|
+
# 2) 환경변수 — .env.example 복사 후 DATABASE_URL 채우기
|
|
22
|
+
cp .env.example .env
|
|
23
|
+
# DATABASE_URL=postgres://user:pass@localhost:5432/yourdb
|
|
24
|
+
|
|
25
|
+
# 3) 스키마 마이그레이션
|
|
26
|
+
npm run migrate # = mega migrate (pending up 일괄)
|
|
27
|
+
npm run migrate:status # 적용/미적용 목록
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## 실행
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm start # NODE_ENV=production mega start — HTTP listen (기본 프로덕션)
|
|
34
|
+
npm run dev # NODE_ENV=development mega start — 개발 모드
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
> `npm start` 는 **프로덕션 기본**(`NODE_ENV=production`)이라 i18n `saveMissing`(누락 키 자동 기입)이
|
|
38
|
+
> 꺼진다 — 라이브 서버가 ko/en locale 파일을 오염시키지 않게 하기 위함(ADR-164). 누락 키 자동 보완 등
|
|
39
|
+
> 개발 편의가 필요하면 `npm run dev`(`NODE_ENV=development`)로 띄운다.
|
|
40
|
+
|
|
41
|
+
## API (envelope: `{ ok, data, meta }`)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
curl -X POST localhost:3000/users -H 'content-type: application/json' \
|
|
45
|
+
-d '{"name":"Ada","email":"ada@example.com"}' # 201 → { ok:true, data:{ id, name, email, created_at }, meta }
|
|
46
|
+
curl localhost:3000/users # 목록
|
|
47
|
+
curl localhost:3000/users/1 # 단건 (없으면 404 { ok:false, error })
|
|
48
|
+
curl -X PUT localhost:3000/users/1 -H 'content-type: application/json' -d '{"name":"Ada L."}'
|
|
49
|
+
curl -X DELETE localhost:3000/users/1 # { ok:true, data:{ deleted:true }, meta }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 관리 UI (Bootstrap 5, 서버사이드 렌더)
|
|
53
|
+
|
|
54
|
+
같은 서비스 계층을 쓰는 **MPA 관리 화면**(EJS + ejs-mate + Bootstrap 5)을 함께 제공한다. JSON API(`/users`)와
|
|
55
|
+
충돌하지 않도록 UI 는 `/admin/users` 네임스페이스를 쓴다.
|
|
56
|
+
|
|
57
|
+
| 경로 | 설명 |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `GET /` | 랜딩(관리 UI · JSON API 안내) |
|
|
60
|
+
| `GET /admin/users` | 목록(테이블 + 수정/삭제, 삭제 확인 모달) |
|
|
61
|
+
| `GET /admin/users/new` · `POST /admin/users` | 신규 폼 → 생성(PRG 리다이렉트) |
|
|
62
|
+
| `GET /admin/users/:id/edit` · `POST /admin/users/:id` | 수정 폼(사전 채움) → 수정 |
|
|
63
|
+
| `POST /admin/users/:id/delete` | 삭제 |
|
|
64
|
+
|
|
65
|
+
- HTML 폼은 GET/POST 만 쓰므로(브라우저 제약) 수정·삭제도 POST. RESTful PUT/DELETE 는 JSON API(`/users`)가 제공.
|
|
66
|
+
- 폼 제출(urlencoded)은 `@fastify/formbody` 가 파싱(views 옵트인 시 자동, ADR-151). CSRF(디폴트 ON) 토큰은
|
|
67
|
+
폼에 자동 포함된다.
|
|
68
|
+
- navbar 의 언어 메뉴로 ko/en 전환(쿠키 `mega.lang`), 다크모드 토글(`data-bs-theme` + localStorage).
|
|
69
|
+
- Bootstrap 은 `apps/main/public/vendor/bootstrap/` 에 vendored(`/static/vendor/bootstrap/...`).
|
|
70
|
+
|
|
71
|
+
## 테스트
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm test # mega test — UserService 단위(모델 스파이, DB 불필요)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> 로컬 검증 주의: 레포 통합 테스트(`postgres-adapter`)가 같은 `mega_test` DB 의 제네릭 `users` 테이블을
|
|
78
|
+
> 매 실행 재생성(다른 스키마)한다. 레포 테스트 직후 이 샘플을 띄우면 `created_at` 컬럼 부재로 충돌하니,
|
|
79
|
+
> `npm run migrate:down && npm run migrate` 로 스키마를 재생성하거나 별도 DB 를 쓴다.
|
|
80
|
+
|
|
81
|
+
## 롤백
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm run migrate:down # 마지막 마이그레이션 1개 롤백(down)
|
|
85
|
+
```
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* apps/main/app.config.js — App-only + Shared-Reference 스코프(ADR-061). 기본 앱 `main`.
|
|
4
|
+
* 전역 db 'primary'(mega.config.js)를 앱 별명 'db' 로 참조한다(ADR-102 글로벌 공유). 라우트 핸들러는
|
|
5
|
+
* `ctx.db('db')`, 모델은 `static adapter='primary'`(globalKey 직접)로 같은 인스턴스에 닿는다.
|
|
6
|
+
*/
|
|
7
|
+
import { webRequireAuth } from './middleware/web-auth.js'
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
name: 'main',
|
|
11
|
+
hosts: ['localhost', 'main.localhost'],
|
|
12
|
+
|
|
13
|
+
// 어댑터 별명 맵 — alias 'db' → globalKey 'primary'(postgres), 'mongo' → 'mongo'(notes 데모, ADR-108).
|
|
14
|
+
databases: {
|
|
15
|
+
db: 'primary',
|
|
16
|
+
mongo: 'mongo',
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
// 캐시 별명 맵 — 'rate' → 'rate'(brute-force, ADR-049/130), 'demo' → 'demo'(/demo/redis·cron·jobs 캐시).
|
|
20
|
+
caches: {
|
|
21
|
+
rate: 'rate',
|
|
22
|
+
demo: 'demo',
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
// 버스 별명 맵 — 'jobs' → 'jobs'(NATS). /demo/jobs 컨트롤러가 ctx.bus('jobs').native(nc)로 EmailJob 을
|
|
26
|
+
// enqueue 한다(producer 측). 소비는 `mega worker` 프로세스 몫(ADR-119).
|
|
27
|
+
buses: {
|
|
28
|
+
jobs: 'jobs',
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
// 세션(ADR-129/046) — redis store. sid 쿠키(HMAC 서명)는 server.sessionSecret 로 서명. rolling TTL 24h.
|
|
32
|
+
// 로그인 신원(userId/userName)을 세션에 싣고, 미들웨어가 req.session/ctx.session 으로 노출한다.
|
|
33
|
+
session: {
|
|
34
|
+
store: {
|
|
35
|
+
driver: 'redis',
|
|
36
|
+
url: process.env.REDIS_SESSION_URL,
|
|
37
|
+
},
|
|
38
|
+
ttlMs: 86_400_000,
|
|
39
|
+
rolling: true,
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// EJS + ejs-mate 서버사이드 렌더(ADR-011/136) — 관리 UI(MPA). views 옵트인이라 HTML 폼 제출
|
|
43
|
+
// (urlencoded)도 자동 파싱된다(ADR-151, @fastify/formbody). 렌더 시 req.t/req.lang 자동 병합.
|
|
44
|
+
views: {
|
|
45
|
+
dir: 'apps/main/views',
|
|
46
|
+
layoutDir: 'layouts',
|
|
47
|
+
partialsDir: 'partials',
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// i18next 다국어(ADR-037/038/039) — locale 은 쿠키(`mega.lang`)로만 결정. server scope 는 `t()`/뷰,
|
|
51
|
+
// client scope 는 `/i18n/translations`. navbar 의 언어 메뉴가 쿠키를 굽고 새로고침한다.
|
|
52
|
+
i18n: {
|
|
53
|
+
default: 'ko',
|
|
54
|
+
available: ['ko', 'en'],
|
|
55
|
+
fallback: 'en',
|
|
56
|
+
localesDir: 'apps/main/locales',
|
|
57
|
+
exposeTranslations: true,
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// 정적 자산(ADR-071/139) — Bootstrap 5 + WASM MegaSocket vendored + 브랜드 CSS/JS 를 `${prefix}/...` 로 서빙.
|
|
61
|
+
staticAssets: {
|
|
62
|
+
enabled: true,
|
|
63
|
+
dir: 'apps/main/public',
|
|
64
|
+
prefix: '/static',
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
// ASP 앱 옵트인(ADR-083/127) — websocket.namespaces 에 든 WS 경로만 기본 암호화(E: 프레임), 나머지는
|
|
68
|
+
// 평문(P:). masterSecret 은 global(mega.config.js)에서 합성된다. /ws/chat 채팅은 ASP E: 로 종단한다.
|
|
69
|
+
asp: {
|
|
70
|
+
websocket: {
|
|
71
|
+
namespaces: ['/ws/chat'],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// rate limit 상향(ADR-073) — /demo/worker 데모는 1초 간격 하트비트 ping(메인 스레드 non-block 시연)으로
|
|
76
|
+
// 폴링한다. 기본 한도(100/분)는 폴링 + 일반 탐색이 겹치면 쉽게 넘어 데모가 429 로 끊긴다. 데모 앱이라
|
|
77
|
+
// 폴링을 허용하도록 한도를 넉넉히 둔다(여전히 ON — 무제한 아님). 운영 앱은 엔드포인트별로 더 낮게 잡는다.
|
|
78
|
+
rateLimit: { max: 600, timeWindow: '1 minute' },
|
|
79
|
+
|
|
80
|
+
// OpenAPI/Swagger 옵트인(ADR-070/140) — @fastify/swagger + swagger-ui 를 `/docs` 에 노출한다(관측 데모).
|
|
81
|
+
// 라우트에 적은 JSON Schema(routes/*.js 의 opts.schema)와 openapi 메타(opts.openapi)를 코어가 onRoute 로
|
|
82
|
+
// 긁어모아 OpenAPI 3.x 명세를 자동 생성한다. auth 가드는 핸들러와 같은 시그니처(webRequireAuth) — 비로그인
|
|
83
|
+
// 브라우저는 로그인 페이지로 리다이렉트된다(데모 API 문서도 로그인 필요). 디폴트 OFF 라 명시적으로 켠다.
|
|
84
|
+
openapi: {
|
|
85
|
+
enabled: true,
|
|
86
|
+
path: '/docs',
|
|
87
|
+
info: {
|
|
88
|
+
title: 'sample-crud API',
|
|
89
|
+
version: '0.1.0',
|
|
90
|
+
description: 'MEGA-FRAMEWORK CRUD 샘플의 JSON REST API 명세 (users/notes/redis). 관측 데모(ADR-163).',
|
|
91
|
+
},
|
|
92
|
+
auth: [webRequireAuth],
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// 파일 업로드 옵트인(ADR-133) — @fastify/multipart. /demo/upload 데모가 req.saveUploads()로 임시 디렉터리에
|
|
96
|
+
// 저장한다. MIME 화이트리스트(이미지/PDF/텍스트) + 개수·크기 제한으로 게이트한다. 미옵트인이면 multipart=415.
|
|
97
|
+
upload: {
|
|
98
|
+
maxFileSize: 5 * 1024 * 1024,
|
|
99
|
+
maxFiles: 3,
|
|
100
|
+
allowedMimeTypes: ['image/*', 'application/pdf', 'text/plain'],
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
// helmet CSP override(ADR-153/158) — /demo/ws 채팅은 WASM MegaSocket(ASP 클라이언트)을 쓴다.
|
|
104
|
+
// WebAssembly.instantiate 는 CSP script-src 에 'wasm-unsafe-eval' 이 있어야 한다(CSP3) — helmet 기본
|
|
105
|
+
// script-src 'self' 만으로는 WASM 컴파일이 차단된다. useDefaults(기본 true)라 이 지시문만 교체되고
|
|
106
|
+
// 나머지(connect-src 'self' = 동일 출처 WebSocket 허용 등)는 helmet 기본을 유지한다.
|
|
107
|
+
helmet: {
|
|
108
|
+
contentSecurityPolicy: {
|
|
109
|
+
directives: {
|
|
110
|
+
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* chat-bus.js — redis pub/sub 기반 cluster-wide 채팅 broadcast (ADR-158).
|
|
4
|
+
*
|
|
5
|
+
* `app.broadcast`(로컬 ns fan-out)는 단일 프로세스 안에서만 전달된다 — 클러스터(워커 N개)에선 다른
|
|
6
|
+
* 워커에 붙은 클라에게 닿지 않는다. hub 프로세스 없이 cluster-wide 전파를 하기 위해 redis pub/sub 를 쓴다:
|
|
7
|
+
* - 송신: 메시지를 redis 채널(`ws:chat:bcast`)에 **PUBLISH** 만 한다(로컬 직접 전달 안 함).
|
|
8
|
+
* - 수신: **워커마다** 전용 구독 연결(ioredis `duplicate()`)이 그 채널을 **SUBSCRIBE** 하고, 도착한
|
|
9
|
+
* 메시지를 자기 워커의 로컬 연결들에 전달한다.
|
|
10
|
+
* 결과: PUBLISH 1건 → 모든 워커의 구독자가 받아 각자 로컬 전달 → 전 클러스터 클라가 정확히 1회씩 수신
|
|
11
|
+
* (송신자 본인 워커도 구독자 경로로 전달받아 echo). 구독 연결은 명령 전용 연결과 분리해야 하므로
|
|
12
|
+
* (subscribe 모드 연결은 일반 명령 불가) `demo` 캐시의 raw ioredis 를 `duplicate()` 한다.
|
|
13
|
+
*
|
|
14
|
+
* 접속자 명단(roster)도 cluster-wide 다 — redis HASH(`ws:chat:roster`, field=sessionId→userName)에 두어
|
|
15
|
+
* 어느 워커에서든 같은 명단·인원수를 본다(INCR/DECR 카운터의 비정상종료 드리프트 회피, 명단까지 공유).
|
|
16
|
+
*
|
|
17
|
+
* @module channels/chat-bus
|
|
18
|
+
*/
|
|
19
|
+
import { MegaShutdown } from 'mega-framework'
|
|
20
|
+
|
|
21
|
+
/** cluster-wide 전파 채널(redis pub/sub 은 db 와 무관한 글로벌 네임스페이스). */
|
|
22
|
+
export const BCAST_CHANNEL = 'ws:chat:bcast'
|
|
23
|
+
/** 접속자 명단 HASH 키 — field=sessionId, value=userName. */
|
|
24
|
+
export const ROSTER_KEY = 'ws:chat:roster'
|
|
25
|
+
|
|
26
|
+
/** 이 워커의 로컬 연결 레지스트리. sock → { sessionId, userName }. */
|
|
27
|
+
const localConns = new Map()
|
|
28
|
+
/** 이 워커의 전용 구독 연결(1개, lazy). */
|
|
29
|
+
let subscriber = null
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 이 워커의 구독자를 1회 보장한다(첫 연결 시). 채널 SUBSCRIBE + 메시지 → 로컬 전달 + graceful 종료 hook.
|
|
33
|
+
*
|
|
34
|
+
* @param {any} app - MegaApp(로깅용).
|
|
35
|
+
* @param {import('ioredis').Redis} redisNative - `demo` 캐시의 raw ioredis(여기서 duplicate).
|
|
36
|
+
* @returns {void}
|
|
37
|
+
*/
|
|
38
|
+
export function ensureSubscriber(app, redisNative) {
|
|
39
|
+
if (subscriber) return
|
|
40
|
+
const log = app?.fastify?.log
|
|
41
|
+
subscriber = redisNative.duplicate()
|
|
42
|
+
subscriber.on('message', (/** @type {string} */ _channel, /** @type {string} */ raw) => {
|
|
43
|
+
let env
|
|
44
|
+
try {
|
|
45
|
+
env = JSON.parse(raw)
|
|
46
|
+
} catch (err) {
|
|
47
|
+
// 손상 페이로드는 전달하지 않는다(사유 명시 + 로그, silent 금지).
|
|
48
|
+
log?.warn?.({ err }, 'chat-bus subscribe payload parse failed')
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
deliverLocal(env)
|
|
52
|
+
})
|
|
53
|
+
subscriber.on('error', (/** @type {Error} */ err) => {
|
|
54
|
+
// 구독 연결 오류는 비치명적(ioredis 가 재연결) — 로그만.
|
|
55
|
+
log?.warn?.({ err }, 'chat-bus subscriber connection error')
|
|
56
|
+
})
|
|
57
|
+
// subscribe 실패는 전파 자체가 죽는 심각한 신호 — 로그 + throw(fail-closed, silent 금지).
|
|
58
|
+
subscriber.subscribe(BCAST_CHANNEL).catch((err) => {
|
|
59
|
+
log?.error?.({ err, channel: BCAST_CHANNEL }, 'chat-bus subscribe failed')
|
|
60
|
+
throw err
|
|
61
|
+
})
|
|
62
|
+
// 워커 graceful 종료 시 구독 연결 정리(이벤트루프 누수 방지).
|
|
63
|
+
MegaShutdown.register('sample-chat-bus', async () => {
|
|
64
|
+
const s = subscriber
|
|
65
|
+
subscriber = null
|
|
66
|
+
localConns.clear()
|
|
67
|
+
if (s) await s.quit().catch(() => {})
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 구독으로 받은 메시지를 이 워커의 로컬 연결에 전달한다. exceptSessionIds 에 든 세션은 건너뛴다.
|
|
73
|
+
* @param {{ message: { type: string, payload?: object }, exceptSessionIds?: string[] }} env
|
|
74
|
+
* @returns {void}
|
|
75
|
+
*/
|
|
76
|
+
function deliverLocal({ message, exceptSessionIds }) {
|
|
77
|
+
if (!message || typeof message.type !== 'string') return
|
|
78
|
+
const except = Array.isArray(exceptSessionIds) && exceptSessionIds.length > 0 ? new Set(exceptSessionIds) : null
|
|
79
|
+
for (const [sock, meta] of localConns) {
|
|
80
|
+
if (!sock.isOpen) continue
|
|
81
|
+
if (except && except.has(meta.sessionId)) continue
|
|
82
|
+
sock.send({ type: message.type, payload: message.payload })
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 로컬 연결 등록(onConnect). @param {any} sock @param {{ sessionId: string, userName: string }} meta
|
|
88
|
+
* @returns {void}
|
|
89
|
+
*/
|
|
90
|
+
export function registerConn(sock, meta) {
|
|
91
|
+
localConns.set(sock, meta)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 로컬 연결 해제(onDisconnect). @param {any} sock @returns {void} */
|
|
95
|
+
export function unregisterConn(sock) {
|
|
96
|
+
localConns.delete(sock)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* cluster-wide 전파 — redis 채널에 PUBLISH(모든 워커 구독자가 받아 로컬 전달).
|
|
101
|
+
* @param {import('ioredis').Redis} redisNative
|
|
102
|
+
* @param {{ message: { type: string, payload?: object }, exceptSessionIds?: string[] }} env
|
|
103
|
+
* @returns {Promise<void>}
|
|
104
|
+
*/
|
|
105
|
+
export async function publish(redisNative, env) {
|
|
106
|
+
await redisNative.publish(BCAST_CHANNEL, JSON.stringify(env))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 테스트 정리용 — 구독 연결 종료 + 레지스트리 초기화. @returns {Promise<void>} */
|
|
110
|
+
export async function closeChatBus() {
|
|
111
|
+
const s = subscriber
|
|
112
|
+
subscriber = null
|
|
113
|
+
localConns.clear()
|
|
114
|
+
if (s) await s.quit().catch(() => {})
|
|
115
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* ChatChannel — /ws/chat 실시간 채팅 WS 채널 (ADR-158).
|
|
4
|
+
*
|
|
5
|
+
* MegaWebSocketController 를 상속해 라이프사이클 3훅 + `type` 자동 디스패치(ADR-015)를 쓴다. 채널
|
|
6
|
+
* 인스턴스는 **연결마다 새로 생성**되므로 공유 상태는 인스턴스가 아니라:
|
|
7
|
+
* - **전파(cluster-wide)**: redis pub/sub({@link module:channels/chat-bus}) — `publish` 로 모든 워커에
|
|
8
|
+
* fan-out. `app.broadcast`(로컬 ns 전용)는 클러스터에서 다른 워커에 안 닿으므로 쓰지 않는다(hub 불요).
|
|
9
|
+
* - **접속자 명단·기록**: redis(`demo` 캐시 `.native`) — roster HASH(field=sessionId→userName, cluster-wide
|
|
10
|
+
* 인원수·명단) + 최근기록 캡 리스트(RPUSH/LTRIM).
|
|
11
|
+
* 에 둔다.
|
|
12
|
+
*
|
|
13
|
+
* ASP: `/ws/chat` 는 asp.websocket.namespaces 에 들어 기본 암호화(E: 프레임). 코덱은 프레임워크
|
|
14
|
+
* (ws-upgrade.js)가 종단하므로 채널은 **평문 envelope** 만 다룬다 — 암복호는 transport 계층 책임.
|
|
15
|
+
*
|
|
16
|
+
* 인증: `before`(makeWsRequireAuth) 가 로그인 세션만 통과시키므로 `ctx.auth`(userId/sessionId/userName)가 보장된다.
|
|
17
|
+
*
|
|
18
|
+
* @module channels/chat-channel
|
|
19
|
+
*/
|
|
20
|
+
import { MegaWebSocketController } from 'mega-framework'
|
|
21
|
+
import { ensureSubscriber, registerConn, unregisterConn, publish, ROSTER_KEY } from './chat-bus.js'
|
|
22
|
+
|
|
23
|
+
/** broadcast 논리 채널명(payload 분류용). */
|
|
24
|
+
const CHANNEL = 'chat'
|
|
25
|
+
/** redis 키 — 최근 메시지 캡 리스트(RPUSH + LTRIM). */
|
|
26
|
+
const HISTORY_KEY = 'ws:chat:history'
|
|
27
|
+
/** 보관·재생할 최근 메시지 개수. */
|
|
28
|
+
const HISTORY_LIMIT = 30
|
|
29
|
+
/** 기록 TTL(초) — 1일. 갱신마다 연장. */
|
|
30
|
+
const HISTORY_TTL_SEC = 86_400
|
|
31
|
+
|
|
32
|
+
export class ChatChannel extends MegaWebSocketController {
|
|
33
|
+
/**
|
|
34
|
+
* 연결 수립 — 워커 구독자 보장 + 로컬 등록 + roster 갱신(cluster-wide) + 최근기록 재생 + 입장 전파.
|
|
35
|
+
* @param {any} sock @param {any} ctx @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
async onConnect(sock, ctx) {
|
|
38
|
+
const { userId, sessionId, userName } = ctx.auth
|
|
39
|
+
const redis = this._redis(ctx)
|
|
40
|
+
if (!redis) {
|
|
41
|
+
// redis 가 전파 transport — 없으면 채팅 불가. 명시적으로 닫는다(silent 진행 금지).
|
|
42
|
+
ctx.log?.error?.({ connId: sock.id }, 'ws.chat: redis(demo) unavailable — closing')
|
|
43
|
+
if (sock.isOpen) sock.close(1011, 'chat backend unavailable')
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
ensureSubscriber(ctx.app, redis) // 이 워커의 cluster-wide 구독 1회 보장.
|
|
47
|
+
registerConn(sock, { sessionId, userName })
|
|
48
|
+
|
|
49
|
+
let roster = [userName]
|
|
50
|
+
let items = []
|
|
51
|
+
try {
|
|
52
|
+
await redis.hset(ROSTER_KEY, sessionId, userName)
|
|
53
|
+
roster = await redis.hvals(ROSTER_KEY)
|
|
54
|
+
const raw = await redis.lrange(HISTORY_KEY, -HISTORY_LIMIT, -1)
|
|
55
|
+
for (const s of raw) {
|
|
56
|
+
try {
|
|
57
|
+
items.push(JSON.parse(s))
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// 손상된 기록 1건은 재생에서 제외(전체 재생을 막지 않음) — 사유 명시 + debug 로그(silent 금지).
|
|
60
|
+
ctx.log?.debug?.({ err, connId: sock.id }, 'ws.chat skip corrupt history entry')
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// roster/기록 조회 실패는 비치명적 — 빈 명단·기록으로라도 연결은 유지(다음 동작에서 복구).
|
|
65
|
+
ctx.log?.warn?.({ err, connId: sock.id }, 'ws.chat onConnect redis read failed (non-fatal)')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 입장 본인에게: 신원 + 최근기록 + 현재 명단/인원 + 워커 PID(클러스터 확인용).
|
|
69
|
+
sock.send({
|
|
70
|
+
type: 'chat.history',
|
|
71
|
+
payload: { me: { userId, userName }, items, online: roster.length, members: roster, workerPid: process.pid },
|
|
72
|
+
})
|
|
73
|
+
// 다른 접속자(전 클러스터)에게: 입장 알림 + 갱신된 명단(본인은 제외).
|
|
74
|
+
await this._publish(ctx, redis, {
|
|
75
|
+
message: { type: 'chat.presence', payload: { event: 'join', userName, online: roster.length, members: roster } },
|
|
76
|
+
exceptSessionIds: [sessionId],
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `chat.send` — 검증된 text 를 기록 적재 + 전 클러스터 전파(본인 포함, echo).
|
|
82
|
+
* @param {any} sock @param {any} msg @param {any} ctx @returns {Promise<void>}
|
|
83
|
+
*/
|
|
84
|
+
async ['chat.send'](sock, msg, ctx) {
|
|
85
|
+
const text = String(msg.payload?.text ?? '').trim()
|
|
86
|
+
if (text.length === 0) return // 스키마가 minLength 1 강제하지만 trim 후 공백뿐이면 무시.
|
|
87
|
+
const redis = this._redis(ctx)
|
|
88
|
+
if (!redis) return
|
|
89
|
+
const entry = { userId: ctx.auth.userId, userName: ctx.auth.userName, text, ts: Date.now() }
|
|
90
|
+
try {
|
|
91
|
+
await redis.rpush(HISTORY_KEY, JSON.stringify(entry))
|
|
92
|
+
await redis.ltrim(HISTORY_KEY, -HISTORY_LIMIT, -1)
|
|
93
|
+
await redis.expire(HISTORY_KEY, HISTORY_TTL_SEC)
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// 기록 적재 실패는 비치명적 — 전파는 그대로 진행(다른 사용자는 메시지를 받는다).
|
|
96
|
+
ctx.log?.warn?.({ err, connId: sock.id }, 'ws.chat history append failed (non-fatal)')
|
|
97
|
+
}
|
|
98
|
+
// exceptSessionIds 없음 → 본인도 구독자 경로로 echo 받아 렌더(optimistic 렌더 안 함 → 중복 없음).
|
|
99
|
+
await this._publish(ctx, redis, { message: { type: 'chat.msg', payload: entry } })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 연결 종료 — 로컬 해제 + roster 제거(cluster-wide) + 퇴장 전파.
|
|
104
|
+
* @param {any} sock @param {any} ctx @returns {Promise<void>}
|
|
105
|
+
*/
|
|
106
|
+
async onDisconnect(sock, ctx) {
|
|
107
|
+
unregisterConn(sock)
|
|
108
|
+
if (!ctx.auth) return
|
|
109
|
+
const { sessionId, userName } = ctx.auth
|
|
110
|
+
const redis = this._redis(ctx)
|
|
111
|
+
if (!redis) return
|
|
112
|
+
let roster = []
|
|
113
|
+
try {
|
|
114
|
+
await redis.hdel(ROSTER_KEY, sessionId)
|
|
115
|
+
roster = await redis.hvals(ROSTER_KEY)
|
|
116
|
+
} catch (err) {
|
|
117
|
+
ctx.log?.warn?.({ err, connId: sock.id }, 'ws.chat onDisconnect redis failed (non-fatal)')
|
|
118
|
+
}
|
|
119
|
+
await this._publish(ctx, redis, {
|
|
120
|
+
message: { type: 'chat.presence', payload: { event: 'leave', userName, online: roster.length, members: roster } },
|
|
121
|
+
exceptSessionIds: [sessionId],
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 전파 1건 — 실패해도 연결을 막지 않는다(메시지 1건 유실은 비치명적, 로그). silent 금지.
|
|
127
|
+
* @param {any} ctx @param {import('ioredis').Redis} redis @param {object} env @returns {Promise<void>}
|
|
128
|
+
* @private
|
|
129
|
+
*/
|
|
130
|
+
async _publish(ctx, redis, env) {
|
|
131
|
+
try {
|
|
132
|
+
await publish(redis, env)
|
|
133
|
+
} catch (err) {
|
|
134
|
+
ctx.log?.warn?.({ err }, 'ws.chat publish failed (message dropped)')
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 'demo' redis 캐시의 raw ioredis 핸들(원자적 연산·pub/sub용). 미배선(단위 테스트 mock app)이면 null.
|
|
140
|
+
* @param {any} ctx @returns {any|null} @private
|
|
141
|
+
*/
|
|
142
|
+
_redis(ctx) {
|
|
143
|
+
return ctx.cache?.('demo')?.native ?? null
|
|
144
|
+
}
|
|
145
|
+
}
|