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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# 분산 락 · 메시지 버스
|
|
2
|
+
|
|
3
|
+
여러 인스턴스(클러스터 워커·여러 노드)가 같은 자원을 동시에 건드리거나, 모듈끼리 이벤트로 느슨하게
|
|
4
|
+
결합해야 할 때 쓰는 두 사용자 API다. 둘 다 **별명 선언 없이** `ctx.lock` / `ctx.bus` 로 바로 쓰고, 환경에
|
|
5
|
+
맞춰 driver 가 자동으로 골라진다(redis/nats 있으면 진짜 분산, 없으면 cluster, 그것도 없으면 단일 프로세스).
|
|
6
|
+
|
|
7
|
+
- 실제 동작: `/demo/lock`(분산 락) · `/demo/bus`(메시지 버스) — 두 탭을 띄워 워커 PID·순서·fan-out 을 눈으로 본다.
|
|
8
|
+
- 정본: 분산 락 = ADR-226, 메시지 버스 = ADR-227.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. 분산 락 — `ctx.lock`
|
|
13
|
+
|
|
14
|
+
### 언제 쓰나
|
|
15
|
+
|
|
16
|
+
- **race condition 방지**: 재고 차감, 좌석 예약 등 "읽고-고치고-쓰기"를 한 번에 하나만.
|
|
17
|
+
- **단일 실행 보장**: 클러스터에서 정기 작업을 한 인스턴스만(스케줄러 leader election — 단, 스케줄 중복방지는
|
|
18
|
+
`static lock`(ADR-118)이 따로 있다).
|
|
19
|
+
- **외부 호출 직렬화**: 같은 리소스에 대한 외부 API 호출이 겹치지 않게.
|
|
20
|
+
|
|
21
|
+
### API — `with` 권장
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
// 권장 — 잠그고, 끝나면(성공/예외 무관) 자동 해제
|
|
25
|
+
await ctx.lock.with('stock:42', { ttl: 5000 }, async (lock) => {
|
|
26
|
+
const row = await stock.byId(42)
|
|
27
|
+
await stock.update(42, { qty: row.qty - 1 }) // 이 블록은 노드를 넘어 한 번에 하나만 실행
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// 수동 — try/finally
|
|
31
|
+
const lock = await ctx.lock.acquire('order:99', { ttl: 5000, waitMs: 1000 })
|
|
32
|
+
try { /* ... */ } finally { await lock.release() }
|
|
33
|
+
|
|
34
|
+
// 즉시 1회(대기 없음) — 못 잡으면 null
|
|
35
|
+
const got = await ctx.lock.tryAcquire('job:nightly')
|
|
36
|
+
if (got) { try { /* ... */ } finally { await got.release() } }
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 옵션
|
|
40
|
+
|
|
41
|
+
| 옵션 | 뜻 |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `ttl` | 락 보유 시간(ms). 이 시간이 지나면 자동 만료(보유자 crash 안전망). |
|
|
44
|
+
| `waitMs` | 경합 시 획득 대기 상한(ms). `0` = 즉시 실패(tryAcquire 와 동일). |
|
|
45
|
+
| `fifo` | `true` 면 대기자를 **도착 순서**대로 깨운다(기아 방지). |
|
|
46
|
+
| `fence` | `true` 면 단조 증가 토큰(`lock.fence`)을 함께 준다 — 외부 시스템이 stale-writer 를 거를 때. |
|
|
47
|
+
| `extendable` | `true` 면 watchdog 이 ttl 절반마다 자동 연장(긴 작업). |
|
|
48
|
+
|
|
49
|
+
`acquire`/`with` 는 못 잡으면 `lock.not_acquired`(409) throw, `tryAcquire` 는 `null`.
|
|
50
|
+
|
|
51
|
+
### driver 자동 폴백 + config
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
// mega.config.js (전역)
|
|
55
|
+
lock: { driver: 'auto', cache: 'lock', ttl: 5000, waitMs: 1000, fifo: false }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| driver | 보장 범위 | 전제 |
|
|
59
|
+
| --- | --- | --- |
|
|
60
|
+
| `redis` | 멀티 프로세스·멀티 노드(진짜 분산) | `lock.cache` = redis 캐시 별명 |
|
|
61
|
+
| `cluster` | 같은 노드 워커 간 | `mega start --cluster=N` 워커 |
|
|
62
|
+
| `memory` | 단일 프로세스만 | 없음(개발용) |
|
|
63
|
+
|
|
64
|
+
`auto`(기본)는 redis 가용 시 redis, 클러스터 워커면 cluster, 그 외 memory + 부팅 경고. 명시 driver 의 전제가
|
|
65
|
+
없으면 부팅 fail-fast. 이 데모(crud)는 `cache: 'lock'`(기존 `caches.lock`)로 redis 분산 락을 켜 둔다.
|
|
66
|
+
|
|
67
|
+
### 함정
|
|
68
|
+
|
|
69
|
+
- **ttl 을 임계구역보다 짧게 잡으면** 작업 도중 락이 만료돼 다른 인스턴스가 들어온다 → ttl 을 넉넉히, 긴
|
|
70
|
+
작업은 `extendable: true`(watchdog).
|
|
71
|
+
- 외부 시스템(DB·파일)에 쓰기를 보호할 땐, 락 만료와 쓰기 사이의 경합을 막으려 **fence 토큰**을 외부에 같이
|
|
72
|
+
넘겨 stale-writer 를 거르는 패턴을 권장한다.
|
|
73
|
+
- `ctx.lock(alias)`(설정형 redlock, ADR-113)와 `ctx.lock.with(...)`(자동폴백)는 **별개**다. TTL 단위는 둘 다 ms.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 2. 메시지 버스 — `ctx.bus`
|
|
78
|
+
|
|
79
|
+
### 언제 쓰나
|
|
80
|
+
|
|
81
|
+
- **도메인 이벤트**: "주문 생성됨" → 이메일·분석·재고가 각각 반응(발행자는 구독자를 모름).
|
|
82
|
+
- **fan-out**: 한 이벤트를 여러 구독자가 동시에 받는다.
|
|
83
|
+
- **느슨한 결합**: 모듈이 서로 직접 호출하지 않고 이벤트로만 연결.
|
|
84
|
+
|
|
85
|
+
### API
|
|
86
|
+
|
|
87
|
+
핸들러 시그니처는 **`(payload, meta, subject)`** 다 — 3번째 `subject` 는 메시지가 도착한 **구체** subject
|
|
88
|
+
(wildcard 가 아닌 실제 발행 subject)다. `meta.subject` 에도 같은 값이 담긴다(정본 위치). 기존 `(payload, meta)`
|
|
89
|
+
2-인자 핸들러는 그대로 동작한다(3번째 인자를 안 받아도 무방 — 호환).
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
// fan-out — 구독자 전원 수신
|
|
93
|
+
ctx.bus.emit('order.created', { orderId: 42 }, { meta: { traceId } })
|
|
94
|
+
ctx.bus.on('order.created', async (payload, meta) => { await email.sendReceipt(payload.orderId) })
|
|
95
|
+
|
|
96
|
+
// request/reply — 핸들러 '반환값'이 응답
|
|
97
|
+
ctx.bus.on('catalog.product', async ({ id }) => ({ id, name: 'widget' }))
|
|
98
|
+
const product = await ctx.bus.request('catalog.product', { id: 1 }, { timeout: 1000 })
|
|
99
|
+
|
|
100
|
+
// 구독 해제 / 요청 단위 meta 바인딩
|
|
101
|
+
const sub = await ctx.bus.on('topic', handler); await sub.unsubscribe() // 또는 ctx.bus.off('topic', handler)
|
|
102
|
+
const scoped = ctx.bus.with({ traceId }); await scoped.emit('x', payload) // 이후 emit/request 에 meta 자동 병합
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Wildcards (NATS 식)
|
|
106
|
+
|
|
107
|
+
구독 subject 에 wildcard 를 쓴다(발행 subject 는 구체적이어야 한다):
|
|
108
|
+
|
|
109
|
+
- `*` = **정확히 한 토큰**. `order.*` → `order.created`(O), `order.created.eu`(X)
|
|
110
|
+
- `>` = **한 토큰 이상(꼬리 전체)**, 맨 끝에만. `order.>` → `order.created`, `order.created.eu`(둘 다 O)
|
|
111
|
+
|
|
112
|
+
wildcard 로 구독하면 **3번째 인자 `subject`** 로 어느 구체 subject 가 매칭됐는지 안다:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
ctx.bus.on('order.>', async (payload, meta, subject) => {
|
|
116
|
+
// subject === 'order.created' | 'order.created.eu' | ... (실제 발행 subject)
|
|
117
|
+
if (subject === 'order.created') await onCreate(payload)
|
|
118
|
+
else if (subject.endsWith('.eu')) await onEu(payload)
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 옵션
|
|
123
|
+
|
|
124
|
+
| 옵션 | 뜻 |
|
|
125
|
+
| --- | --- |
|
|
126
|
+
| `persist`(emit/on) | `true` 면 JetStream 영속(저장 + 재전달). **nats driver 만**; 그 외엔 경고 후 비영속. |
|
|
127
|
+
| `ordered`(on) | `true` 면 같은 구독을 도착 순서대로 1건씩 직렬 처리. |
|
|
128
|
+
| `timeout`(request) | 응답 대기 상한(ms). 초과 시 `bus.request_timeout`, 응답자 없으면 `bus.no_responders`. |
|
|
129
|
+
|
|
130
|
+
한 구독자가 throw 해도 다른 구독자·다음 메시지엔 영향 없다(매니저가 잡아 로깅 — fan-out 격리).
|
|
131
|
+
|
|
132
|
+
### driver 자동 폴백 + config
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
// mega.config.js (전역)
|
|
136
|
+
bus: { driver: 'auto', nats: 'jobs', prefix: 'app.', defaultPersist: false, requestTimeoutMs: 5000 }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
| driver | 보장 범위 | persist | 전제 |
|
|
140
|
+
| --- | --- | --- | --- |
|
|
141
|
+
| `nats` | 멀티 프로세스·멀티 노드 | O(JetStream) | `bus.nats` = NATS 버스 별명 |
|
|
142
|
+
| `cluster` | 같은 노드 워커 간 | X(경고 후 비영속) | `mega start --cluster=N` 워커 |
|
|
143
|
+
| `memory` | 단일 프로세스만 | X | 없음(개발용) |
|
|
144
|
+
|
|
145
|
+
`prefix`(예: `app.`)는 모든 subject 에 자동으로 붙어 네임스페이스를 격리한다. nats driver 는 기존 잡 큐
|
|
146
|
+
(ADR-119)·클러스터 broadcast(ADR-176)와 **같은 NATS 연결**을 빌려 쓴다(새 연결 X). 이 데모는 `nats: 'jobs'`로
|
|
147
|
+
NATS 버스를 켜 둔다.
|
|
148
|
+
|
|
149
|
+
### 함정
|
|
150
|
+
|
|
151
|
+
- **구독하기 전에 발행된 이벤트는 비영속(core)에선 유실된다.** 늦게 붙은 구독자도 받아야 하면 `persist: true`
|
|
152
|
+
(JetStream 이 저장했다가 전달) — 단 nats driver 필요.
|
|
153
|
+
- subject 는 `.` 으로 나뉜 토큰열이고 공백/wildcard 규칙이 있다(NATS 제약). 발행 subject 에 `*`/`>` 는 금지.
|
|
154
|
+
- `ctx.bus(alias)`(설정형 NATS 어댑터, ADR-110)와 `ctx.bus.emit(...)`(자동폴백)은 **별개** 하위시스템이다.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## 3. ctx 없는 곳에서 — `getApp()` (ADR-228)
|
|
159
|
+
|
|
160
|
+
`ctx.lock`/`ctx.bus` 는 요청 ctx 에만 있다. 요청 흐름 **밖**(백그라운드 타이머·외부 SDK 콜백·테스트)에서는
|
|
161
|
+
`getApp()` 으로 booted 앱을 잡아 **같은 표면**으로 쓴다. lock/bus manager 는 process 싱글톤이라 `app.lock` 은
|
|
162
|
+
`ctx.lock` 과 같은 객체다.
|
|
163
|
+
|
|
164
|
+
```js
|
|
165
|
+
import { getApp } from 'mega-framework'
|
|
166
|
+
|
|
167
|
+
// 백그라운드 하트비트 — 요청 ctx 가 없는 setInterval 콜백
|
|
168
|
+
setInterval(async () => {
|
|
169
|
+
const app = getApp()
|
|
170
|
+
await app.bus.emit('heartbeat', { ts: Date.now() })
|
|
171
|
+
}, 60_000)
|
|
172
|
+
|
|
173
|
+
// 외부 SDK 콜백에서 분산 락
|
|
174
|
+
stripe.webhooks.on('charge.succeeded', async (event) => {
|
|
175
|
+
await getApp().lock.with(`charge:${event.id}`, { ttl: 5000 }, async () => { /* 멱등 처리 */ })
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
- **표면**: `app.lock` / `app.bus`(ctx 와 동일 API) · `app.db(alias)` / `app.cache(alias)` · `app.log` / `app.name`.
|
|
180
|
+
요청-스코프(`req`/`user`/`session`/요청 locale `t`)는 **없다** — 그건 ctx 전용이다.
|
|
181
|
+
- **부팅 전 호출은 fail-fast**: 아직 부팅이 안 끝났으면 `app.not_initialized` throw(타이밍 버그를 조용히 넘기지
|
|
182
|
+
않음). `afterBoot` hook 이후·listen 이후의 백그라운드에서 호출한다. 부팅 여부만 보려면 `hasApp()`.
|
|
183
|
+
- **멀티앱 프로세스**: 앱이 둘 이상이면 `getApp('<name>')` 로 이름을 준다(생략 시 `app.ambiguous`).
|
|
184
|
+
- **권장**: ctx 가 **있는** 곳(라우트·서비스·잡/스케줄 `run(ctx)`)은 여전히 `ctx.lock`/`ctx.bus` 를 쓴다 — 요청
|
|
185
|
+
로거·user·session 이 함께 묶여 추적이 쉽다. `getApp()` 은 ctx 가 **없을 때만**.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 4. 앱별 lock/bus 분리 — 멀티앱 (ADR-229)
|
|
190
|
+
|
|
191
|
+
한 프로세스에 여러 앱을 vhost 로 띄울 때(`apps/<name>/`), 앱마다 **다른** lock/bus 를 쓸 수 있다 — `admin` 은
|
|
192
|
+
격리된 NATS·redis, `public` 은 공용처럼. `lock`/`bus` 는 글로벌(`mega.config.js`)·앱(`apps/<name>/app.config.js`)
|
|
193
|
+
**양쪽**에 둘 수 있고(dual-scope), 우선순위는 **앱별 > 글로벌 > 기본값**(앱이 정의하면 글로벌과 합쳐 그 앱 전용
|
|
194
|
+
manager, 미정의면 글로벌 그대로).
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
// mega.config.js (글로벌 = fallback)
|
|
198
|
+
lock: { cache: 'lockMain' },
|
|
199
|
+
bus: { nats: 'natsMain', prefix: 'app.' },
|
|
200
|
+
|
|
201
|
+
// apps/admin/app.config.js — admin 전용 backend
|
|
202
|
+
lock: { cache: 'lockAdmin' }, // admin 만 다른 redis
|
|
203
|
+
bus: { nats: 'natsAdmin', prefix: 'admin.' },
|
|
204
|
+
|
|
205
|
+
// apps/public/app.config.js — bus 만 prefix 분리(같은 NATS, 가벼운 격리)
|
|
206
|
+
bus: { prefix: 'public.' },
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- `ctx.lock`/`ctx.bus`(요청은 vhost 로 앱에 라우팅됨)와 `getApp('admin').lock`/`bus`(ctx 없는 영역, ADR-228)는
|
|
210
|
+
**그 앱의 manager** 를 가리킨다.
|
|
211
|
+
- `lock.cache`/`bus.nats` 는 앱 config 에서도 **글로벌 `services`** 의 키를 참조한다(services 는 글로벌 정의).
|
|
212
|
+
- **한계**: 같은 프로세스라 메모리·CPU·이벤트루프는 공유된다. 강한 격리(다른 스케일·장애 격리)가 필요하면
|
|
213
|
+
**process-per-app + mega-bus(NATS)** 로 프로세스 간 통신하는 쪽이 정답이다.
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## 데모로 확인하기
|
|
218
|
+
|
|
219
|
+
- `/demo/lock` — 키·ttl·waitMs·holdMs 와 fifo/fence/extendable 토글로 임계구역을 실행한다. **두 탭에서 같은
|
|
220
|
+
키로 동시에 "실행"** 하면 한 쪽은 즉시, 다른 쪽은 대기 후 실행되고(상호배제), fifo 면 도착 순서가 보인다.
|
|
221
|
+
상태 패널의 보유/대기 수와 워커 PID 로 분산 동작이 드러난다.
|
|
222
|
+
- `/demo/bus` — subject·payload 를 발행하면 페이지가 `demo.>` 구독으로 받아 표시한다. **여러 워커가 받는
|
|
223
|
+
fan-out**, `order.created`/`order.created.eu`/`user.login` 의 wildcard 매칭, `demo.echo` request/reply(어느
|
|
224
|
+
워커가 답했는지 PID), persist 배지로 영속 이벤트를 구분해 본다.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# 멀티앱 — 한 프로세스 vhost
|
|
2
|
+
|
|
3
|
+
한 프로세스에 **여러 앱**을 띄우고 `Host` 헤더로 라우팅하는 vhost 모델(ADR-063)과, 앱별 lock/bus 분리
|
|
4
|
+
(ADR-229)·cross-app 호출(ADR-228)을 설명합니다. 실동작은 `sample/multi`(web=a.com, admin=b.com)에서 봅니다.
|
|
5
|
+
|
|
6
|
+
## vhost 모델 — 한 포트, Host 라우팅
|
|
7
|
+
|
|
8
|
+
`mega.config.js` 의 `apps` 배열에 앱 폴더 이름을 나열하면, 각 `apps/<name>/app.config.js` 의 `hosts` 로 도메인이
|
|
9
|
+
매핑됩니다. boot 는 앱마다 **별도 Fastify 인스턴스**를 만들고 하나의 서버에 vhost 마운트해 **한 포트로 한 번
|
|
10
|
+
listen** 합니다. 매 요청은 `Host` 헤더의 hostname 으로 해당 앱에 라우팅됩니다.
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
// mega.config.js
|
|
14
|
+
export default { apps: ['web', 'admin'], server: { port: 3000 }, /* ... */ }
|
|
15
|
+
|
|
16
|
+
// apps/web/app.config.js → hosts: ['a.com']
|
|
17
|
+
// apps/admin/app.config.js → hosts: ['b.com']
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- **host_collision**: 같은 host 를 두 앱에 매핑하면 부팅 시 `config.host_collision` 로 **fail-fast**(ADR-067) —
|
|
21
|
+
요청이 어느 앱으로 갈지 모호해지는 것을 부팅에서 막습니다.
|
|
22
|
+
|
|
23
|
+
## 앱별 lock/bus 분리 (ADR-229)
|
|
24
|
+
|
|
25
|
+
`lock`/`bus` 는 글로벌(`mega.config.js`)·앱(`apps/<name>/app.config.js`) 양쪽에 둘 수 있습니다(dual-scope). 앱이
|
|
26
|
+
정의하면 글로벌과 합쳐 **그 앱 전용 manager**, 미정의면 글로벌 fallback. `sample/multi` 예:
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
// mega.config.js — 글로벌(fallback): bus는 NATS(glob.), lock은 생략 → memory
|
|
30
|
+
bus: { nats: 'events', prefix: 'glob.' },
|
|
31
|
+
|
|
32
|
+
// apps/web/app.config.js — 앱 전용: redis 락 + web. 버스
|
|
33
|
+
lock: { cache: 'webLock' }, // admin 의 memory 락과 다른 backend
|
|
34
|
+
bus: { nats: 'events', prefix: 'web.' },
|
|
35
|
+
|
|
36
|
+
// apps/admin/app.config.js — lock/bus 미정의 → 글로벌(memory 락 + glob. 버스)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
확인:
|
|
40
|
+
|
|
41
|
+
- `a.com/whoami` → `lock: redis`, `b.com/whoami` → `lock: memory` (앱별 backend 분리).
|
|
42
|
+
- web 의 `web.` 발행은 admin(`glob.`)이 **못 받음** (prefix 격리, 같은 NATS 라도 subject 공간이 갈림).
|
|
43
|
+
- 같은 키여도 web(redis)·admin(memory)은 다른 manager 라 **서로 격리**됩니다.
|
|
44
|
+
|
|
45
|
+
## cross-app 호출 (`getApp`, ADR-228)
|
|
46
|
+
|
|
47
|
+
ctx 가 없거나 다른 앱의 자원이 필요하면 `getApp('<name>')` 로 잡습니다. 멀티앱이면 **이름 필수**(생략 시
|
|
48
|
+
`app.ambiguous`, 없는 이름은 `app.not_found`, 부팅 전은 `app.not_initialized` — 전부 fail-fast).
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { getApp } from 'mega-framework'
|
|
52
|
+
// web 의 컨트롤러에서 admin 의 (글로벌) 버스로 직접 발행 → b.com/received 에 도착
|
|
53
|
+
await getApp('admin').bus.emit('notice', { from: 'web' })
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`getApp('admin').lock`/`bus`/`db(alias)`/`cache(alias)`/`log` 모두 **그 앱의** manager·자원을 가리킵니다.
|
|
57
|
+
|
|
58
|
+
## 언제 멀티앱(vhost) vs process-per-app
|
|
59
|
+
|
|
60
|
+
| 기준 | 멀티앱(vhost) | process-per-app |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| 자원 공유 | 메모리/CPU/이벤트루프 공유 | 완전 분리 |
|
|
63
|
+
| 배포·스케일 | 함께(한 프로세스) | 독립 스케일·독립 배포 |
|
|
64
|
+
| 장애 격리 | 약함(한 프로세스 죽으면 전부) | 강함 |
|
|
65
|
+
| 통신 | 같은 프로세스(`getApp`) | mega-bus(NATS) cross-process |
|
|
66
|
+
| 적합 | admin+public 처럼 묶여 다니는 앱, 같은 도메인 그룹 | 독립 서비스, 다른 스케일/SLA |
|
|
67
|
+
|
|
68
|
+
**강한 격리**(다른 스케일·장애 격리·다른 backend 전용)가 필요하면 process-per-app + mega-bus(NATS)로 프로세스 간
|
|
69
|
+
통신하세요. 멀티앱은 "같은 프로세스 안에서 논리 분리(다른 host·다른 lock/bus backend·prefix)"까지가 적정선입니다.
|
|
70
|
+
|
|
71
|
+
## 실행 — `sample/multi`
|
|
72
|
+
|
|
73
|
+
`sample/multi/README.md` 참고. `/etc/hosts` 에 `a.com`/`b.com` 을 `127.0.0.1` 로 매핑하고 redis·nats 를 띄운 뒤
|
|
74
|
+
`npm run dev`, `http://a.com:3200`(web)·`http://b.com:3200`(admin) 으로 접속합니다.
|
|
@@ -111,6 +111,38 @@ export default {
|
|
|
111
111
|
},
|
|
112
112
|
},
|
|
113
113
|
|
|
114
|
+
// ── 분산 락 사용자 API(ADR-226) — `ctx.lock.with/.acquire/.tryAcquire` ─────────────────────────
|
|
115
|
+
// 위 services.locks(설정형 redlock, `ctx.lock('main')`)와 **별개 하위시스템**이다. 이쪽은 별명 선언 없이
|
|
116
|
+
// 코드에서 바로 임계구역을 잠근다: `await ctx.lock.with('user:42', { ttl: 5000 }, async () => { ... })`.
|
|
117
|
+
//
|
|
118
|
+
// 이 `lock` 블록이 **없어도** ctx.lock.with 는 동작한다 — driver 자동 폴백이 redis 가용 시 redis, 클러스터
|
|
119
|
+
// 워커면 cluster IPC, 그 외엔 **memory**(단일 프로세스, 부팅 경고)를 고른다. 멀티 프로세스/노드에서 진짜
|
|
120
|
+
// 분산 락을 쓰려면 아래처럼 **redis cache 별명을 cache 에 지정**한다(추가 .env 불필요 — 기존 caches.lock 재사용).
|
|
121
|
+
// 이 데모(crud)는 /demo/lock 시연을 위해 redis 분산 락을 켠다(클러스터 워커 간 실 상호배제·FIFO·fence).
|
|
122
|
+
lock: {
|
|
123
|
+
driver: 'auto', // 'auto'(기본) | 'redis' | 'cluster' | 'memory'. 명시 시 전제 부재면 부팅 fail-fast.
|
|
124
|
+
cache: 'lock', // services.caches 의 redis 별명(여기선 caches.lock = REDIS_LOCK_URL). driver redis/auto 시 사용.
|
|
125
|
+
ttl: 5000, // 락 보유 기본 시간(ms)
|
|
126
|
+
waitMs: 3000, // 획득 대기 기본 상한(ms, 0 = 즉시 실패). 데모 FIFO 대기가 보이도록 넉넉히.
|
|
127
|
+
fifo: false, // true = 대기자 도착 순서 보장(기아 방지). subject별 { fifo } 로 덮음.
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// ── 메시지 버스 사용자 API(ADR-227) — `ctx.bus.emit/.on/.request/.with` ────────────────────────
|
|
131
|
+
// 위 services.buses(설정형 어댑터, `ctx.bus('jobs')`)와 **별개 하위시스템**이다. 별명 선언 없이 코드에서 바로
|
|
132
|
+
// 이벤트를 주고받는다: `ctx.bus.emit('order.created', { id })` / `ctx.bus.on('order.*', handler)`.
|
|
133
|
+
//
|
|
134
|
+
// 이 `bus` 블록이 **없어도** ctx.bus.emit 은 동작한다 — driver 자동 폴백이 NATS 가용 시 nats, 클러스터 워커면
|
|
135
|
+
// cluster IPC, 그 외엔 **memory**(단일 프로세스, 부팅 경고)를 고른다. 멀티 프로세스/노드에서 진짜 분산 버스를
|
|
136
|
+
// 쓰려면 아래처럼 **NATS 버스 별명을 nats 에 지정**한다(추가 .env 불필요 — 기존 buses.jobs 재사용).
|
|
137
|
+
// 이 데모(crud)는 /demo/bus 시연을 위해 NATS 버스를 켠다(워커 간 fan-out·persist(JetStream)·request/reply).
|
|
138
|
+
bus: {
|
|
139
|
+
driver: 'auto', // 'auto'(기본) | 'nats' | 'cluster' | 'memory'. 명시 시 전제 부재면 부팅 fail-fast.
|
|
140
|
+
nats: 'jobs', // services.buses 의 NATS 별명(여기선 buses.jobs = NATS_JOBS_URL). driver nats/auto 시 사용.
|
|
141
|
+
prefix: 'app.', // 모든 subject 에 붙는 네임스페이스(충돌 회피). 빈 값이면 그대로.
|
|
142
|
+
defaultPersist: false, // 기본 비영속. true 면 emit/on 이 기본 JetStream(영속) — subject별 { persist } 로 덮음.
|
|
143
|
+
requestTimeoutMs: 5000, // request 응답 대기 기본 상한(ms)
|
|
144
|
+
},
|
|
145
|
+
|
|
114
146
|
// (예시·미사용) Embedded WS Hub(ADR-137) — `mega ws-hub` 별도 프로세스 대신 같은 프로세스에 허브를 띄움
|
|
115
147
|
// (single-node). 켜면 app.config.js bridgeHub.url 을 이 host:port 로 맞춘다. acceptedTokens 필수(빈 값=throw).
|
|
116
148
|
// wsHub: {
|
package/sample/crud/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
|
-
"node": ">=
|
|
7
|
+
"node": ">=22"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"dev": "NODE_ENV=development mega start --watch",
|
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
"test": "mega test"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"@nats-io/jetstream": "^3.4.0",
|
|
20
21
|
"highlight.js": "^11.11.1",
|
|
21
22
|
"marked": "^18.0.5",
|
|
22
23
|
"mega-framework": "file:../.."
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
|
-
"concurrently": "^
|
|
26
|
+
"concurrently": "^10.0.3",
|
|
26
27
|
"vitest": "^4.1.8"
|
|
27
28
|
}
|
|
28
29
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# sample-multi 환경변수 (.env) — 로컬 전용(.gitignore). 실제 docker 인프라 값으로 채움.
|
|
2
|
+
|
|
3
|
+
# HTTP 포트 — 한 포트로 두 앱(web=a.com, admin=b.com)을 vhost 라우팅한다.
|
|
4
|
+
# 3200 사용(sample/crud 앱 3000·crud ws-hub 3100 과 충돌 회피). 점유된 포트면 boot 가 EADDRINUSE 로 실패.
|
|
5
|
+
PORT=3200
|
|
6
|
+
|
|
7
|
+
# ── 앱별 lock/bus 백엔드 (ADR-229) ───────────────────────────────────────────
|
|
8
|
+
# web 앱 전용 분산 락 backend(redis) — services.caches.webLock. admin 은 글로벌 memory 락이라 불필요.
|
|
9
|
+
# docker mega-redis 는 비밀번호 필요. db 5 사용(crud 가 0~4 점유: 0 세션·1 demo·2 rate·3 lock·4 cache).
|
|
10
|
+
REDIS_WEBLOCK_URL=redis://:dkTkqkfl12@localhost:6379/5
|
|
11
|
+
# 두 앱이 공유하는 NATS — services.buses.events. web 은 prefix 'web.', admin(글로벌)은 'glob.' 로 격리.
|
|
12
|
+
NATS_EVENTS_URL=nats://localhost:4222
|
|
13
|
+
|
|
14
|
+
# ── /etc/hosts (a.com / b.com 이미 설정됨) ──────────────────────────────────
|
|
15
|
+
# 미설정이면 /etc/hosts 에 추가: 127.0.0.1 a.com / 127.0.0.1 b.com
|
|
16
|
+
# 접속: http://a.com:3200 (web) / http://b.com:3200 (admin)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# sample-multi 환경변수 (.env.example) — 복사해서 .env 로 쓰고 실제 값 채우기. .env 는 git 에 안 올림.
|
|
2
|
+
|
|
3
|
+
# HTTP 포트 — 한 포트로 두 앱(web=a.com, admin=b.com)을 vhost 라우팅한다.
|
|
4
|
+
# 3200 사용(sample/crud·sample/simple 이 3000 이라 충돌 회피). 이미 점유된 포트면 boot 가 EADDRINUSE 로 실패한다.
|
|
5
|
+
PORT=3200
|
|
6
|
+
|
|
7
|
+
# ── 앱별 lock/bus 백엔드 (ADR-229) ───────────────────────────────────────────
|
|
8
|
+
# web 앱 전용 분산 락 backend(redis) — services.caches.webLock. admin 은 글로벌 memory 락이라 불필요.
|
|
9
|
+
REDIS_WEBLOCK_URL=redis://localhost:6379/0
|
|
10
|
+
# 두 앱이 공유하는 NATS — services.buses.events. web 은 prefix 'web.', admin(글로벌)은 'glob.' 로 격리.
|
|
11
|
+
NATS_EVENTS_URL=nats://localhost:4222
|
|
12
|
+
|
|
13
|
+
# ── /etc/hosts (이미 a.com / b.com 설정돼 있다고 가정) ────────────────────────
|
|
14
|
+
# 미설정이면 아래를 /etc/hosts 에 추가:
|
|
15
|
+
# 127.0.0.1 a.com
|
|
16
|
+
# 127.0.0.1 b.com
|
|
17
|
+
# 그 뒤 http://a.com:3200 (web) / http://b.com:3200 (admin) 으로 접속.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# sample-multi — 한 프로세스 멀티앱 (vhost) 데모
|
|
2
|
+
|
|
3
|
+
한 프로세스에 **두 앱**을 띄우고, `Host` 헤더로 라우팅(vhost, ADR-063)하며, **앱별 lock/bus 분리**(ADR-229)와
|
|
4
|
+
ctx 없는 영역의 **cross-app 호출**(`getApp`, ADR-228)을 보여주는 최소 데모입니다.
|
|
5
|
+
|
|
6
|
+
- **web** (`a.com`) — 앱 전용 lock(**redis**) + bus(NATS, prefix `web.`)
|
|
7
|
+
- **admin** (`b.com`) — 앱별 설정 없음 → **글로벌 fallback** (lock=**memory**, bus prefix `glob.`)
|
|
8
|
+
|
|
9
|
+
> 이 데모는 멀티앱 패턴(vhost·앱별 lock/bus·cross-app)에 집중한 **목적형 최소 앱 2개**입니다. 풀 기능
|
|
10
|
+
> 데모는 단일앱 `sample/crud`(DB·캐시·잡·WS·ASP 등) / `sample/simple`(최소 E2E)을 보세요.
|
|
11
|
+
|
|
12
|
+
## 실행
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
cd sample/multi
|
|
16
|
+
cp .env.example .env # REDIS_WEBLOCK_URL / NATS_EVENTS_URL 확인 (redis·nats 필요)
|
|
17
|
+
npm install
|
|
18
|
+
npm run dev
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`/etc/hosts` 에 `a.com` / `b.com` 이 `127.0.0.1` 로 매핑돼 있어야 합니다(이미 설정돼 있다고 가정 — 없으면
|
|
22
|
+
`.env.example` 안내 참고). 접속:
|
|
23
|
+
|
|
24
|
+
| URL | 앱 | 무엇 |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| http://a.com:3200/ | web | 셸 + 엔드포인트 안내 |
|
|
27
|
+
| http://a.com:3200/whoami | web | lock=`redis`, bus=`nats` (앱 전용) |
|
|
28
|
+
| http://a.com:3200/lock | web | redis 락으로 임계구역 실행 |
|
|
29
|
+
| http://a.com:3200/emit | web | `web.order.created` 발행(admin 미수신) |
|
|
30
|
+
| http://a.com:3200/cross | web | `getApp('admin').bus.emit('notice')` → admin 으로 cross-app |
|
|
31
|
+
| http://b.com:3200/whoami | admin | lock=`memory`, bus=`nats`(글로벌) |
|
|
32
|
+
| http://b.com:3200/received | admin | 글로벌 버스로 받은 이벤트 — web 의 cross-app `notice` 가 보임, `web.` 발행은 격리로 없음 |
|
|
33
|
+
|
|
34
|
+
## 무엇을 보여주나
|
|
35
|
+
|
|
36
|
+
- **vhost**: 한 포트(3200) 한 번 listen, `a.com`/`b.com` 이 다른 앱으로 라우팅. 같은 host 를 두 앱에 매핑하면
|
|
37
|
+
부팅 시 `config.host_collision` 로 fail-fast(ADR-067).
|
|
38
|
+
- **앱별 lock 분리**: `a.com/whoami` 는 `redis`, `b.com/whoami` 는 `memory` — 같은 키여도 다른 manager 라 격리.
|
|
39
|
+
- **앱별 bus prefix 격리**: web 의 `web.` 발행을 admin(`glob.`)이 못 받음.
|
|
40
|
+
- **cross-app**: web 의 `/cross` 가 `getApp('admin').bus` 로 admin 의 글로벌 버스에 직접 발행 → `b.com/received` 에 도착.
|
|
41
|
+
|
|
42
|
+
## 문제 해결 — `host.not_mounted`
|
|
43
|
+
|
|
44
|
+
`{"error":{"code":"host.not_mounted"}}` 가 보이면 **다른 mega 앱이 그 포트를 점유**하고 있어, 이 앱이 listen 에
|
|
45
|
+
실패하고 브라우저가 옛 서버를 때리는 것입니다(예: `sample/crud`가 3000 에서 도는 중). 확인·해결:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
lsof -nP -iTCP:3200 -sTCP:LISTEN # 누가 점유 중인지
|
|
49
|
+
# → 옛 서버를 끄거나, PORT 를 비어있는 값으로 바꿔 실행: PORT=3201 npm run dev
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
이 앱은 점유된 포트면 `listen EADDRINUSE` 로 **명확히 실패**합니다 — 그 로그가 보이면 포트를 바꾸세요.
|
|
53
|
+
|
|
54
|
+
자세한 설명은 프레임워크 가이드 `docs/guide/10-multi-app.md` 참고.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* apps/admin/app.config.js — `admin` 앱(b.com). **앱별 lock/bus 미지정** → 글로벌 fallback 을 쓴다(ADR-229):
|
|
4
|
+
* - lock: 글로벌(memory) — web 의 redis 락과 격리(다른 manager).
|
|
5
|
+
* - bus: 글로벌(NATS, prefix `glob.`) — web 의 `web.` 와 subject 공간이 갈린다.
|
|
6
|
+
* 멀티앱이라도 앱이 lock/bus 를 정의하지 않으면 종전처럼 글로벌을 그대로 쓴다(호환).
|
|
7
|
+
*/
|
|
8
|
+
export default {
|
|
9
|
+
name: 'admin',
|
|
10
|
+
hosts: ['b.com'],
|
|
11
|
+
|
|
12
|
+
views: { dir: 'apps/admin/views' },
|
|
13
|
+
|
|
14
|
+
// 정적 자산 — 데모 페이지의 클라이언트 JS(/static/js/admin.js)를 서빙(CSP script-src 'self' 호환).
|
|
15
|
+
staticAssets: { enabled: true, dir: 'apps/admin/public', prefix: '/static' },
|
|
16
|
+
|
|
17
|
+
// helmet CSP — 로컬 http 데모라 `upgrade-insecure-requests` 끄기(null). 안 끄면 브라우저가 https 로 강제
|
|
18
|
+
// 업그레이드해 연결 실패. useDefaults 라 나머지 보안 헤더는 유지(운영 https 에선 이 override 제거 권장).
|
|
19
|
+
helmet: {
|
|
20
|
+
contentSecurityPolicy: {
|
|
21
|
+
directives: { upgradeInsecureRequests: null },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* AdminController — `admin` 앱(b.com) 컨트롤러. 앱별 lock/bus 미지정이라 **글로벌**(memory 락 + glob. 버스)을 쓴다.
|
|
4
|
+
*
|
|
5
|
+
* 첫 요청에서 글로벌 버스의 `>`(= glob.>)를 구독해 받은 이벤트를 링버퍼에 모은다. web 의 `/cross`
|
|
6
|
+
* (getApp('admin').bus.emit('notice'))가 보낸 cross-app 이벤트가 여기로 들어온다. web 의 `web.` 발행은
|
|
7
|
+
* prefix 가 달라 들어오지 않는다(격리).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 수신 버퍼(워커별 인메모리). @type {any[]} */
|
|
11
|
+
const received = []
|
|
12
|
+
const MAX = 30
|
|
13
|
+
let subscribed = false
|
|
14
|
+
|
|
15
|
+
/** 글로벌 버스 1회 구독(워커별 멱등). @param {any} ctx @returns {Promise<void>} */
|
|
16
|
+
async function ensureSubscribed(ctx) {
|
|
17
|
+
if (subscribed) return
|
|
18
|
+
subscribed = true
|
|
19
|
+
await ctx.bus.on('>', (/** @type {any} */ payload, /** @type {any} */ _meta, /** @type {any} */ subject) => {
|
|
20
|
+
received.push({ subject, payload, at: new Date().toISOString() })
|
|
21
|
+
if (received.length > MAX) received.shift()
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class AdminController {
|
|
26
|
+
/** GET / — 셸 페이지. @param {any} _req @param {any} _reply @param {any} ctx */
|
|
27
|
+
static async home(_req, _reply, ctx) {
|
|
28
|
+
await ensureSubscribed(ctx)
|
|
29
|
+
return ctx.render('index', { app: 'admin', host: 'b.com' })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** GET /whoami — 글로벌 lock/bus driver. @param {any} _req @param {any} _reply @param {any} ctx */
|
|
33
|
+
static async whoami(_req, _reply, ctx) {
|
|
34
|
+
return { app: 'admin', lock: (await ctx.lock.stats()).driver, bus: (await ctx.bus.stats()).driver, pid: process.pid }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** GET /received — 글로벌 버스(glob.>)로 받은 이벤트(web 의 cross-app 포함, web. 발행은 격리되어 없음). @param {any} _req @param {any} _reply @param {any} ctx */
|
|
38
|
+
static async received(_req, _reply, ctx) {
|
|
39
|
+
await ensureSubscribed(ctx)
|
|
40
|
+
return { app: 'admin', count: received.length, events: received.slice().reverse() }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/* admin 앱 데모 — whoami / 수신 이벤트(received)를 fetch 해 인라인 표시 + 2초 자동 새로고침. */
|
|
3
|
+
;(function () {
|
|
4
|
+
var out = document.getElementById('out')
|
|
5
|
+
function show(/** @type {any} */ obj) {
|
|
6
|
+
if (out) out.textContent = JSON.stringify(obj, null, 2)
|
|
7
|
+
}
|
|
8
|
+
function load(/** @type {string} */ url) {
|
|
9
|
+
fetch(url, { headers: { accept: 'application/json' } })
|
|
10
|
+
.then(function (res) {
|
|
11
|
+
return res.json()
|
|
12
|
+
})
|
|
13
|
+
.then(function (j) {
|
|
14
|
+
show(j && j.data != null ? j.data : j)
|
|
15
|
+
})
|
|
16
|
+
.catch(function (e) {
|
|
17
|
+
if (out) out.textContent = '오류: ' + e.message
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
var whoami = document.querySelector('button[data-get="/whoami"]')
|
|
22
|
+
if (whoami) whoami.addEventListener('click', function () { load('/whoami') })
|
|
23
|
+
var refresh = document.getElementById('refresh')
|
|
24
|
+
if (refresh) refresh.addEventListener('click', function () { load('/received') })
|
|
25
|
+
|
|
26
|
+
// 첫 로드 + 2초 폴링(탭 숨김 시 멈춤) — web 의 cross-app 이벤트가 도착하는 것을 실시간으로 본다.
|
|
27
|
+
load('/received')
|
|
28
|
+
setInterval(function () {
|
|
29
|
+
if (!document.hidden) load('/received')
|
|
30
|
+
}, 2000)
|
|
31
|
+
})()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* apps/admin 라우트 — 자동 로딩. 글로벌 lock/bus(fallback) 사용 + cross-app 으로 받은 이벤트 표시.
|
|
4
|
+
*/
|
|
5
|
+
import { AdminController } from '../controllers/admin-controller.js'
|
|
6
|
+
|
|
7
|
+
export default (/** @type {any} */ router) => {
|
|
8
|
+
router.http.get('/', AdminController.home)
|
|
9
|
+
router.http.get('/whoami', AdminController.whoami) // 글로벌 lock/bus driver 확인
|
|
10
|
+
router.http.get('/received', AdminController.received) // 글로벌 버스(glob.>)로 받은 이벤트 — web 의 cross-app 포함
|
|
11
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="ko">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>admin (b.com) — 멀티앱 데모</title>
|
|
7
|
+
<style>
|
|
8
|
+
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; }
|
|
9
|
+
code { background: #f3f3f3; padding: .1rem .3rem; border-radius: 3px; }
|
|
10
|
+
.tag { background: #6c757d; color: #fff; padding: .1rem .5rem; border-radius: 4px; font-size: .85rem; }
|
|
11
|
+
button { font: inherit; padding: .4rem .8rem; margin: .2rem .3rem .2rem 0; border: 1px solid #6c757d; background: #fff; color: #495057; border-radius: 6px; cursor: pointer; }
|
|
12
|
+
button:hover { background: #6c757d; color: #fff; }
|
|
13
|
+
.out { background: #f8f9fa; border: 1px solid #e3e6ea; border-radius: 6px; padding: .5rem .7rem; font-family: ui-monospace, monospace; font-size: .85rem; white-space: pre-wrap; min-height: 1.4rem; margin: .3rem 0; }
|
|
14
|
+
.desc { color: #555; font-size: .9rem; margin: .1rem 0 .3rem; }
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<h1>admin 앱 <span class="tag">b.com</span></h1>
|
|
19
|
+
<p>이 앱은 <strong>앱별 lock/bus 설정이 없어 글로벌 fallback</strong>(lock=memory, bus prefix <code>glob.</code>)을
|
|
20
|
+
씁니다 (ADR-229). 같은 프로세스의 <code>web</code>(a.com)은 앱 전용 redis 락 + <code>web.</code> 버스를 씁니다.</p>
|
|
21
|
+
|
|
22
|
+
<div>
|
|
23
|
+
<button data-get="/whoami">whoami</button>
|
|
24
|
+
<button id="refresh">수신 이벤트 새로고침</button>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="desc">이 앱의 lock/bus driver — memory / nats(glob.). 아래는 글로벌 버스로 받은 이벤트입니다.</div>
|
|
27
|
+
<div class="out" id="out"></div>
|
|
28
|
+
<p class="desc"><code>web</code> 의 <code>/cross</code>(getApp('admin').bus)가 보낸 <code>notice</code> 가 여기 들어옵니다(cross-app).
|
|
29
|
+
web 의 <code>web.</code> 발행은 prefix 격리로 들어오지 않습니다. web 앱은 <a href="http://a.com:3200/">a.com</a>.</p>
|
|
30
|
+
|
|
31
|
+
<script src="/static/js/admin.js"></script>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* apps/web/app.config.js — `web` 앱(a.com). **앱 전용 lock/bus**(ADR-229)로 admin 과 분리한다.
|
|
4
|
+
* - lock: redis(`webLock` 캐시) — admin 의 글로벌 memory 락과 다른 backend.
|
|
5
|
+
* - bus: NATS, prefix `web.` — 글로벌 `glob.` 와 subject 공간이 갈려 격리된다(같은 NATS 공유).
|
|
6
|
+
*/
|
|
7
|
+
export default {
|
|
8
|
+
name: 'web',
|
|
9
|
+
hosts: ['a.com', 'localhost'],
|
|
10
|
+
|
|
11
|
+
views: { dir: 'apps/web/views' },
|
|
12
|
+
|
|
13
|
+
// 정적 자산 — 데모 페이지의 클라이언트 JS(/static/js/web.js)를 서빙(CSP script-src 'self' 호환).
|
|
14
|
+
staticAssets: { enabled: true, dir: 'apps/web/public', prefix: '/static' },
|
|
15
|
+
|
|
16
|
+
// helmet CSP — 로컬 http 데모라 `upgrade-insecure-requests` 를 끈다(null 로 디폴트 지시어 제거). 안 끄면
|
|
17
|
+
// 브라우저가 http://a.com:3200 을 https 로 강제 업그레이드해 (TLS 없음) 연결 실패한다. useDefaults 라 나머지
|
|
18
|
+
// 보안 헤더는 유지. (운영 https 배포에선 이 override 를 빼서 업그레이드를 다시 켜는 게 맞다.)
|
|
19
|
+
helmet: {
|
|
20
|
+
contentSecurityPolicy: {
|
|
21
|
+
directives: { upgradeInsecureRequests: null },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
// 앱 전용 분산 락 — 글로벌(memory)과 달리 redis backend. lock.cache 는 글로벌 services.caches 키를 참조.
|
|
26
|
+
lock: { cache: 'webLock' },
|
|
27
|
+
|
|
28
|
+
// 앱 전용 버스 — 글로벌과 같은 NATS(`events`)지만 prefix 가 달라(web.) admin(glob.)과 격리된다.
|
|
29
|
+
bus: { nats: 'events', prefix: 'web.' },
|
|
30
|
+
}
|