mega-framework 0.1.9 → 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.
Files changed (90) hide show
  1. package/README.md +14 -4
  2. package/package.json +23 -21
  3. package/sample/crud/.env +10 -2
  4. package/sample/crud/.env.example +8 -0
  5. package/sample/crud/apps/main/controllers/bus-controller.js +122 -0
  6. package/sample/crud/apps/main/controllers/jobs-controller.js +22 -2
  7. package/sample/crud/apps/main/controllers/lock-controller.js +117 -0
  8. package/sample/crud/apps/main/jobs/email-job.js +37 -2
  9. package/sample/crud/apps/main/locales/server/en.json +36 -1
  10. package/sample/crud/apps/main/locales/server/ko.json +36 -1
  11. package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
  12. package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
  13. package/sample/crud/apps/main/routes/bus.js +43 -0
  14. package/sample/crud/apps/main/routes/lock.js +35 -0
  15. package/sample/crud/apps/main/services/jobs-demo-service.js +22 -15
  16. package/sample/crud/apps/main/views/bus/index.ejs +80 -0
  17. package/sample/crud/apps/main/views/jobs/index.ejs +9 -3
  18. package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
  19. package/sample/crud/apps/main/views/lock/index.ejs +99 -0
  20. package/sample/crud/docs/guide/03-service-model-db.md +48 -0
  21. package/sample/crud/docs/guide/05-scheduler-job-worker.md +29 -2
  22. package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
  23. package/sample/crud/docs/guide/10-multi-app.md +74 -0
  24. package/sample/crud/mega.config.js +32 -0
  25. package/sample/crud/package.json +3 -2
  26. package/sample/multi/.env +16 -0
  27. package/sample/multi/.env.example +17 -0
  28. package/sample/multi/README.md +54 -0
  29. package/sample/multi/apps/admin/app.config.js +24 -0
  30. package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
  31. package/sample/multi/apps/admin/public/js/admin.js +31 -0
  32. package/sample/multi/apps/admin/routes/pages.js +11 -0
  33. package/sample/multi/apps/admin/views/index.ejs +33 -0
  34. package/sample/multi/apps/web/app.config.js +30 -0
  35. package/sample/multi/apps/web/controllers/web-controller.js +45 -0
  36. package/sample/multi/apps/web/public/js/web.js +24 -0
  37. package/sample/multi/apps/web/routes/pages.js +13 -0
  38. package/sample/multi/apps/web/views/index.ejs +51 -0
  39. package/sample/multi/mega.config.js +42 -0
  40. package/sample/multi/package.json +20 -0
  41. package/sample/simple/package.json +2 -2
  42. package/src/adapters/nats-adapter.js +39 -44
  43. package/src/adapters/nats-codec.js +38 -0
  44. package/src/cli/commands/scaffold.js +1 -0
  45. package/src/cli/index.js +50 -1
  46. package/src/core/app-registry.js +69 -0
  47. package/src/core/boot.js +99 -0
  48. package/src/core/bus/cluster-bus.js +190 -0
  49. package/src/core/bus/contract.js +123 -0
  50. package/src/core/bus/index.js +285 -0
  51. package/src/core/bus/memory-bus.js +103 -0
  52. package/src/core/bus/nats-bus.js +203 -0
  53. package/src/core/config-validator.js +118 -1
  54. package/src/core/ctx-builder.js +14 -1
  55. package/src/core/index.js +2 -0
  56. package/src/core/lock/cluster-lock.js +174 -0
  57. package/src/core/lock/contract.js +123 -0
  58. package/src/core/lock/fifo-waitlist.js +93 -0
  59. package/src/core/lock/index.js +292 -0
  60. package/src/core/lock/memory-lock.js +162 -0
  61. package/src/core/lock/redis-lock.js +276 -0
  62. package/src/core/mega-app.js +29 -0
  63. package/src/core/migration/generate.js +1 -1
  64. package/src/core/migration/journal.js +1 -1
  65. package/src/core/scope-registry.js +9 -0
  66. package/src/eslint-plugin/no-direct-model-import.js +2 -2
  67. package/src/index.js +2 -0
  68. package/src/lib/mega-job-queue.js +71 -47
  69. package/types/adapters/mega-adapter.d.ts +1 -1
  70. package/types/adapters/nats-adapter.d.ts +4 -4
  71. package/types/adapters/nats-codec.d.ts +13 -0
  72. package/types/adapters/redlock-adapter.d.ts +1 -1
  73. package/types/core/app-registry.d.ts +22 -0
  74. package/types/core/bus/cluster-bus.d.ts +45 -0
  75. package/types/core/bus/contract.d.ts +164 -0
  76. package/types/core/bus/index.d.ts +100 -0
  77. package/types/core/bus/memory-bus.d.ts +45 -0
  78. package/types/core/bus/nats-bus.d.ts +41 -0
  79. package/types/core/index.d.ts +1 -0
  80. package/types/core/lock/cluster-lock.d.ts +44 -0
  81. package/types/core/lock/contract.d.ts +181 -0
  82. package/types/core/lock/fifo-waitlist.d.ts +38 -0
  83. package/types/core/lock/index.d.ts +96 -0
  84. package/types/core/lock/memory-lock.d.ts +58 -0
  85. package/types/core/lock/redis-lock.d.ts +43 -0
  86. package/types/core/mega-app.d.ts +10 -0
  87. package/types/core/scope-registry.d.ts +6 -0
  88. package/types/index.d.ts +1 -1
  89. package/types/lib/mega-job-queue.d.ts +27 -4
  90. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -0,0 +1,99 @@
