mega-framework 0.1.10 → 0.1.11
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/README.md +14 -4
- package/package.json +23 -21
- package/sample/crud/.env +10 -2
- package/sample/crud/.env.example +8 -0
- package/sample/crud/apps/main/controllers/bus-controller.js +122 -0
- package/sample/crud/apps/main/controllers/lock-controller.js +117 -0
- package/sample/crud/apps/main/locales/server/en.json +31 -1
- package/sample/crud/apps/main/locales/server/ko.json +31 -1
- package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
- package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
- package/sample/crud/apps/main/routes/bus.js +43 -0
- package/sample/crud/apps/main/routes/lock.js +35 -0
- package/sample/crud/apps/main/services/jobs-demo-service.js +21 -14
- package/sample/crud/apps/main/views/bus/index.ejs +80 -0
- package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
- package/sample/crud/apps/main/views/lock/index.ejs +99 -0
- package/sample/crud/docs/guide/03-service-model-db.md +48 -0
- package/sample/crud/docs/guide/05-scheduler-job-worker.md +3 -1
- package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
- package/sample/crud/docs/guide/10-multi-app.md +74 -0
- package/sample/crud/mega.config.js +32 -0
- package/sample/crud/package.json +3 -2
- package/sample/multi/.env +16 -0
- package/sample/multi/.env.example +17 -0
- package/sample/multi/README.md +54 -0
- package/sample/multi/apps/admin/app.config.js +24 -0
- package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
- package/sample/multi/apps/admin/public/js/admin.js +31 -0
- package/sample/multi/apps/admin/routes/pages.js +11 -0
- package/sample/multi/apps/admin/views/index.ejs +33 -0
- package/sample/multi/apps/web/app.config.js +30 -0
- package/sample/multi/apps/web/controllers/web-controller.js +45 -0
- package/sample/multi/apps/web/public/js/web.js +24 -0
- package/sample/multi/apps/web/routes/pages.js +13 -0
- package/sample/multi/apps/web/views/index.ejs +51 -0
- package/sample/multi/mega.config.js +42 -0
- package/sample/multi/package.json +20 -0
- package/sample/simple/package.json +2 -2
- package/src/adapters/nats-adapter.js +39 -44
- package/src/adapters/nats-codec.js +38 -0
- package/src/cli/commands/scaffold.js +1 -0
- package/src/cli/index.js +9 -1
- package/src/core/app-registry.js +69 -0
- package/src/core/boot.js +99 -0
- package/src/core/bus/cluster-bus.js +190 -0
- package/src/core/bus/contract.js +123 -0
- package/src/core/bus/index.js +285 -0
- package/src/core/bus/memory-bus.js +103 -0
- package/src/core/bus/nats-bus.js +203 -0
- package/src/core/config-validator.js +118 -1
- package/src/core/ctx-builder.js +14 -1
- package/src/core/index.js +2 -0
- package/src/core/lock/cluster-lock.js +174 -0
- package/src/core/lock/contract.js +123 -0
- package/src/core/lock/fifo-waitlist.js +93 -0
- package/src/core/lock/index.js +292 -0
- package/src/core/lock/memory-lock.js +162 -0
- package/src/core/lock/redis-lock.js +276 -0
- package/src/core/mega-app.js +29 -0
- package/src/core/migration/generate.js +1 -1
- package/src/core/migration/journal.js +1 -1
- package/src/core/scope-registry.js +9 -0
- package/src/eslint-plugin/no-direct-model-import.js +2 -2
- package/src/index.js +2 -0
- package/src/lib/mega-job-queue.js +71 -47
- package/types/adapters/mega-adapter.d.ts +1 -1
- package/types/adapters/nats-adapter.d.ts +4 -4
- package/types/adapters/nats-codec.d.ts +13 -0
- package/types/adapters/redlock-adapter.d.ts +1 -1
- package/types/core/app-registry.d.ts +22 -0
- package/types/core/bus/cluster-bus.d.ts +45 -0
- package/types/core/bus/contract.d.ts +164 -0
- package/types/core/bus/index.d.ts +100 -0
- package/types/core/bus/memory-bus.d.ts +45 -0
- package/types/core/bus/nats-bus.d.ts +41 -0
- package/types/core/index.d.ts +1 -0
- package/types/core/lock/cluster-lock.d.ts +44 -0
- package/types/core/lock/contract.d.ts +181 -0
- package/types/core/lock/fifo-waitlist.d.ts +38 -0
- package/types/core/lock/index.d.ts +96 -0
- package/types/core/lock/memory-lock.d.ts +58 -0
- package/types/core/lock/redis-lock.d.ts +43 -0
- package/types/core/mega-app.d.ts +10 -0
- package/types/core/scope-registry.d.ts +6 -0
- package/types/index.d.ts +1 -1
- package/types/lib/mega-job-queue.d.ts +27 -4
- package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ HTTP·WebSocket 을 동급 일급으로 다루고, 데이터 어댑터·잡/스
|
|
|
22
22
|
| **HTTP** | `MegaApp`(Fastify v5 래퍼) · `MegaServer`(멀티앱 vhost 라우팅) · 단일 시그니처 `router.http.<method>(path, handler, opts?)` · 라우트 자동 로드 · 자동 envelope(`{ok, data\|error, meta}`) · AJV 검증 에러 → `details` 자동 매핑 |
|
|
23
23
|
| **WebSocket** | `MegaWebSocketController`(타입 자동 디스패치) · Bridge↔Hub 12-타입 프로토콜 · presence · `broadcast`/`directToUser` fan-out · per-message deflate 압축 · embedded/별도 프로세스 hub |
|
|
24
24
|
| **데이터** | `MegaModel`(ORM 없음, native API + 트랜잭션) · 어댑터 베이스 트리(Db/Cache/Bus/Lock/Session) · 드라이버: Postgres·MariaDB·SQLite·MongoDB·Redis·NATS·File · `MegaRedlockAdapter`(분산 락) |
|
|
25
|
+
| **동시성(분산 락)** | `ctx.lock.with/.acquire/.tryAcquire`(ADR-226) · driver 자동폴백 redis>cluster>memory · FIFO·fence·watchdog 옵트인 |
|
|
26
|
+
| **메시지 버스** | `ctx.bus.emit/.on/.request/.with`(ADR-227) · driver 자동폴백 nats>cluster>memory · wildcards(`*`/`>`)·request/reply·persist(JetStream)·ordered 옵트인 |
|
|
25
27
|
| **운영(잡/스케줄)** | `MegaJobQueue`(NATS JetStream + 재시도 + DLQ) · `MegaJobWorker` · `MegaScheduler`(croner + 분산 중복방지) · `MegaWorker`(worker_threads CPU 풀) |
|
|
26
28
|
| **복원력** | `MegaRetry`(p-retry 지수 백오프) · `MegaCircuitBreaker`(opossum) · `MegaHealth`(`/health`·`/health/ready`) · `MegaShutdown`(LIFO graceful) |
|
|
27
29
|
| **관측성** | `MegaTracing`(OpenTelemetry 옵트인, HTTP/WS/어댑터/native query 자동 span, Zipkin) · `MegaMetrics`(Prometheus `/metrics`) |
|
|
@@ -128,14 +130,22 @@ docs/ 설계·ADR·운영 문서
|
|
|
128
130
|
|
|
129
131
|
## 의존성
|
|
130
132
|
|
|
131
|
-
- **런타임**: Node.js `>=20` (engines)
|
|
133
|
+
- **런타임**: Node.js `>=20.19.0` (engines — `mongodb` v7 요구)
|
|
132
134
|
- **HTTP/WS**: `fastify` v5 + `@fastify/*`(cookie/cors/csrf/helmet/multipart/rate-limit) · `ws`
|
|
133
|
-
- **데이터**: `pg` · `mariadb` · `mongodb` · `better-sqlite3` · `ioredis` ·
|
|
135
|
+
- **데이터**: `pg` · `mariadb` · `mongodb` · `better-sqlite3` · `ioredis` · `@nats-io/*`(transport-node/jetstream/nats-core, ADR-225) · `redlock`
|
|
134
136
|
- **복원력·운영**: `p-retry` · `opossum` · `croner`
|
|
135
|
-
- **관측성**: `@opentelemetry/*`(api/sdk-trace/sdk-metrics/exporter-zipkin/exporter-prometheus/exporter-otlp)
|
|
137
|
+
- **관측성**: `@opentelemetry/*`(api/sdk-trace/sdk-metrics/exporter-zipkin/exporter-prometheus/exporter-otlp) · `pino`
|
|
136
138
|
- **SSR·i18n·검증**: `ejs` + `ejs-mate` · `i18next` · `ajv`
|
|
137
139
|
|
|
138
|
-
> 모든 신규 의존성은
|
|
140
|
+
> 모든 신규 의존성은 ADR 로 1줄 기록(P8 — ADR-218+ 는 [docs/adr/](./docs/adr/)). 커스텀 구현은 Node 빌트인만 사용(scrypt/HMAC/cluster/worker_threads).
|
|
141
|
+
|
|
142
|
+
### `npm update` 호환
|
|
143
|
+
|
|
144
|
+
받은 직후 `npm update` 로 한 번에 최신화해도 동작하도록 검증한다(검증 매트릭스·정책 = [ADR-224](./docs/adr/0224-dependency-bulk-update-and-npm-update-compat.md)). semver range 는 `^`(caret) 유지로 patch/minor 를 자동 수용한다.
|
|
145
|
+
|
|
146
|
+
> **OTel 만 예외 — 부분 업데이트 금지**: experimental exporter(`exporter-prometheus`/`exporter-trace-otlp-http`, 0.21x)가 stable `@opentelemetry/core`·`sdk-*`(2.x)를 exact 핀한다. stable 만 올리면 `@opentelemetry/core` 가 중복 로드되어 trace context 전파가 깨진다. `@opentelemetry/*` 는 stable+experimental 을 **항상 함께** 올릴 것.
|
|
147
|
+
|
|
148
|
+
> **`@nats-io/*` 도 lockstep**: NATS v3 클라이언트는 `transport-node`·`jetstream`·`nats-core` 로 분리됐고 세 패키지는 같은 버전을 함께 써야 한다(`nats-core` 가 다른 둘의 공유 기반 — 버전이 어긋나면 타입/런타임 불일치). `@nats-io/*` 는 **항상 같은 버전으로 동시에** 올릴 것(ADR-225).
|
|
139
149
|
|
|
140
150
|
## 문서
|
|
141
151
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mega-framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
|
-
"node": ">=20.
|
|
7
|
+
"node": ">=20.19.0"
|
|
8
8
|
},
|
|
9
9
|
"main": "src/index.js",
|
|
10
10
|
"types": "types/index.d.ts",
|
|
@@ -84,17 +84,17 @@
|
|
|
84
84
|
"release": "bash scripts/publish.sh"
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
|
-
"@eslint/js": "^
|
|
87
|
+
"@eslint/js": "^10.0.1",
|
|
88
88
|
"@types/better-sqlite3": "^7.6.13",
|
|
89
89
|
"@types/node": "^25.9.1",
|
|
90
90
|
"@types/opossum": "^8.1.9",
|
|
91
91
|
"@types/pg": "^8.20.0",
|
|
92
92
|
"@vitest/coverage-v8": "^4.1.8",
|
|
93
|
-
"eslint": "^
|
|
93
|
+
"eslint": "^10.5.0",
|
|
94
94
|
"eslint-plugin-promise": "^7.3.0",
|
|
95
|
-
"globals": "^
|
|
95
|
+
"globals": "^17.6.0",
|
|
96
96
|
"prettier": "^3.0.0",
|
|
97
|
-
"typescript": "^
|
|
97
|
+
"typescript": "^6.0.3",
|
|
98
98
|
"vitest": "^4.1.8"
|
|
99
99
|
},
|
|
100
100
|
"license": "MIT",
|
|
@@ -102,22 +102,25 @@
|
|
|
102
102
|
"dependencies": {
|
|
103
103
|
"@fastify/cookie": "^11.0.2",
|
|
104
104
|
"@fastify/cors": "^11.2.0",
|
|
105
|
+
"@fastify/csrf-protection": "^8.0.0",
|
|
105
106
|
"@fastify/formbody": "^8.0.2",
|
|
106
|
-
"@fastify/csrf-protection": "^7.1.0",
|
|
107
107
|
"@fastify/helmet": "^13.0.2",
|
|
108
108
|
"@fastify/multipart": "^10.0.0",
|
|
109
|
-
"@fastify/rate-limit": "^
|
|
109
|
+
"@fastify/rate-limit": "^11.0.0",
|
|
110
110
|
"@fastify/static": "^9.1.3",
|
|
111
111
|
"@fastify/swagger": "^9.7.0",
|
|
112
|
-
"@fastify/swagger-ui": "^
|
|
112
|
+
"@fastify/swagger-ui": "^6.0.0",
|
|
113
|
+
"@nats-io/jetstream": "^3.4.0",
|
|
114
|
+
"@nats-io/nats-core": "^3.4.0",
|
|
115
|
+
"@nats-io/transport-node": "^3.4.0",
|
|
113
116
|
"@opentelemetry/api": "^1.9.1",
|
|
114
|
-
"@opentelemetry/core": "^2.
|
|
115
|
-
"@opentelemetry/exporter-prometheus": "^0.
|
|
116
|
-
"@opentelemetry/exporter-trace-otlp-http": "^0.
|
|
117
|
-
"@opentelemetry/exporter-zipkin": "^2.
|
|
118
|
-
"@opentelemetry/resources": "^2.
|
|
119
|
-
"@opentelemetry/sdk-metrics": "^2.
|
|
120
|
-
"@opentelemetry/sdk-trace-base": "^2.
|
|
117
|
+
"@opentelemetry/core": "^2.8.0",
|
|
118
|
+
"@opentelemetry/exporter-prometheus": "^0.219.0",
|
|
119
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
|
|
120
|
+
"@opentelemetry/exporter-zipkin": "^2.8.0",
|
|
121
|
+
"@opentelemetry/resources": "^2.8.0",
|
|
122
|
+
"@opentelemetry/sdk-metrics": "^2.8.0",
|
|
123
|
+
"@opentelemetry/sdk-trace-base": "^2.8.0",
|
|
121
124
|
"@opentelemetry/semantic-conventions": "^1.41.1",
|
|
122
125
|
"ajv": "^8.20.0",
|
|
123
126
|
"ajv-formats": "^3.0.1",
|
|
@@ -130,16 +133,15 @@
|
|
|
130
133
|
"i18next": "^26.3.1",
|
|
131
134
|
"ioredis": "^5.11.0",
|
|
132
135
|
"mariadb": "^3.5.2",
|
|
133
|
-
"mongodb": "^
|
|
134
|
-
"nats": "^2.29.3",
|
|
136
|
+
"mongodb": "^7.3.0",
|
|
135
137
|
"opossum": "^9.0.0",
|
|
136
138
|
"p-retry": "^8.0.0",
|
|
137
139
|
"pg": "^8.21.0",
|
|
138
|
-
"pino": "^
|
|
140
|
+
"pino": "^10.3.1",
|
|
139
141
|
"pino-pretty": "^13.1.3",
|
|
140
|
-
"pino-roll": "^
|
|
142
|
+
"pino-roll": "^4.0.0",
|
|
141
143
|
"prompts": "^2.4.2",
|
|
142
144
|
"redlock": "5.0.0-beta.2",
|
|
143
145
|
"ws": "^8.21.0"
|
|
144
146
|
}
|
|
145
|
-
}
|
|
147
|
+
}
|
package/sample/crud/.env
CHANGED
|
@@ -67,7 +67,11 @@ REDIS_DEMO_URL=redis://:dkTkqkfl12@localhost:6379/1
|
|
|
67
67
|
# 분산 락(redlock, ADR-113) — caches.lock(locks.main 이 빌려 씀). /demo/cron leader election.
|
|
68
68
|
REDIS_LOCK_URL=redis://:dkTkqkfl12@localhost:6379/3
|
|
69
69
|
# 범용 캐시(미사용) — 별도 캐시 어댑터가 필요하면 services.caches 에 추가해 참조.
|
|
70
|
-
|
|
70
|
+
REDIS_CACHE_URL=redis://:dkTkqkfl12@localhost:6379/4
|
|
71
|
+
|
|
72
|
+
# 분산 락 사용자 API(ADR-226, ctx.lock.with/.acquire) — driver 자동 폴백이라 **추가 .env 변수는 필요 없다**.
|
|
73
|
+
# redis 분산 락을 켜려면 mega.config.js 의 `lock` 블록 주석을 풀고 `cache: 'lock'`(위 REDIS_LOCK_URL 재사용)을
|
|
74
|
+
# 지정한다. 미설정 시 redis 가용→redis, 클러스터 워커→cluster, 그 외→memory(단일 프로세스, 부팅 경고).
|
|
71
75
|
|
|
72
76
|
|
|
73
77
|
# ── NATS 버스 (mega.config.js > services.buses) ──────────────────────────────
|
|
@@ -77,6 +81,10 @@ NATS_JOBS_URL=nats://localhost:4222
|
|
|
77
81
|
# 이벤트 버스(미사용) — pub/sub 등 두 번째 버스가 필요하면 services.buses 에 추가.
|
|
78
82
|
# NATS_EVENTS_URL=nats://localhost:4222
|
|
79
83
|
|
|
84
|
+
# 메시지 버스 사용자 API(ADR-227, ctx.bus.emit/.on/.request) — driver 자동 폴백이라 **추가 .env 변수는 필요 없다**.
|
|
85
|
+
# NATS 분산 버스를 켜려면 mega.config.js 의 `bus` 블록 주석을 풀고 `nats: 'jobs'`(위 NATS_JOBS_URL 재사용)을
|
|
86
|
+
# 지정한다. 미설정 시 NATS 가용→nats, 클러스터 워커→cluster, 그 외→memory(단일 프로세스, 부팅 경고).
|
|
87
|
+
|
|
80
88
|
|
|
81
89
|
# ── WS Hub: 클러스터 채팅 브릿지 (ADR-059/065/137/176) ────────────────────────
|
|
82
90
|
# crud 는 app.config.js 의 bridgeHub 로 /ws/chat 을 클러스터 전파한다. 허브는 `mega ws-hub`(scripts/
|
|
@@ -172,7 +180,7 @@ DEMO_UPLOAD_DIR=var/uploads
|
|
|
172
180
|
# DATABASE | DB → database / DBNAME → dbName(Mongo)
|
|
173
181
|
# POOL_<X> → pool.{camelCase(X)} (드라이버 공통, 값 자동 타입변환)
|
|
174
182
|
# OPTIONS_<X> → options.{드라이버 표기} (postgres/sqlite=snake, 그 외=camel; MS 대문자 보존)
|
|
175
|
-
# 공통 풀 인터페이스(min/max/idleTimeoutMs/acquireTimeoutMs/maxLifetimeMs) 예:
|
|
183
|
+
# 공통 풀 인터페이스(min/max/idleTimeoutMs/acquireTimeoutMs/maxLifetimeMs) 예:
|
|
176
184
|
# MEGA_PG_POOL_MIN=0
|
|
177
185
|
# MEGA_PG_POOL_MAX=10
|
|
178
186
|
# MEGA_PG_POOL_IDLE_TIMEOUT_MS=10000
|
package/sample/crud/.env.example
CHANGED
|
@@ -69,6 +69,10 @@ REDIS_LOCK_URL=redis://:change-me@localhost:6379/3
|
|
|
69
69
|
# 범용 캐시(미사용) — 별도 캐시 어댑터가 필요하면 services.caches 에 추가해 참조.
|
|
70
70
|
# REDIS_CACHE_URL=redis://:change-me@localhost:6379/4
|
|
71
71
|
|
|
72
|
+
# 분산 락 사용자 API(ADR-226, ctx.lock.with/.acquire) — driver 자동 폴백이라 **추가 .env 변수는 필요 없다**.
|
|
73
|
+
# redis 분산 락을 켜려면 mega.config.js 의 `lock` 블록 주석을 풀고 `cache: 'lock'`(위 REDIS_LOCK_URL 재사용)을
|
|
74
|
+
# 지정한다. 미설정 시 redis 가용→redis, 클러스터 워커→cluster, 그 외→memory(단일 프로세스, 부팅 경고).
|
|
75
|
+
|
|
72
76
|
|
|
73
77
|
# ── NATS 버스 (mega.config.js > services.buses) ──────────────────────────────
|
|
74
78
|
# 잡 큐(EmailJob JetStream, ADR-119) — buses.jobs.url. `mega worker` 가 소비, 웹이 enqueue.
|
|
@@ -77,6 +81,10 @@ NATS_JOBS_URL=nats://localhost:4222
|
|
|
77
81
|
# 이벤트 버스(미사용) — pub/sub 등 두 번째 버스가 필요하면 services.buses 에 추가.
|
|
78
82
|
# NATS_EVENTS_URL=nats://localhost:4222
|
|
79
83
|
|
|
84
|
+
# 메시지 버스 사용자 API(ADR-227, ctx.bus.emit/.on/.request) — driver 자동 폴백이라 **추가 .env 변수는 필요 없다**.
|
|
85
|
+
# NATS 분산 버스를 켜려면 mega.config.js 의 `bus` 블록 주석을 풀고 `nats: 'jobs'`(위 NATS_JOBS_URL 재사용)을
|
|
86
|
+
# 지정한다. 미설정 시 NATS 가용→nats, 클러스터 워커→cluster, 그 외→memory(단일 프로세스, 부팅 경고).
|
|
87
|
+
|
|
80
88
|
|
|
81
89
|
# ── WS Hub: 클러스터 채팅 브릿지 (ADR-059/065/137/176) ────────────────────────
|
|
82
90
|
# crud 는 app.config.js 의 bridgeHub 로 /ws/chat 을 클러스터 전파한다. 허브는 `mega ws-hub`(scripts/
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* BusController — /demo/bus 메시지 버스 데모 컨트롤러(ADR-074: 베이스 없음, 정적 메서드만, ADR-227 시연).
|
|
4
|
+
*
|
|
5
|
+
* `ctx.bus.emit/.on/.request` 를 그대로 쓴다(프레임워크 확장 X). 워커마다 첫 요청에서 한 번 `demo.>` 를 구독해
|
|
6
|
+
* (비영속 + 영속 둘 다) 수신 이벤트를 인메모리 링버퍼에 쌓고, `demo.echo` 응답자를 등록한다. 페이지는 폴링으로
|
|
7
|
+
* 버퍼를 가져와 fan-out·wildcards·persist·request/reply 를 워커 PID 와 함께 시각화한다.
|
|
8
|
+
*
|
|
9
|
+
* 핸들러 3번째 인자 `subject`(ADR-227)로 어떤 구체 subject 가 매칭됐는지 안다(`meta.subject` 도 동일). 발행자가
|
|
10
|
+
* 라벨링할 필요 없이 버스가 직접 알려준다 — wildcard 구독에서 특히 유용.
|
|
11
|
+
*/
|
|
12
|
+
import { currentUser } from '../middleware/web-auth.js'
|
|
13
|
+
|
|
14
|
+
/** 수신 이벤트 링버퍼(워커별 인메모리). @type {any[]} */
|
|
15
|
+
const eventBuf = []
|
|
16
|
+
const MAX_EVENTS = 60
|
|
17
|
+
let seq = 0
|
|
18
|
+
/** 워커별 1회 구독 가드. @type {boolean} */
|
|
19
|
+
let subscribed = false
|
|
20
|
+
|
|
21
|
+
/** 버퍼에 수신 이벤트 1건 기록. @param {any} payload @param {any} meta @param {boolean} persisted @param {string} subject @returns {void} */
|
|
22
|
+
function record(payload, meta, persisted, subject) {
|
|
23
|
+
eventBuf.push({
|
|
24
|
+
id: ++seq,
|
|
25
|
+
pid: process.pid,
|
|
26
|
+
subject: subject || '?',
|
|
27
|
+
emittedByPid: (meta && meta.emittedByPid) || null,
|
|
28
|
+
persisted,
|
|
29
|
+
payload,
|
|
30
|
+
at: new Date().toISOString(),
|
|
31
|
+
})
|
|
32
|
+
if (eventBuf.length > MAX_EVENTS) eventBuf.shift()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 이 워커에서 한 번만 demo 구독을 건다(멱등). 비영속/영속 `demo.>` 와 `demo.echo` 응답자.
|
|
37
|
+
* @param {any} ctx @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
async function ensureSubscribed(ctx) {
|
|
40
|
+
if (subscribed) return
|
|
41
|
+
subscribed = true
|
|
42
|
+
// 비영속 fan-out — 일반 emit 을 받는다. 3번째 인자 subject 로 매칭된 구체 subject 를 안다(ADR-227).
|
|
43
|
+
await ctx.bus.on('demo.>', (/** @type {any} */ payload, /** @type {any} */ meta, /** @type {any} */ subject) => record(payload, meta, false, subject))
|
|
44
|
+
// 영속 fan-out — persist:true emit 을 JetStream 에서 받는다(nats driver 만 영속, 그 외엔 비영속으로 폴백).
|
|
45
|
+
try {
|
|
46
|
+
await ctx.bus.on('demo.>', (/** @type {any} */ payload, /** @type {any} */ meta, /** @type {any} */ subject) => record(payload, meta, true, subject), { persist: true })
|
|
47
|
+
} catch (e) {
|
|
48
|
+
// 영속 구독 실패(드라이버 미지원/JetStream 비활성)는 데모 비치명 — 비영속만으로 계속.
|
|
49
|
+
ctx.log?.warn?.({ err: e }, 'bus demo: persistent subscribe unavailable')
|
|
50
|
+
}
|
|
51
|
+
// request/reply 응답자 — 반환값이 reply.
|
|
52
|
+
await ctx.bus.on('demo.echo', (/** @type {any} */ payload) => ({ echo: payload, pid: process.pid, at: new Date().toISOString() }))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class BusController {
|
|
56
|
+
/** GET /demo/bus — 데모 셸 렌더(구독 보장). @param {any} req @param {any} reply @param {any} ctx */
|
|
57
|
+
static async index(req, reply, ctx) {
|
|
58
|
+
await ensureSubscribed(ctx)
|
|
59
|
+
const stats = await BusController.#safeStats(ctx)
|
|
60
|
+
return ctx.render('bus/index', {
|
|
61
|
+
title: ctx.t('bus_title', { defaultValue: '메시지 버스 데모' }),
|
|
62
|
+
stats,
|
|
63
|
+
pid: process.pid,
|
|
64
|
+
currentUser: currentUser(req),
|
|
65
|
+
csrfToken: reply.generateCsrf(),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* POST /demo/bus/emit — fan-out 발행. subject·payload(JSON)·persist·ordered. meta 에 subject·워커 PID 를 실어 보낸다.
|
|
71
|
+
* @param {any} req @param {any} reply @param {any} ctx
|
|
72
|
+
*/
|
|
73
|
+
static async emit(req, reply, ctx) {
|
|
74
|
+
const body = req.body ?? {}
|
|
75
|
+
const subject = String(body.subject ?? 'demo.event')
|
|
76
|
+
const payload = body.payload ?? {}
|
|
77
|
+
await ctx.bus.emit(subject, payload, {
|
|
78
|
+
persist: body.persist === true,
|
|
79
|
+
ordered: body.ordered === true,
|
|
80
|
+
meta: { emittedByPid: process.pid }, // subject 는 버스가 수신측에 자동 제공(ADR-227) — 라벨링 불필요.
|
|
81
|
+
})
|
|
82
|
+
return { ok: true, pid: process.pid, subject, persist: body.persist === true }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* POST /demo/bus/request — req/reply. demo.echo 응답자가 첫 응답을 준다(어느 워커가 답했는지 reply.pid 로 보임).
|
|
87
|
+
* @param {any} req @param {any} reply @param {any} ctx
|
|
88
|
+
*/
|
|
89
|
+
static async request(req, reply, ctx) {
|
|
90
|
+
const body = req.body ?? {}
|
|
91
|
+
const subject = String(body.subject ?? 'demo.echo')
|
|
92
|
+
const timeout = body.timeout ?? 2000
|
|
93
|
+
try {
|
|
94
|
+
const answer = await ctx.bus.request(subject, body.payload ?? {}, { timeout })
|
|
95
|
+
return { ok: true, requestedByPid: process.pid, subject, reply: answer }
|
|
96
|
+
} catch (e) {
|
|
97
|
+
// no_responders / request_timeout 은 정상 데모 결과 — 코드와 함께 응답(throw 아님).
|
|
98
|
+
const code = /** @type {any} */ (e)?.code
|
|
99
|
+
if (code === 'bus.no_responders' || code === 'bus.request_timeout') {
|
|
100
|
+
return { ok: false, requestedByPid: process.pid, subject, error: code }
|
|
101
|
+
}
|
|
102
|
+
throw e
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** GET /demo/bus/events — 이 워커의 수신 버퍼 + driver(폴링용 JSON). @param {any} _req @param {any} _reply @param {any} ctx */
|
|
107
|
+
static async events(_req, _reply, ctx) {
|
|
108
|
+
await ensureSubscribed(ctx)
|
|
109
|
+
const stats = await BusController.#safeStats(ctx)
|
|
110
|
+
return { pid: process.pid, driver: stats ? stats.driver : '?', events: eventBuf.slice().reverse() }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** stats 안전 조회. @param {any} ctx @returns {Promise<any>} */
|
|
114
|
+
static async #safeStats(ctx) {
|
|
115
|
+
try {
|
|
116
|
+
return await ctx.bus.stats()
|
|
117
|
+
} catch (e) {
|
|
118
|
+
ctx.log?.warn?.({ err: e }, 'bus demo: stats failed')
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* LockController — /demo/lock 분산 락 데모 컨트롤러(ADR-074: 베이스 없음, 정적 메서드만, ADR-226 시연).
|
|
4
|
+
*
|
|
5
|
+
* `ctx.lock.with`(권장 — 자동 해제)와 `ctx.lock.tryAcquire`(즉시 1회)를 그대로 쓴다(프레임워크 확장 X). 두 탭에서
|
|
6
|
+
* 같은 key 로 동시에 "실행"하면 한 번에 하나만 임계구역에 들어가고(상호배제), `fifo` 면 도착 순서대로 깬다 —
|
|
7
|
+
* 응답의 워커 PID·대기 시간으로 분산 동작이 보인다. 최근 실행 로그는 워커별 모듈 링버퍼에 남긴다(상태 패널).
|
|
8
|
+
*
|
|
9
|
+
* 락 핸들을 요청 사이에 들고 있지 않는다(클러스터에서 acquire·release 가 다른 워커에 갈 수 있음) — `with` 가
|
|
10
|
+
* 한 요청 안에서 획득→대기(hold)→해제까지 자족적으로 끝내므로 워커 경계 문제가 없다.
|
|
11
|
+
*/
|
|
12
|
+
import { currentUser } from '../middleware/web-auth.js'
|
|
13
|
+
|
|
14
|
+
/** @param {number} ms @returns {Promise<void>} */
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
16
|
+
|
|
17
|
+
/** 최근 실행 로그(워커별 인메모리 링버퍼). 상태 패널에 역순으로 보여준다. @type {any[]} */
|
|
18
|
+
const runLog = []
|
|
19
|
+
const MAX_LOG = 25
|
|
20
|
+
|
|
21
|
+
/** 로그 1건 추가(상한 유지). @param {any} entry @returns {void} */
|
|
22
|
+
function pushLog(entry) {
|
|
23
|
+
runLog.push(entry)
|
|
24
|
+
if (runLog.length > MAX_LOG) runLog.shift()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 락 옵션 정규화(스키마가 타입은 검증 — 여기선 데모 기본값만). @param {any} body */
|
|
28
|
+
function readOpts(body) {
|
|
29
|
+
return {
|
|
30
|
+
key: String(body.key ?? 'demo:resource'),
|
|
31
|
+
ttl: body.ttl ?? 5000,
|
|
32
|
+
waitMs: body.waitMs ?? 3000,
|
|
33
|
+
holdMs: body.holdMs ?? 2000,
|
|
34
|
+
fifo: body.fifo === true,
|
|
35
|
+
fence: body.fence === true,
|
|
36
|
+
extendable: body.extendable === true,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class LockController {
|
|
41
|
+
/** GET /demo/lock — 데모 셸 + 현재 상태(stats) 렌더. @param {any} req @param {any} reply @param {any} ctx */
|
|
42
|
+
static async index(req, reply, ctx) {
|
|
43
|
+
const stats = await LockController.#safeStats(ctx)
|
|
44
|
+
return ctx.render('lock/index', {
|
|
45
|
+
title: ctx.t('lock_title', { defaultValue: '분산 락 데모' }),
|
|
46
|
+
stats,
|
|
47
|
+
pid: process.pid,
|
|
48
|
+
runLog: runLog.slice().reverse(),
|
|
49
|
+
currentUser: currentUser(req),
|
|
50
|
+
csrfToken: reply.generateCsrf(),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* POST /demo/lock/run — `ctx.lock.with` 로 임계구역을 잡고 holdMs 동안 보유 후 해제. 경합 시 waitMs 까지 대기.
|
|
56
|
+
* @param {any} req @param {any} reply @param {any} ctx
|
|
57
|
+
*/
|
|
58
|
+
static async run(req, reply, ctx) {
|
|
59
|
+
const o = readOpts(req.body ?? {})
|
|
60
|
+
const startedAt = Date.now()
|
|
61
|
+
try {
|
|
62
|
+
const inner = await ctx.lock.with(o.key, { ttl: o.ttl, waitMs: o.waitMs, fifo: o.fifo, fence: o.fence, extendable: o.extendable }, async (/** @type {any} */ lock) => {
|
|
63
|
+
const acquiredAt = Date.now()
|
|
64
|
+
await sleep(o.holdMs) // 임계구역 점유(다른 시도는 이만큼 대기).
|
|
65
|
+
return { acquiredAt, fence: lock.fence }
|
|
66
|
+
})
|
|
67
|
+
const entry = { ok: true, acquired: true, key: o.key, pid: process.pid, waitedMs: inner.acquiredAt - startedAt, heldMs: o.holdMs, fence: inner.fence ?? null, fifo: o.fifo, at: new Date().toISOString() }
|
|
68
|
+
pushLog(entry)
|
|
69
|
+
return entry
|
|
70
|
+
} catch (e) {
|
|
71
|
+
// 경합으로 못 잡음(lock.not_acquired, 409)은 **정상 데모 결과** — 그대로 응답한다(throw 아님).
|
|
72
|
+
if (/** @type {any} */ (e)?.code === 'lock.not_acquired') {
|
|
73
|
+
const entry = { ok: true, acquired: false, key: o.key, pid: process.pid, waitedMs: Date.now() - startedAt, reason: 'contended', fifo: o.fifo, at: new Date().toISOString() }
|
|
74
|
+
pushLog(entry)
|
|
75
|
+
return entry
|
|
76
|
+
}
|
|
77
|
+
throw e // 그 외(검증·드라이버 오류)는 표면화(500).
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* POST /demo/lock/try — `ctx.lock.tryAcquire`(즉시 1회). 잡으면 짧게 보유 후 해제, 못 잡으면 즉시 null.
|
|
83
|
+
* @param {any} req @param {any} reply @param {any} ctx
|
|
84
|
+
*/
|
|
85
|
+
static async tryRun(req, reply, ctx) {
|
|
86
|
+
const o = readOpts(req.body ?? {})
|
|
87
|
+
const lock = await ctx.lock.tryAcquire(o.key, { ttl: o.ttl, fence: o.fence })
|
|
88
|
+
if (!lock) {
|
|
89
|
+
const entry = { ok: true, acquired: false, key: o.key, pid: process.pid, reason: 'held', mode: 'try', at: new Date().toISOString() }
|
|
90
|
+
pushLog(entry)
|
|
91
|
+
return entry
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await sleep(Math.min(o.holdMs, 2000))
|
|
95
|
+
const entry = { ok: true, acquired: true, key: o.key, pid: process.pid, token: lock.token, fence: lock.fence ?? null, mode: 'try', at: new Date().toISOString() }
|
|
96
|
+
pushLog(entry)
|
|
97
|
+
return entry
|
|
98
|
+
} finally {
|
|
99
|
+
await lock.release()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** GET /demo/lock/status — 현재 보유/대기 수(stats) + 워커별 최근 실행 로그(JSON, 페이지 폴링용). @param {any} _req @param {any} _reply @param {any} ctx */
|
|
104
|
+
static async status(_req, _reply, ctx) {
|
|
105
|
+
return { stats: await LockController.#safeStats(ctx), pid: process.pid, runLog: runLog.slice().reverse() }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** stats 안전 조회 — 드라이버/연결 문제 시 null(페이지가 깨지지 않게). @param {any} ctx @returns {Promise<any>} */
|
|
109
|
+
static async #safeStats(ctx) {
|
|
110
|
+
try {
|
|
111
|
+
return await ctx.lock.stats()
|
|
112
|
+
} catch (e) {
|
|
113
|
+
ctx.log?.warn?.({ err: e }, 'lock demo: stats failed')
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -221,6 +221,8 @@
|
|
|
221
221
|
"ws_encrypted_note": "Messages are encrypted with ASP (AES-256-GCM) E: frames in transit.",
|
|
222
222
|
"nav_cron": "Scheduler (Cron)",
|
|
223
223
|
"nav_jobs": "Job Queue (NATS)",
|
|
224
|
+
"nav_lock": "Distributed Lock",
|
|
225
|
+
"nav_bus": "Message Bus",
|
|
224
226
|
"nav_worker": "Workers (Threads)",
|
|
225
227
|
"home_demo_cron": "Scheduler demo",
|
|
226
228
|
"home_demo_jobs": "Job queue demo",
|
|
@@ -329,5 +331,33 @@
|
|
|
329
331
|
"field_phone": "전화번호",
|
|
330
332
|
"field_phone_ph": "예: 010-1234-5678",
|
|
331
333
|
"col_username": "아이디",
|
|
332
|
-
"col_nickname": "별명"
|
|
334
|
+
"col_nickname": "별명",
|
|
335
|
+
"lock_title": "분산 락 데모",
|
|
336
|
+
"lock_subtitle": "ctx.lock.with / tryAcquire 로 임계구역을 보호합니다. 두 탭에서 같은 키로 동시에 실행하면 한 번에 하나만 들어가고(상호배제), FIFO 면 도착 순서대로 깨어납니다.",
|
|
337
|
+
"lock_run_title": "임계구역 실행",
|
|
338
|
+
"lock_run_desc": "키를 잡고 holdMs 동안 점유한 뒤 자동 해제합니다(ctx.lock.with). 다른 시도는 waitMs 까지 대기합니다.",
|
|
339
|
+
"lock_f_key": "자원 키",
|
|
340
|
+
"lock_f_fifo": "(도착 순서 보장)",
|
|
341
|
+
"lock_f_fence": "(단조 토큰)",
|
|
342
|
+
"lock_f_ext": "(watchdog 자동연장)",
|
|
343
|
+
"lock_btn_run": "실행 (with)",
|
|
344
|
+
"lock_btn_try": "tryAcquire",
|
|
345
|
+
"lock_status_title": "현재 상태",
|
|
346
|
+
"lock_active": "보유 중",
|
|
347
|
+
"lock_waiting": "대기 중",
|
|
348
|
+
"lock_worker_pid": "이 페이지를 처리한 워커 PID",
|
|
349
|
+
"lock_log_title": "최근 실행 (이 워커)",
|
|
350
|
+
"lock_log_result": "결과",
|
|
351
|
+
"bus_title": "메시지 버스 데모",
|
|
352
|
+
"bus_subtitle": "ctx.bus.emit / on / request 로 이벤트를 주고받습니다. 이 페이지는 demo.> 를 구독해 수신 이벤트를 실시간(폴링) 표시합니다 — 다른 워커가 발행해도 fan-out 으로 도착합니다.",
|
|
353
|
+
"bus_emit_title": "발행 (emit)",
|
|
354
|
+
"bus_emit_desc": "subject 와 payload(JSON)를 fan-out 발행합니다. persist:true 면 JetStream 에 저장됩니다.",
|
|
355
|
+
"bus_btn_emit": "Emit",
|
|
356
|
+
"bus_req_title": "요청/응답 (request)",
|
|
357
|
+
"bus_req_desc": "demo.echo 응답자가 첫 응답을 돌려줍니다 — 어느 워커가 답했는지 PID 로 보입니다.",
|
|
358
|
+
"bus_btn_request": "Request demo.echo",
|
|
359
|
+
"bus_recv_title": "수신 이벤트",
|
|
360
|
+
"bus_recv_pid": "구독 워커",
|
|
361
|
+
"bus_recv_desc": "서버가 demo.> 를 구독합니다 — * 는 한 토큰, > 는 꼬리 전체. order.created / order.created.eu / user.login 모두 demo.> 에 매칭됩니다. persist 이벤트는 배지로 구분됩니다.",
|
|
362
|
+
"bus_recv_pid_col": "수신 워커"
|
|
333
363
|
}
|
|
@@ -329,5 +329,35 @@
|
|
|
329
329
|
"field_phone": "전화번호",
|
|
330
330
|
"field_phone_ph": "예: 010-1234-5678",
|
|
331
331
|
"col_username": "아이디",
|
|
332
|
-
"col_nickname": "별명"
|
|
332
|
+
"col_nickname": "별명",
|
|
333
|
+
"nav_lock": "Distributed Lock",
|
|
334
|
+
"nav_bus": "Message Bus",
|
|
335
|
+
"lock_title": "분산 락 데모",
|
|
336
|
+
"lock_subtitle": "ctx.lock.with / tryAcquire 로 임계구역을 보호합니다. 두 탭에서 같은 키로 동시에 실행하면 한 번에 하나만 들어가고(상호배제), FIFO 면 도착 순서대로 깨어납니다.",
|
|
337
|
+
"lock_run_title": "임계구역 실행",
|
|
338
|
+
"lock_run_desc": "키를 잡고 holdMs 동안 점유한 뒤 자동 해제합니다(ctx.lock.with). 다른 시도는 waitMs 까지 대기합니다.",
|
|
339
|
+
"lock_f_key": "자원 키",
|
|
340
|
+
"lock_f_fifo": "(도착 순서 보장)",
|
|
341
|
+
"lock_f_fence": "(단조 토큰)",
|
|
342
|
+
"lock_f_ext": "(watchdog 자동연장)",
|
|
343
|
+
"lock_btn_run": "실행 (with)",
|
|
344
|
+
"lock_btn_try": "tryAcquire",
|
|
345
|
+
"lock_status_title": "현재 상태",
|
|
346
|
+
"lock_active": "보유 중",
|
|
347
|
+
"lock_waiting": "대기 중",
|
|
348
|
+
"lock_worker_pid": "이 페이지를 처리한 워커 PID",
|
|
349
|
+
"lock_log_title": "최근 실행 (이 워커)",
|
|
350
|
+
"lock_log_result": "결과",
|
|
351
|
+
"bus_title": "메시지 버스 데모",
|
|
352
|
+
"bus_subtitle": "ctx.bus.emit / on / request 로 이벤트를 주고받습니다. 이 페이지는 demo.> 를 구독해 수신 이벤트를 실시간(폴링) 표시합니다 — 다른 워커가 발행해도 fan-out 으로 도착합니다.",
|
|
353
|
+
"bus_emit_title": "발행 (emit)",
|
|
354
|
+
"bus_emit_desc": "subject 와 payload(JSON)를 fan-out 발행합니다. persist:true 면 JetStream 에 저장됩니다.",
|
|
355
|
+
"bus_btn_emit": "Emit",
|
|
356
|
+
"bus_req_title": "요청/응답 (request)",
|
|
357
|
+
"bus_req_desc": "demo.echo 응답자가 첫 응답을 돌려줍니다 — 어느 워커가 답했는지 PID 로 보입니다.",
|
|
358
|
+
"bus_btn_request": "Request demo.echo",
|
|
359
|
+
"bus_recv_title": "수신 이벤트",
|
|
360
|
+
"bus_recv_pid": "구독 워커",
|
|
361
|
+
"bus_recv_desc": "서버가 demo.> 를 구독합니다 — * 는 한 토큰, > 는 꼬리 전체. order.created / order.created.eu / user.login 모두 demo.> 에 매칭됩니다. persist 이벤트는 배지로 구분됩니다.",
|
|
362
|
+
"bus_recv_pid_col": "수신 워커"
|
|
333
363
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/*
|
|
3
|
+
* /demo/bus 클라이언트 — 메시지 버스 데모(ADR-227).
|
|
4
|
+
*
|
|
5
|
+
* - 발행/요청: 버튼이 /demo/bus/emit · /demo/bus/request 에 JSON POST(폼 아님 → CSRF 면제 + Origin 검증).
|
|
6
|
+
* - 수신: 2초마다 /demo/bus/events 를 폴링해 이 워커가 demo.> 로 받은 이벤트(subject·persist·수신 PID·payload)를
|
|
7
|
+
* 보여준다(탭 숨김 시 멈춤). 폴링 간격은 앱 rate limit(100/분, ADR-073) 안에 둔다.
|
|
8
|
+
*/
|
|
9
|
+
;(function () {
|
|
10
|
+
var $ = function (/** @type {string} */ id) {
|
|
11
|
+
return /** @type {any} */ (document.getElementById(id))
|
|
12
|
+
}
|
|
13
|
+
function esc(/** @type {any} */ s) {
|
|
14
|
+
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
|
|
15
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
function parseJson(/** @type {string} */ text, /** @type {any} */ fallback) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(text)
|
|
21
|
+
} catch {
|
|
22
|
+
return fallback
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function postJson(/** @type {string} */ url, /** @type {any} */ body) {
|
|
26
|
+
return fetch(url, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
}).then(function (res) {
|
|
31
|
+
return res.json().then(function (j) {
|
|
32
|
+
if (!res.ok) throw new Error((j && j.error && j.error.code) || 'http ' + res.status)
|
|
33
|
+
return j.data != null ? j.data : j
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── presets ──────────────────────────────────────────────────────────────
|
|
39
|
+
var presets = document.querySelectorAll('.bus-preset')
|
|
40
|
+
for (var i = 0; i < presets.length; i++) {
|
|
41
|
+
presets[i].addEventListener('click', function (/** @type {any} */ ev) {
|
|
42
|
+
$('bus-subject').value = ev.target.getAttribute('data-s')
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── emit ─────────────────────────────────────────────────────────────────
|
|
47
|
+
$('bus-emit').addEventListener('click', function () {
|
|
48
|
+
var payload = parseJson($('bus-payload').value, null)
|
|
49
|
+
if (payload === null || typeof payload !== 'object') {
|
|
50
|
+
$('bus-emit-result').innerHTML = '<div class="alert alert-danger py-1 px-2 mb-0">payload 가 JSON 객체가 아닙니다.</div>'
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
postJson('/demo/bus/emit', {
|
|
54
|
+
subject: $('bus-subject').value,
|
|
55
|
+
payload: payload,
|
|
56
|
+
persist: $('bus-persist').checked,
|
|
57
|
+
ordered: $('bus-ordered').checked,
|
|
58
|
+
})
|
|
59
|
+
.then(function (d) {
|
|
60
|
+
$('bus-emit-result').innerHTML =
|
|
61
|
+
'<div class="alert alert-success py-1 px-2 mb-0">발행됨 · <code>' + esc(d.subject) + '</code>' + (d.persist ? ' · <span class="badge text-bg-info">persist</span>' : '') + ' · PID <code>' + esc(d.pid) + '</code></div>'
|
|
62
|
+
})
|
|
63
|
+
.catch(function (e) {
|
|
64
|
+
$('bus-emit-result').innerHTML = '<div class="alert alert-danger py-1 px-2 mb-0">오류: ' + esc(e.message) + '</div>'
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// ── request ──────────────────────────────────────────────────────────────
|
|
69
|
+
$('bus-request').addEventListener('click', function () {
|
|
70
|
+
var payload = parseJson($('bus-req-payload').value, {})
|
|
71
|
+
$('bus-req-result').innerHTML = '<div class="text-body-secondary">요청 중…</div>'
|
|
72
|
+
postJson('/demo/bus/request', { subject: 'demo.echo', payload: payload, timeout: 2000 })
|
|
73
|
+
.then(function (d) {
|
|
74
|
+
if (!d.ok) {
|
|
75
|
+
$('bus-req-result').innerHTML = '<div class="alert alert-warning py-1 px-2 mb-0">' + esc(d.error) + '</div>'
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
$('bus-req-result').innerHTML =
|
|
79
|
+
'<div class="alert alert-success py-1 px-2 mb-0">응답 받음 · 답한 워커 PID <code>' + esc(d.reply && d.reply.pid) + '</code><br><code>' + esc(JSON.stringify(d.reply && d.reply.echo)) + '</code></div>'
|
|
80
|
+
})
|
|
81
|
+
.catch(function (e) {
|
|
82
|
+
$('bus-req-result').innerHTML = '<div class="alert alert-danger py-1 px-2 mb-0">오류: ' + esc(e.message) + '</div>'
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// ── 수신 폴링 ─────────────────────────────────────────────────────────────
|
|
87
|
+
var POLL_MS = 2000
|
|
88
|
+
function renderEvents(/** @type {any[]} */ rows) {
|
|
89
|
+
$('bus-events').innerHTML = (rows || [])
|
|
90
|
+
.map(function (r) {
|
|
91
|
+
var badge = r.persisted ? '<span class="badge text-bg-info">persist</span>' : '<span class="text-body-secondary">core</span>'
|
|
92
|
+
return (
|
|
93
|
+
'<tr><td class="text-body-secondary">' +
|
|
94
|
+
esc((r.at || '').slice(11, 19)) +
|
|
95
|
+
'</td><td><code>' +
|
|
96
|
+
esc(r.subject) +
|
|
97
|
+
'</code></td><td>' +
|
|
98
|
+
badge +
|
|
99
|
+
'</td><td>' +
|
|
100
|
+
esc(r.pid) +
|
|
101
|
+
'</td><td><code class="small">' +
|
|
102
|
+
esc(JSON.stringify(r.payload)) +
|
|
103
|
+
'</code></td></tr>'
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
.join('')
|
|
107
|
+
}
|
|
108
|
+
function poll() {
|
|
109
|
+
if (document.hidden) {
|
|
110
|
+
setTimeout(poll, POLL_MS)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
fetch('/demo/bus/events', { headers: { accept: 'application/json' } })
|
|
114
|
+
.then(function (res) {
|
|
115
|
+
return res.json()
|
|
116
|
+
})
|
|
117
|
+
.then(function (j) {
|
|
118
|
+
var d = j.data != null ? j.data : j
|
|
119
|
+
if (d.driver) $('bus-driver').textContent = d.driver
|
|
120
|
+
if (d.pid != null) $('bus-pid').textContent = String(d.pid)
|
|
121
|
+
renderEvents(d.events)
|
|
122
|
+
})
|
|
123
|
+
.catch(function () {
|
|
124
|
+
/* 비치명적 — 다음 주기에 재시도 */
|
|
125
|
+
})
|
|
126
|
+
.then(function () {
|
|
127
|
+
setTimeout(poll, POLL_MS)
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
poll()
|
|
131
|
+
})()
|