1
+ <% layout('layouts/main') %>
2
+
3
+ <div class="mb-4">
4
+ <h1 class="h3 mb-1"><%= t('lock_title', { defaultValue: '분산 락 데모' }) %></h1>
5
+ <p class="text-body-secondary small mb-0">
6
+ <%= t('lock_subtitle', { defaultValue: 'ctx.lock.with / tryAcquire 로 임계구역을 보호합니다. 두 탭에서 같은 키로 동시에 실행하면 한 번에 하나만 들어가고(상호배제), FIFO 면 도착 순서대로 깨어납니다.' }) %>
7
+ <a href="/guide" class="ms-1">ADR-226 · 가이드</a>
8
+ </p>
9
+ </div>
10
+
11
+ <div class="row g-3">
12
+ <!-- 실행 폼 -->
13
+ <div class="col-lg-7">
14
+ <div class="card h-100">
15
+ <div class="card-body">
16
+ <h2 class="h5 card-title"><%= t('lock_run_title', { defaultValue: '임계구역 실행' }) %></h2>
17
+ <p class="card-text text-body-secondary small"><%= t('lock_run_desc', { defaultValue: '키를 잡고 holdMs 동안 점유한 뒤 자동 해제합니다(ctx.lock.with). 다른 시도는 waitMs 까지 대기합니다.' }) %></p>
18
+
19
+ <div class="row g-2">
20
+ <div class="col-12">
21
+ <label class="form-label small mb-1" for="lk-key"><%= t('lock_f_key', { defaultValue: '자원 키' }) %></label>
22
+ <input id="lk-key" class="form-control form-control-sm" value="demo:resource" maxlength="80">
23
+ </div>
24
+ <div class="col-6 col-md-3">
25
+ <label class="form-label small mb-1" for="lk-ttl">ttl (ms)</label>
26
+ <input id="lk-ttl" type="number" class="form-control form-control-sm" value="5000" min="100" max="60000">
27
+ </div>
28
+ <div class="col-6 col-md-3">
29
+ <label class="form-label small mb-1" for="lk-wait">waitMs</label>
30
+ <input id="lk-wait" type="number" class="form-control form-control-sm" value="3000" min="0" max="30000">
31
+ </div>
32
+ <div class="col-6 col-md-3">
33
+ <label class="form-label small mb-1" for="lk-hold">holdMs</label>
34
+ <input id="lk-hold" type="number" class="form-control form-control-sm" value="2500" min="0" max="15000">
35
+ </div>
36
+ </div>
37
+
38
+ <div class="d-flex flex-wrap gap-3 mt-3">
39
+ <div class="form-check form-switch">
40
+ <input id="lk-fifo" class="form-check-input" type="checkbox">
41
+ <label class="form-check-label small" for="lk-fifo">fifo <span class="text-body-secondary"><%= t('lock_f_fifo', { defaultValue: '(도착 순서 보장)' }) %></span></label>
42
+ </div>
43
+ <div class="form-check form-switch">
44
+ <input id="lk-fence" class="form-check-input" type="checkbox">
45
+ <label class="form-check-label small" for="lk-fence">fence <span class="text-body-secondary"><%= t('lock_f_fence', { defaultValue: '(단조 토큰)' }) %></span></label>
46
+ </div>
47
+ <div class="form-check form-switch">
48
+ <input id="lk-ext" class="form-check-input" type="checkbox">
49
+ <label class="form-check-label small" for="lk-ext">extendable <span class="text-body-secondary"><%= t('lock_f_ext', { defaultValue: '(watchdog 자동연장)' }) %></span></label>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="d-flex gap-2 mt-3">
54
+ <button id="lk-run" class="btn btn-primary btn-sm"><%= t('lock_btn_run', { defaultValue: '실행 (with)' }) %></button>
55
+ <button id="lk-try" class="btn btn-outline-secondary btn-sm"><%= t('lock_btn_try', { defaultValue: 'tryAcquire' }) %></button>
56
+ </div>
57
+
58
+ <div id="lk-result" class="mt-3 small"></div>
59
+ <div class="mt-3"><code class="small">ctx.lock.with(key, { ttl, waitMs, fifo, fence, extendable }, fn)</code></div>
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <!-- 상태 패널 -->
65
+ <div class="col-lg-5">
66
+ <div class="card h-100">
67
+ <div class="card-body">
68
+ <h2 class="h5 card-title"><%= t('lock_status_title', { defaultValue: '현재 상태' }) %></h2>
69
+ <div class="d-flex gap-4 mb-2">
70
+ <div>
71
+ <div class="h4 fw-bold mb-0" id="lk-active"><%= stats ? stats.active : '–' %></div>
72
+ <div class="text-body-secondary small"><%= t('lock_active', { defaultValue: '보유 중' }) %></div>
73
+ </div>
74
+ <div>
75
+ <div class="h4 fw-bold mb-0" id="lk-waiting"><%= stats ? stats.waiting : '–' %></div>
76
+ <div class="text-body-secondary small"><%= t('lock_waiting', { defaultValue: '대기 중' }) %></div>
77
+ </div>
78
+ <div>
79
+ <div class="h6 fw-bold mb-0"><span class="badge text-bg-secondary" id="lk-driver"><%= stats ? stats.driver : '?' %></span></div>
80
+ <div class="text-body-secondary small">driver</div>
81
+ </div>
82
+ </div>
83
+ <p class="text-body-secondary small mb-2">
84
+ <%= t('lock_worker_pid', { defaultValue: '이 페이지를 처리한 워커 PID' }) %>: <code><%= pid %></code>
85
+ </p>
86
+ <hr>
87
+ <h3 class="h6"><%= t('lock_log_title', { defaultValue: '최근 실행 (이 워커)' }) %></h3>
88
+ <div class="table-responsive" style="max-height: 320px; overflow-y: auto;">
89
+ <table class="table table-sm small mb-0">
90
+ <thead><tr><th>at</th><th>key</th><th>pid</th><th><%= t('lock_log_result', { defaultValue: '결과' }) %></th><th>wait</th></tr></thead>
91
+ <tbody id="lk-log"></tbody>
92
+ </table>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+
99
+ <script src="/static/js/lock-demo.js"></script>
@@ -636,6 +636,54 @@ caches: {
636
636
 
637
637
  이 셋은 본 문서(Service+Model+DB) 범위 밖이라 이름만 짚는다.
638
638
 
639
+ ### 분산 락 사용자 API — `ctx.lock.with/.acquire` (ADR-226)
640
+
641
+ 여러 인스턴스가 같은 자원을 동시에 건드리면 안 될 때(재고 차감, 1회성 작업 등) **분산 락**으로 임계구역을
642
+ 보호한다. 위 `redlock`(`ctx.lock('main')`, 스케줄 leader election)과 **별개 하위시스템**으로, 별명 선언 없이
643
+ 컨트롤러·서비스에서 바로 쓴다.
644
+
645
+ ```js
646
+ // 권장 — 잠그고, 끝나면(성공/예외 무관) 자동 해제
647
+ await ctx.lock.with('stock:sku-42', { ttl: 5000 }, async () => {
648
+ const row = await stock.byId(42)
649
+ await stock.update(42, { qty: row.qty - 1 }) // 이 블록은 노드를 넘어 한 번에 하나만 실행
650
+ })
651
+
652
+ // 즉시 1회 시도(대기 없음) — 못 잡으면 null
653
+ const lock = await ctx.lock.tryAcquire('job:nightly')
654
+ if (lock) { try { /* ... */ } finally { await lock.release() } }
655
+ ```
656
+
657
+ - **옵션**: `ttl`(보유 ms) · `waitMs`(획득 대기 ms, 0=즉시) · `fifo`(도착 순서 보장) · `fence`(단조 토큰
658
+ `lock.fence`) · `extendable`(긴 작업 자동 연장). `acquire` 는 못 잡으면 `lock.not_acquired`(409) throw,
659
+ `tryAcquire` 는 `null`.
660
+ - **driver 자동 폴백**: `mega.config.js` 의 `lock` 블록(주석 처리됨 — 풀어서 `cache: 'lock'` 지정)이 있으면
661
+ **redis**(진짜 분산), 없고 클러스터 워커면 **cluster**(단일 노드), 그 외엔 **memory**(단일 프로세스, 부팅 경고).
662
+ redis 활성에 **추가 .env 는 불필요** — 기존 `caches.lock`(`REDIS_LOCK_URL`)을 재사용한다.
663
+
664
+ ### 메시지 버스 사용자 API — `ctx.bus.emit/.on/.request` (ADR-227)
665
+
666
+ 서비스끼리 이벤트로 느슨하게 결합할 때(주문 생성 → 이메일·분석 각각 반응) **메시지 버스**를 쓴다. 위
667
+ `services.buses`(`ctx.bus('jobs')`, 잡 큐)와 **별개 하위시스템**으로, 별명 선언 없이 발행/구독한다.
668
+
669
+ ```js
670
+ // fan-out — 구독자 전원 수신 (핸들러는 payload, meta)
671
+ ctx.bus.emit('order.created', { orderId: 42 }, { meta: { traceId } })
672
+ ctx.bus.on('order.created', async (payload) => { await emailService.sendReceipt(payload.orderId) })
673
+ ctx.bus.on('order.*', async (payload, meta) => { /* order.created/updated 등 — '*' 는 한 토큰 */ })
674
+
675
+ // request/reply — 핸들러 '반환값'이 응답
676
+ ctx.bus.on('catalog.product', async ({ id }) => ({ id, name: 'widget' }))
677
+ const product = await ctx.bus.request('catalog.product', { id: 1 }, { timeout: 1000 })
678
+ ```
679
+
680
+ - **wildcards**(NATS 식): `order.*` = 한 토큰(`order.created` O, `order.created.eu` X), `order.>` = 꼬리 전체.
681
+ - **옵트인**: `{ persist: true }`(JetStream 영속 — nats driver 만; 그 외엔 경고 후 비영속) · `{ ordered: true }`(순서 보장).
682
+ 한 구독자가 throw 해도 다른 구독자·다음 메시지엔 영향 없다(매니저가 잡아 로깅).
683
+ - **driver 자동 폴백**: `mega.config.js` 의 `bus` 블록(주석 처리됨 — 풀어서 `nats: 'jobs'` 지정)이 있으면
684
+ **nats**(진짜 분산), 없고 클러스터 워커면 **cluster**(단일 노드), 그 외엔 **memory**(단일 프로세스, 부팅 경고).
685
+ nats 활성에 **추가 .env 는 불필요** — 기존 `buses.jobs`(`NATS_JOBS_URL`)을 재사용한다.
686
+
639
687
  ---
640
688
 
641
689
  ## 5. mega migrate — 스키마 마이그레이션 (ADR-149)
@@ -139,12 +139,17 @@ export class EmailJob extends MegaJob {
139
139
  static concurrency = 2
140
140
  static retries = 2 // 추가 재시도 2회 (첫 시도 포함 최대 3회)
141
141
  static backoff = { type: 'exponential', initial: 500, max: 2000 }
142
+ static timeoutMs = 5000 // run 전체 상한 — 초과 시 timeout → DLQ (2-6)
142
143
 
143
144
  async run(payload, ctx) {
144
145
  const { id, to, mode } = payload
145
146
  const redis = ctx.cache('demo').native
146
147
  const attempt = await redis.incr(`demo:jobs:attempt:${id}`)
147
148
 
149
+ if (mode === 'hang') {
150
+ await new Promise((r) => setTimeout(r, payload.delayMs ?? 8000)) // 5s(timeoutMs) 초과 → timeout
151
+ return { id, status: 'sent', attempt } // timeout 후 백그라운드에서 늦게 완료
152
+ }
148
153
  if (mode === 'flaky' && attempt < 2) {
149
154
  throw new Error(`transient failure on attempt ${attempt} (will retry)`) // 재시도 트리거
150
155
  }
@@ -163,6 +168,7 @@ export class EmailJob extends MegaJob {
163
168
  | `concurrency` | `1` | 동시 처리 메시지 수(consumer `max_ack_pending`). **양의 정수만** — 0/음수는 NATS 가 "무제한" 으로 해석하는 풋건이라 거부. ⚠️ **워커 그룹(전 인스턴스) 합산 상한** — 인스턴스를 늘려도 합산 in-flight 가 이 값을 못 넘으므로 처리량 증설 시 함께 키울 것 |
164
169
  | `retries` | `3` | run 실패 시 **추가** 재시도 횟수(첫 시도 제외) |
165
170
  | `backoff` | `{ type:'exponential', initial:1000, max:30000 }` | 지수 백오프. **현재 `'exponential'` 만 지원**, `initial`/`max` 는 ms |
171
+ | `timeoutMs` | 큐 디폴트(30분) | run 전체(재시도 포함) 실행 상한(ms). 초과 시 timeout 실패 → DLQ. `0` = 무제한. 행(hang)된 잡이 lease 를 영구 갱신하며 메시지를 점유하는 것을 막는 backstop (2-6) |
166
172
 
167
173
  > **핵심**: 일시·영구 오류는 그냥 `throw` 하면 된다. `MegaJobQueue` 가 `static retries`/`static backoff` 만큼 인프로세스 지수 백오프(p-retry, factor=2·jitter on 고정)로 재시도하고, 모두 실패하면 DLQ 로 보낸다.
168
174
 
@@ -219,11 +225,32 @@ MegaShutdown.register('worker', async () => { await worker.stop() })
219
225
  DLQ 상태를 읽는 쪽 예시(`jobs-demo-service.js`):
220
226
 
221
227
  ```js
222
- const jsm = await this.ctx.bus('jobs').native.jetstreamManager()
228
+ // nats v3(ADR-225): jetstreamManager 는 nc 메서드가 아니라 @nats-io/jetstream 의 함수다.
229
+ import { jetstreamManager } from '@nats-io/jetstream'
230
+ const jsm = await jetstreamManager(this.ctx.bus('jobs').native)
223
231
  const info = await jsm.streams.info('MEGA_JOBS_demo_email_DLQ') // count = info.state.messages
224
232
  const msg = await jsm.streams.getMessage(DLQ_STREAM, { last_by_subj: 'demo.email.dlq' })
225
233
  ```
226
234
 
235
+ ### 2-6. timeoutMs — 행(hang)된 잡 backstop
236
+
237
+ `run()` 이 실패(throw)는 안 하는데 **끝나지도 않으면**(외부 호출이 hang, 무한 루프 등) `working()` 하트비트가 ack lease 를 영원히 갱신해 메시지가 재전달도 DLQ 도 못 가는 **영구 점유**가 된다. `static timeoutMs`(또는 큐 디폴트 30분)가 이 backstop이다 — run 전체(재시도 포함)에 상한을 걸어 초과하면 잡을 **실패로 판정**해 DLQ 로 라우팅한다.
238
+
239
+ `/demo/jobs` 의 **`hang` 모드**가 이를 시연한다. `delayMs`(기본 8000ms)만큼 자는 잡을 enqueue하면, EmailJob 의 `static timeoutMs = 5000` 을 넘는 순간 큐가 timeout으로 판정한다:
240
+
241
+ ```
242
+ WARN job ... (없음 — hang 은 throw 안 함, retry 이벤트 없음)
243
+ ERROR job failed subject=demo.email phase=run err=… exceeded run timeout (5000ms) …
244
+ WARN job routed to DLQ subject=demo.email dlqSubject=demo.email.dlq
245
+ ```
246
+
247
+ - DLQ 봉투의 `error.message` = `exceeded run timeout (5000ms)` — 페이지 DLQ 카드에 표시된다.
248
+ - **⚠️ timeout 후에도 진행 중 run 은 중단되지 않는다**(abort 없음 — JS 는 협조적 취소만 가능). hang 잡은 백그라운드에서 `delayMs` 뒤 끝까지 흘러 'sent' 이벤트를 **뒤늦게** 남긴다. 그래서 **run 은 멱등(idempotent)하게 설계**해야 한다 — 같은 잡이 timeout 후 백그라운드 완료 + (max_deliver 재전달 시) 재실행돼도 안전하도록.
249
+ - 진짜 backstop은 `static timeoutMs` 가 아니라도 큐 디폴트(30분)로 항상 걸려 있다. 데모는 체감을 위해 5s 로 줄였을 뿐이다(`0` = 무제한으로 끌 수 있으나 행 잡 점유 위험을 떠안는다).
250
+ - 만약 abandoned된(timeout 패배) run 이 나중에 **throw** 하면, 그 늦은 실패는 `fail(phase:'abandoned-run')` 이벤트로 별도 표면화된다(잡은 이미 DLQ 라우팅됐으므로 처리 흐름엔 영향 없음).
251
+
252
+ 위 워커 로그는 호스트(`mega worker`)가 잡 이벤트를 구독해 남긴 것이다(ADR-223) — `fail`=error, `dlq`/`retry`=warn, `start`/`done`=debug.
253
+
227
254
  ---
228
255
 
229
256
  ## 3. Worker (worker_threads, MegaWorker, ADR-124)
@@ -337,7 +364,7 @@ module.exports = {
337
364
  }
338
365
  ```
339
366
 
340
- 잡 메트릭(ADR-132)은 `mega worker` 호스트가 `MegaMetrics.subscribeJobs(worker)` 로 구독한다 — `health.exposeMetrics` 옵트인 시 enqueue(dispatch)부터 done/retry/fail/dlq 까지 Prometheus 로 집계되고, 옵트인 OFF 면 no-op 이다.
367
+ 잡 메트릭(ADR-132)은 `mega worker` 호스트가 `MegaMetrics.subscribeJobs(worker)` 로 구독한다 — `health.exposeMetrics` 옵트인 시 enqueue(dispatch)부터 done/retry/fail/dlq 까지 Prometheus 로 집계되고, 옵트인 OFF 면 no-op 이다. 메트릭과 **독립적으로**, 호스트는 같은 이벤트를 **로그로도** 남긴다(ADR-223) — `fail`=error, `dlq`/`retry`=warn, `start`/`done`=debug. 메트릭을 꺼도 잡 실패·DLQ 격리는 로그에 남는다.
341
368
 
342
369
  ---
343
370
 
@@ -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: {
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
7
- "node": ">=20"
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": "^9.0.0",
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) 으로 접속.