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,190 @@
1
+ // @ts-check
2
+ /**
3
+ * ClusterBusDriver — Node `cluster` IPC 기반 메시지 버스 driver (ADR-227).
4
+ *
5
+ * NATS 가 없지만 **클러스터**(`mega start --cluster=N`)로 도는 환경용. 워커들이 **master 프로세스를 fan-out
6
+ * 라우터**로 쓴다 — 워커가 IPC 로 master 에 구독/발행을 보내고, master 가 구독 레지스트리를 관리하며 매칭되는
7
+ * 모든 워커에 메시지를 배달한다. wildcard 매칭은 master 가 {@link matchSubject} 로 수행한다.
8
+ *
9
+ * # ⚠️ 멀티 노드 미보장
10
+ * cluster IPC 는 **한 노드(master + 그 워커들)** 안에서만 닿는다. 노드가 여러 대면 master 도 여러 개라 서로의
11
+ * 구독을 모른다 — **노드 간 전달은 안 된다**. 진짜 분산 버스는 nats-bus 를 쓴다. 영속(persist)도 미지원
12
+ * (메모리 라우팅 — `persist:true` 면 manager 가 경고 후 비영속 전달). factory 가 부팅 경고로 알린다.
13
+ *
14
+ * # 구성
15
+ * - **master 측**: {@link installClusterBusMaster} 를 master 프로세스에서 1회 호출 — 구독 레지스트리 +
16
+ * fan-out + request/reply 라우팅. 워커 종료 시 그 워커 구독 자동 정리(`mega start` 클러스터 분기).
17
+ * - **worker 측**: {@link ClusterBusDriver} 가 `process.send` 로 구독/발행하고, master 의 'deliver' 를 받아
18
+ * 로컬 핸들러를 호출한다. ordered 직렬화·request 응답 대기는 worker 가 로컬 처리한다.
19
+ *
20
+ * driver 계약은 {@link import('./contract.js').BusDriver} 를 따른다(typedef — 런타임 implements 아님).
21
+ * @module core/bus/cluster-bus
22
+ */
23
+ import nodeCluster from 'node:cluster'
24
+ import { matchSubject } from './contract.js'
25
+ import { MegaInternalError } from '../../errors/http-errors.js'
26
+
27
+ /** IPC 메시지 마커 — 다른 cluster 메시지(metrics·lock 등)와 섞이지 않게. */
28
+ const BUS_MSG = 'mega:bus'
29
+ /** master responder 설치 여부(멱등 가드). @type {boolean} */
30
+ let masterInstalled = false
31
+
32
+ /**
33
+ * master 프로세스에 버스 라우터를 설치한다(멱등). 워커들의 구독을 모아 발행 시 fan-out 한다.
34
+ * `mega start` 클러스터 분기에서 master 가 1회 호출한다.
35
+ * @param {import('node:cluster').Cluster} [cluster] - 테스트 주입용(기본 node:cluster).
36
+ * @returns {{ subscriptions: () => number } | null} 관측용 핸들(테스트). 이미 설치됐으면 null.
37
+ */
38
+ export function installClusterBusMaster(cluster = nodeCluster) {
39
+ if (masterInstalled) return null
40
+ masterInstalled = true
41
+ /** @type {Array<{ worker: any, subId: number, pattern: string }>} 구독 레지스트리. */
42
+ const subs = []
43
+ /** @type {Map<string, any>} reqId → 요청 보낸 worker(reply 라우팅용). */
44
+ const requests = new Map()
45
+
46
+ cluster.on('message', (worker, msg) => {
47
+ if (!msg || msg.t !== BUS_MSG) return
48
+ if (msg.op === 'subscribe') {
49
+ subs.push({ worker, subId: msg.subId, pattern: msg.pattern })
50
+ } else if (msg.op === 'unsubscribe') {
51
+ const i = subs.findIndex((s) => s.worker === worker && s.subId === msg.subId)
52
+ if (i >= 0) subs.splice(i, 1)
53
+ } else if (msg.op === 'publish') {
54
+ for (const s of subs) {
55
+ if (matchSubject(s.pattern, msg.subject)) s.worker.send?.({ t: BUS_MSG, op: 'deliver', subId: s.subId, envelope: msg.envelope, subject: msg.subject })
56
+ }
57
+ } else if (msg.op === 'request') {
58
+ const matches = subs.filter((s) => matchSubject(s.pattern, msg.subject))
59
+ if (matches.length === 0) {
60
+ worker.send?.({ t: BUS_MSG, op: 'no_responders', reqId: msg.reqId })
61
+ return
62
+ }
63
+ requests.set(msg.reqId, worker)
64
+ for (const s of matches) s.worker.send?.({ t: BUS_MSG, op: 'deliver', subId: s.subId, envelope: msg.envelope, reqId: msg.reqId, subject: msg.subject })
65
+ } else if (msg.op === 'reply') {
66
+ const requester = requests.get(msg.reqId)
67
+ if (requester) {
68
+ requests.delete(msg.reqId) // 첫 응답만 라우팅(요청자도 settled 가드).
69
+ requester.send?.({ t: BUS_MSG, op: 'deliver-reply', reqId: msg.reqId, envelope: msg.envelope })
70
+ }
71
+ }
72
+ })
73
+
74
+ // 워커 종료 시 그 워커의 구독 정리(stale fan-out 방지).
75
+ cluster.on('exit', (worker) => {
76
+ for (let i = subs.length - 1; i >= 0; i--) {
77
+ if (subs[i].worker === worker) subs.splice(i, 1)
78
+ }
79
+ })
80
+
81
+ return { subscriptions: () => subs.length }
82
+ }
83
+
84
+ /** 테스트 격리용 — master 설치 플래그 초기화. @returns {void} */
85
+ export function _resetClusterBusMaster() {
86
+ masterInstalled = false
87
+ }
88
+
89
+ /**
90
+ * 워커 측 cluster 버스 driver — master 에 IPC 위임 + master 의 deliver 를 로컬 핸들러로 디스패치.
91
+ */
92
+ export class ClusterBusDriver {
93
+ /** @type {NodeJS.Process} master 와의 IPC 채널. */ #proc
94
+ /** @type {(msg: any) => void} 'message' 리스너 참조(close 에서 제거). */ #onMessage
95
+ /** @type {number} 구독 id 시퀀스(워커 로컬). */ #subSeq = 0
96
+ /** @type {number} 요청 id 시퀀스. */ #reqSeq = 0
97
+ /** @type {Map<number, { handler: Function, ordered: boolean, tail: Promise<void> }>} subId → 로컬 핸들러. */ #handlers = new Map()
98
+ /** @type {Map<string, { resolve: (v: any) => void, reject: (e: any) => void, timer: any }>} reqId → 응답 대기. */ #pending = new Map()
99
+
100
+ /**
101
+ * @param {{ proc?: NodeJS.Process }} [opts] - `proc` 테스트 주입용(기본 process). cluster 워커여야 send 존재.
102
+ * @throws {Error} proc.send 가 없으면(cluster 워커 아님).
103
+ */
104
+ constructor({ proc = process } = {}) {
105
+ if (typeof proc.send !== 'function') {
106
+ throw new Error('ClusterBusDriver requires an IPC channel (process.send) — only valid in a cluster worker.')
107
+ }
108
+ this.#proc = proc
109
+ this.#onMessage = (msg) => this.#receive(msg)
110
+ proc.on('message', this.#onMessage)
111
+ }
112
+
113
+ /** @type {'cluster'} */
114
+ get name() {
115
+ return 'cluster'
116
+ }
117
+
118
+ /** master 의 deliver/deliver-reply/no_responders 를 처리. @param {any} msg @returns {void} */
119
+ #receive(msg) {
120
+ if (!msg || msg.t !== BUS_MSG) return
121
+ if (msg.op === 'deliver') {
122
+ const local = this.#handlers.get(msg.subId)
123
+ if (!local) return
124
+ // request 면(reqId 동봉) reply 를 master 로 돌려보내는 replyFn 제공.
125
+ const reply = msg.reqId !== undefined ? (/** @type {any} */ env) => this.#proc.send?.({ t: BUS_MSG, op: 'reply', reqId: msg.reqId, envelope: env }) : undefined
126
+ if (local.ordered) local.tail = local.tail.then(() => local.handler(msg.envelope, reply, msg.subject))
127
+ else void local.handler(msg.envelope, reply, msg.subject)
128
+ } else if (msg.op === 'deliver-reply') {
129
+ const p = this.#pending.get(msg.reqId)
130
+ if (!p) return
131
+ this.#pending.delete(msg.reqId)
132
+ clearTimeout(p.timer)
133
+ p.resolve(msg.envelope)
134
+ } else if (msg.op === 'no_responders') {
135
+ const p = this.#pending.get(msg.reqId)
136
+ if (!p) return
137
+ this.#pending.delete(msg.reqId)
138
+ clearTimeout(p.timer)
139
+ p.reject(new MegaInternalError('bus.no_responders', 'bus request: no subscriber matches the subject (cluster).', { details: {} }))
140
+ }
141
+ }
142
+
143
+ /** cluster 는 persist 무시(opts 는 계약 정합용). @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ persist?: boolean }} [_opts] @returns {Promise<void>} */
144
+ async publish(subject, envelope, _opts = {}) {
145
+ this.#proc.send?.({ t: BUS_MSG, op: 'publish', subject, envelope })
146
+ }
147
+
148
+ /** @param {string} pattern @param {Function} handler @param {{ ordered?: boolean }} opts @returns {Promise<import('./contract.js').Subscription>} */
149
+ async subscribe(pattern, handler, { ordered = false } = {}) {
150
+ const subId = ++this.#subSeq
151
+ this.#handlers.set(subId, { handler, ordered, tail: Promise.resolve() })
152
+ this.#proc.send?.({ t: BUS_MSG, op: 'subscribe', subId, pattern })
153
+ return {
154
+ unsubscribe: async () => {
155
+ this.#handlers.delete(subId)
156
+ this.#proc.send?.({ t: BUS_MSG, op: 'unsubscribe', subId })
157
+ },
158
+ }
159
+ }
160
+
161
+ /** @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ timeout: number }} opts @returns {Promise<import('./contract.js').BusEnvelope>} */
162
+ async request(subject, envelope, { timeout }) {
163
+ const reqId = `${process.pid}:${++this.#reqSeq}`
164
+ return new Promise((resolve, reject) => {
165
+ const timer = setTimeout(() => {
166
+ this.#pending.delete(reqId)
167
+ reject(new MegaInternalError('bus.request_timeout', `bus request("${subject}") timed out after ${timeout}ms.`, { details: { subject, timeout } }))
168
+ }, timeout)
169
+ timer.unref?.()
170
+ this.#pending.set(reqId, { resolve, reject, timer })
171
+ this.#proc.send?.({ t: BUS_MSG, op: 'request', reqId, subject, envelope })
172
+ })
173
+ }
174
+
175
+ /** @returns {Promise<{ driver: string, subscriptions: number }>} */
176
+ async stats() {
177
+ return { driver: 'cluster', subscriptions: this.#handlers.size }
178
+ }
179
+
180
+ /** 정리 — IPC 리스너 제거 + 대기 요청 reject + 핸들러 비움. @returns {Promise<void>} */
181
+ async close() {
182
+ this.#proc.off?.('message', this.#onMessage)
183
+ for (const [, p] of this.#pending) {
184
+ clearTimeout(p.timer)
185
+ p.reject(new MegaInternalError('bus.closed', 'bus closed while request was pending.', { details: {} }))
186
+ }
187
+ this.#pending.clear()
188
+ this.#handlers.clear()
189
+ }
190
+ }
@@ -0,0 +1,123 @@
1
+ // @ts-check
2
+ /**
3
+ * 메시지 버스 공통 contract — driver 인터페이스 + subject 검증/정규화 + NATS 식 wildcard 매처 (ADR-227).
4
+ *
5
+ * `ctx.bus.emit/.on/.request` 사용자 API 는 {@link import('./index.js').BusManager} 가 제공하고, 실제
6
+ * 전달(fan-out·request/reply·영속)은 **driver**(nats/cluster/memory)가 담당한다. driver 는 전송만 하고,
7
+ * subject prefix·envelope(payload+meta) 포장·핸들러 등록부 같은 ergonomics 는 manager 가 driver 위에 얹는다.
8
+ *
9
+ * # 기존 `ctx.bus(alias)` 와의 관계 (ADR-110 ↔ ADR-227)
10
+ * `ctx.bus` 는 **callable object** 다 — `ctx.bus(alias)` 는 종전대로 설정된 `MegaBusAdapter`(NATS 등)를
11
+ * 반환하고(기존 사용처 무변경), 거기에 `.emit/.on/.off/.request/.with` 메서드가 붙어 사용자 메시지 버스 API 가
12
+ * 된다. 락(ADR-226)의 `ctx.lock` 콜러블 공존과 같은 패턴이다.
13
+ *
14
+ * # subject 와 wildcard (NATS 의미 그대로)
15
+ * subject 는 `.` 으로 구분된 토큰열(`order.created`). 구독 패턴엔 wildcard 를 쓸 수 있다:
16
+ * - `*` = **정확히 한 토큰**. `order.*` → `order.created`(O), `order.created.eu`(X).
17
+ * - `>` = **한 토큰 이상(꼬리 전체)**, 맨 끝에만. `order.>` → `order.created`(O), `order.created.eu`(O), `order`(X).
18
+ * nats-bus 는 NATS 가 native 로 매칭하고, cluster/memory-bus 는 {@link matchSubject} 로 자체 매칭한다.
19
+ *
20
+ * @module core/bus/contract
21
+ */
22
+ import { MegaValidationError } from '../../errors/http-errors.js'
23
+
24
+ /** request/reply 응답 대기 디폴트(ms). config `bus.requestTimeoutMs` 로 조정. */
25
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 5000
26
+
27
+ /**
28
+ * @typedef {Object} BusEnvelope - wire/IPC 로 오가는 메시지 봉투(payload 와 meta 분리 — meta 는 traceId 등).
29
+ * @property {any} payload - 사용자 데이터.
30
+ * @property {Record<string, any>} [meta] - 부가 메타(전파용). 없으면 빈 객체로 취급.
31
+ */
32
+
33
+ /**
34
+ * @typedef {Object} EmitOpts - emit 옵션.
35
+ * @property {Record<string, any>} [meta] - 이 메시지의 meta.
36
+ * @property {boolean} [persist] - true 면 영속 전달(JetStream — nats-bus 만; 그 외 driver 는 경고 후 비영속).
37
+ */
38
+
39
+ /**
40
+ * @typedef {Object} OnOpts - on(구독) 옵션.
41
+ * @property {boolean} [persist] - true 면 영속 구독(JetStream consumer — nats-bus 만).
42
+ * @property {boolean} [ordered] - true 면 같은 subject 메시지를 순서대로 1건씩 직렬 처리(핸들러 await 직렬화).
43
+ */
44
+
45
+ /**
46
+ * @typedef {(payload: any, meta: Record<string, any>, subject: string) => any} BusHandler - 사용자 핸들러.
47
+ * 3번째 인자 `subject` 는 메시지가 도착한 **구체** subject(prefix 제거됨) — wildcard 구독에서 어느 subject 가
48
+ * 매칭됐는지 안다. `meta.subject` 에도 같은 값이 담긴다(정본 위치). request 대상이면 **반환값이 reply**.
49
+ */
50
+
51
+ /**
52
+ * @typedef {Object} Subscription - on/subscribe 가 돌려주는 구독 핸들.
53
+ * @property {() => Promise<void>} unsubscribe - 구독 해제.
54
+ */
55
+
56
+ /**
57
+ * @typedef {(envelope: BusEnvelope) => void} ReplyFn - driver 가 핸들러에 주는 응답 함수(request 일 때만).
58
+ *
59
+ * @typedef {Object} BusDriver - driver 가 구현하는 저수준 인터페이스(manager 가 호출).
60
+ * @property {string} name - 'nats' | 'cluster' | 'memory'.
61
+ * @property {(subject: string, envelope: BusEnvelope, opts: { persist?: boolean }) => Promise<void>} publish -
62
+ * fire-and-forget 발행(구독자 전원 fan-out).
63
+ * @property {(subject: string, handler: (envelope: BusEnvelope, reply?: ReplyFn, subject?: string) => any, opts: { persist?: boolean, ordered?: boolean }) => Promise<Subscription>} subscribe -
64
+ * 구독(subject 는 wildcard 가능). `reply` 는 request 로 들어온 메시지일 때만 제공. 핸들러의 3번째 인자는
65
+ * 매칭된 **구체** subject(driver 가 채워 manager 로 넘김 — wildcard 어디로 왔는지 안다).
66
+ * @property {(subject: string, envelope: BusEnvelope, opts: { timeout: number }) => Promise<BusEnvelope>} request -
67
+ * req/reply — 첫 응답 envelope 반환. 응답자 없으면 `bus.no_responders`, 시한 초과면 `bus.request_timeout` throw.
68
+ * @property {() => Promise<{ driver: string, subscriptions: number }>} stats - 현재 구독 수.
69
+ * @property {() => Promise<void>} close - 구독·타이머·IPC·연결참조 정리.
70
+ */
71
+
72
+ /**
73
+ * subject 정규화 — prefix 를 앞에 붙인다(네임스페이스 격리). prefix 가 빈 값이면 그대로.
74
+ * @param {string} subject @param {string} [prefix] - 예: 'app.'.
75
+ * @returns {string}
76
+ */
77
+ export function normalizeSubject(subject, prefix) {
78
+ return prefix ? `${prefix}${subject}` : subject
79
+ }
80
+
81
+ /**
82
+ * subject 검증 — 비어있지 않고 공백/제어문자 없는 문자열. publish subject 는 wildcard 금지(구체적이어야 함).
83
+ * @param {string} subject @param {{ allowWildcard?: boolean }} [opts] - true 면 `*`/`>` 허용(구독용).
84
+ * @returns {void}
85
+ * @throws {MegaValidationError} `bus.invalid_subject`
86
+ */
87
+ export function assertSubject(subject, { allowWildcard = false } = {}) {
88
+ if (typeof subject !== 'string' || subject.length === 0) {
89
+ throw new MegaValidationError('bus.invalid_subject', `bus subject must be a non-empty string. Got: ${typeof subject}.`, { details: { subject } })
90
+ }
91
+ if (/\s/.test(subject)) {
92
+ throw new MegaValidationError('bus.invalid_subject', `bus subject must not contain whitespace. Got: ${JSON.stringify(subject)}.`, { details: { subject } })
93
+ }
94
+ if (!allowWildcard && /[*>]/.test(subject)) {
95
+ throw new MegaValidationError('bus.invalid_subject', `bus publish subject must be concrete (no '*'/'>' wildcards). Got: ${JSON.stringify(subject)}.`, { details: { subject } })
96
+ }
97
+ if (allowWildcard) {
98
+ const tokens = subject.split('.')
99
+ const gtIdx = tokens.indexOf('>')
100
+ if (gtIdx !== -1 && gtIdx !== tokens.length - 1) {
101
+ throw new MegaValidationError('bus.invalid_subject', `bus '>' wildcard must be the last token. Got: ${JSON.stringify(subject)}.`, { details: { subject } })
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * NATS 식 wildcard 매칭 — cluster/memory-bus 의 자체 패턴 매칭(nats-bus 는 NATS native 사용).
108
+ * `*` = 한 토큰, `>` = 한 토큰 이상(꼬리). 토큰 수가 정확히 맞아야 매칭(`>` 제외).
109
+ * @param {string} pattern - 구독 패턴(wildcard 가능). @param {string} subject - 구체 subject.
110
+ * @returns {boolean}
111
+ */
112
+ export function matchSubject(pattern, subject) {
113
+ if (pattern === subject) return true
114
+ const p = pattern.split('.')
115
+ const s = subject.split('.')
116
+ for (let i = 0; i < p.length; i++) {
117
+ if (p[i] === '>') return s.length > i // 꼬리 전체(한 토큰 이상 남아야).
118
+ if (i >= s.length) return false
119
+ if (p[i] === '*') continue // 한 토큰 매치.
120
+ if (p[i] !== s[i]) return false
121
+ }
122
+ return p.length === s.length // wildcard 없는 잔여 — 토큰 수 일치해야.
123
+ }
@@ -0,0 +1,285 @@
1
+ // @ts-check
2
+ /**
3
+ * 메시지 버스 — BusManager(사용자 API) + 자동 폴백 factory + `ctx.bus` 부착 (ADR-227).
4
+ *
5
+ * # 두 층
6
+ * - **driver**(nats/cluster/memory): 저수준 전달만(`publish/subscribe/request/stats/close`).
7
+ * {@link import('./contract.js').BusDriver} 계약.
8
+ * - **BusManager**: driver 위에 사용자 ergonomics — `emit`/`on`(핸들러 = payload,meta)/`off`/`request`/
9
+ * `with`(meta 바인딩) + subject prefix + envelope 포장 + 핸들러 에러 격리(driver 로 reject 전파 안 함).
10
+ *
11
+ * # 자동 폴백 (`driver: 'auto'`, 기본)
12
+ * bus(NATS) 어댑터 활성 → **nats**(진짜 분산). 없고 cluster 워커 → **cluster**(단일 노드, 경고). 둘 다 없음
13
+ * → **memory**(분산 미보장, 경고). 명시 driver 는 전제 부재 시 **fail-fast**(P7).
14
+ *
15
+ * # `ctx.bus` 통합
16
+ * `ctx.bus` 는 종전대로 `(alias) => MegaBusAdapter` 콜러블이고, {@link attachBusApi} 가
17
+ * `.emit/.on/.off/.request/.with/.stats` 를 얹는다. `ctx.bus(alias)`(기존)와 `ctx.bus.emit(...)`(신규) 공존.
18
+ *
19
+ * @module core/bus
20
+ */
21
+ import { MegaValidationError } from '../../errors/http-errors.js'
22
+ import { assertSubject, normalizeSubject, DEFAULT_REQUEST_TIMEOUT_MS } from './contract.js'
23
+ import { MemoryBusDriver } from './memory-bus.js'
24
+ import { ClusterBusDriver } from './cluster-bus.js'
25
+ import { NatsBusDriver } from './nats-bus.js'
26
+
27
+ export { MemoryBusDriver, ClusterBusDriver, NatsBusDriver }
28
+
29
+ /**
30
+ * 프로세스 전역 활성 manager — boot 가 설정하고 ctx-builder 가 `ctx.bus` 부착 시 읽는다(락과 동일 패턴).
31
+ * @type {BusManager | null}
32
+ */
33
+ let activeManager = null
34
+
35
+ /** 활성 버스 manager 설정(boot bus 스테이지). null 로 해제(셧다운/테스트). @param {BusManager | null} m @returns {void} */
36
+ export function setBusManager(m) {
37
+ activeManager = m
38
+ }
39
+
40
+ /** 현재 활성 버스 manager(없으면 null). @returns {BusManager | null} */
41
+ export function getBusManager() {
42
+ return activeManager
43
+ }
44
+
45
+ /**
46
+ * 사용자 메시지 버스 API. driver 한 개를 감싸 emit/on/off/request/with 를 제공한다.
47
+ */
48
+ export class BusManager {
49
+ /** @type {import('./contract.js').BusDriver} */ #driver
50
+ /** @type {{ prefix: string, defaultPersist: boolean, requestTimeoutMs: number }} */ #defaults
51
+ /** @type {any} 로거(옵션). */ #logger
52
+ /** @type {Array<{ subject: string, handler: Function, subscription: import('./contract.js').Subscription }>} off() 추적. */ #regs = []
53
+ /** @type {boolean} persist 미지원 driver 경고 1회 가드. */ #persistWarned = false
54
+
55
+ /**
56
+ * @param {import('./contract.js').BusDriver} driver
57
+ * @param {{ defaults: { prefix: string, defaultPersist: boolean, requestTimeoutMs: number }, logger?: any }} opts
58
+ */
59
+ constructor(driver, { defaults, logger }) {
60
+ this.#driver = driver
61
+ this.#defaults = defaults
62
+ this.#logger = logger
63
+ }
64
+
65
+ /** @returns {string} 활성 driver 이름('nats'|'cluster'|'memory'). */
66
+ get driverName() {
67
+ return this.#driver.name
68
+ }
69
+
70
+ /**
71
+ * 메시지 발행(fire-and-forget fan-out).
72
+ * @param {string} subject - 구체 subject(wildcard 불가).
73
+ * @param {any} payload
74
+ * @param {import('./contract.js').EmitOpts} [opts] - meta / persist.
75
+ * @returns {Promise<void>}
76
+ */
77
+ async emit(subject, payload, opts = {}) {
78
+ assertSubject(subject)
79
+ const full = normalizeSubject(subject, this.#defaults.prefix)
80
+ const persist = this.#resolvePersist(opts.persist)
81
+ this.#logger?.debug?.({ subject: full, driver: this.#driver.name, persist }, 'bus.emit')
82
+ await this.#driver.publish(full, { payload, meta: opts.meta ?? {} }, { persist })
83
+ }
84
+
85
+ /**
86
+ * 구독. 핸들러는 `(payload, meta)`. request 대상이면 핸들러 **반환값이 reply**(undefined 면 응답 안 함).
87
+ * @param {string} subject - wildcard(`*`/`>`) 가능.
88
+ * @param {import('./contract.js').BusHandler} handler
89
+ * @param {import('./contract.js').OnOpts} [opts] - persist / ordered.
90
+ * @returns {Promise<import('./contract.js').Subscription>}
91
+ */
92
+ async on(subject, handler, opts = {}) {
93
+ assertSubject(subject, { allowWildcard: true })
94
+ if (typeof handler !== 'function') {
95
+ throw new MegaValidationError('bus.invalid_handler', `ctx.bus.on(subject, handler) requires a function. Got: ${typeof handler}.`, { details: { subject } })
96
+ }
97
+ const full = normalizeSubject(subject, this.#defaults.prefix)
98
+ const persist = this.#resolvePersist(opts.persist)
99
+ // driver 로 넘기는 wrapper — 사용자 핸들러 에러를 여기서 흡수(driver 는 reject 를 못 받는다). reply 처리도 여기.
100
+ // driver 가 넘긴 구체 subject 에서 prefix 를 떼어(사용자는 unprefixed 로 생각) 3번째 인자 + meta.subject 로 준다.
101
+ const wrapped = async (/** @type {import('./contract.js').BusEnvelope} */ env, /** @type {import('./contract.js').ReplyFn} */ reply, /** @type {string} */ subject) => {
102
+ const userSubject = this.#stripPrefix(subject)
103
+ const meta = userSubject === undefined ? (env.meta ?? {}) : { ...(env.meta ?? {}), subject: userSubject }
104
+ try {
105
+ const result = await handler(env.payload, meta, /** @type {any} */ (userSubject))
106
+ if (reply && result !== undefined) reply({ payload: result, meta: {} })
107
+ } catch (e) {
108
+ // 핸들러 실패는 비치명적(다른 구독자·다음 메시지에 영향 없게) — 로깅하고 흡수(P4: 무시 아님, 격리).
109
+ this.#logger?.error?.({ subject: full, driver: this.#driver.name, err: e }, 'bus.on handler threw')
110
+ }
111
+ }
112
+ const subscription = await this.#driver.subscribe(full, wrapped, { persist, ordered: opts.ordered === true })
113
+ this.#regs.push({ subject: full, handler, subscription })
114
+ this.#logger?.debug?.({ subject: full, driver: this.#driver.name, persist, ordered: opts.ordered === true }, 'bus.on subscribed')
115
+ return subscription
116
+ }
117
+
118
+ /**
119
+ * 구독 해제 — 같은 (subject, handler) 로 등록된 구독을 푼다.
120
+ * @param {string} subject @param {import('./contract.js').BusHandler} handler @returns {Promise<void>}
121
+ */
122
+ async off(subject, handler) {
123
+ const full = normalizeSubject(subject, this.#defaults.prefix)
124
+ const i = this.#regs.findIndex((r) => r.subject === full && r.handler === handler)
125
+ if (i < 0) return // 등록 안 됐거나 이미 해제 — idempotent.
126
+ const [reg] = this.#regs.splice(i, 1)
127
+ await reg.subscription.unsubscribe()
128
+ }
129
+
130
+ /**
131
+ * req/reply — 첫 응답 payload 반환. 응답자 없으면 `bus.no_responders`, 시한 초과면 `bus.request_timeout`.
132
+ * @param {string} subject @param {any} payload
133
+ * @param {{ timeout?: number, meta?: Record<string, any> }} [opts]
134
+ * @returns {Promise<any>}
135
+ */
136
+ async request(subject, payload, opts = {}) {
137
+ assertSubject(subject)
138
+ const full = normalizeSubject(subject, this.#defaults.prefix)
139
+ const timeout = opts.timeout ?? this.#defaults.requestTimeoutMs
140
+ this.#logger?.debug?.({ subject: full, driver: this.#driver.name, timeout }, 'bus.request')
141
+ const reply = await this.#driver.request(full, { payload, meta: opts.meta ?? {} }, { timeout })
142
+ return reply.payload
143
+ }
144
+
145
+ /**
146
+ * meta 를 바인딩한 스코프 버스 — 이후 emit/request 가 이 meta 를 자동 병합한다(요청 단위 traceId 등).
147
+ * @param {Record<string, any>} meta @returns {{ emit: Function, on: Function, off: Function, request: Function, with: Function }}
148
+ */
149
+ with(meta) {
150
+ const self = this
151
+ /** @param {Record<string, any>} [m] */
152
+ const merge = (m) => ({ ...meta, ...m })
153
+ return {
154
+ emit: (/** @type {string} */ s, /** @type {any} */ p, /** @type {import('./contract.js').EmitOpts} */ o = {}) => self.emit(s, p, { ...o, meta: merge(o.meta) }),
155
+ request: (/** @type {string} */ s, /** @type {any} */ p, /** @type {any} */ o = {}) => self.request(s, p, { ...o, meta: merge(o.meta) }),
156
+ on: (/** @type {string} */ s, /** @type {any} */ h, /** @type {any} */ o) => self.on(s, h, o),
157
+ off: (/** @type {string} */ s, /** @type {any} */ h) => self.off(s, h),
158
+ with: (/** @type {Record<string, any>} */ m) => self.with(merge(m)),
159
+ }
160
+ }
161
+
162
+ /** @returns {Promise<{ driver: string, subscriptions: number }>} */
163
+ async stats() {
164
+ return this.#driver.stats()
165
+ }
166
+
167
+ /** 정리 — 등록 구독 해제 + driver 정리. @returns {Promise<void>} */
168
+ async close() {
169
+ for (const reg of this.#regs.splice(0)) {
170
+ try {
171
+ await reg.subscription.unsubscribe()
172
+ } catch (e) {
173
+ this.#logger?.warn?.({ subject: reg.subject, err: e }, 'bus.close: unsubscribe failed (continuing)')
174
+ }
175
+ }
176
+ await this.#driver.close()
177
+ }
178
+
179
+ /**
180
+ * persist 요청을 driver 능력에 맞춰 해석 — nats 만 영속 지원. 그 외 driver 면 1회 경고 후 비영속.
181
+ * @param {boolean | undefined} requested @returns {boolean}
182
+ */
183
+ #resolvePersist(requested) {
184
+ const want = requested ?? this.#defaults.defaultPersist
185
+ if (want && this.#driver.name !== 'nats') {
186
+ if (!this.#persistWarned) {
187
+ this.#persistWarned = true
188
+ this.#logger?.warn?.({ driver: this.#driver.name }, `bus: persist:true requires the nats driver — '${this.#driver.name}' delivers non-persistently. Use NATS for durable messaging.`)
189
+ }
190
+ return false
191
+ }
192
+ return want
193
+ }
194
+
195
+ /**
196
+ * driver 가 넘긴 구체 subject 에서 config prefix 를 떼어 사용자 관점(unprefixed)으로 되돌린다.
197
+ * @param {string | undefined} subject @returns {string | undefined}
198
+ */
199
+ #stripPrefix(subject) {
200
+ if (subject === undefined) return undefined
201
+ const p = this.#defaults.prefix
202
+ return p && subject.startsWith(p) ? subject.slice(p.length) : subject
203
+ }
204
+ }
205
+
206
+ /**
207
+ * config 의 bus 섹션에서 manager 기본값을 뽑는다.
208
+ * @param {{ prefix?: string, defaultPersist?: boolean, requestTimeoutMs?: number }} busConfig
209
+ * @returns {{ prefix: string, defaultPersist: boolean, requestTimeoutMs: number }}
210
+ */
211
+ function resolveDefaults(busConfig) {
212
+ return {
213
+ prefix: typeof busConfig.prefix === 'string' ? busConfig.prefix : '',
214
+ defaultPersist: busConfig.defaultPersist === true,
215
+ requestTimeoutMs: Number.isInteger(busConfig.requestTimeoutMs) ? /** @type {number} */ (busConfig.requestTimeoutMs) : DEFAULT_REQUEST_TIMEOUT_MS,
216
+ }
217
+ }
218
+
219
+ /**
220
+ * 요청 driver(또는 'auto')와 가용 자원으로 driver 를 고른다.
221
+ * @param {string} requested - 'auto'|'nats'|'cluster'|'memory'.
222
+ * @param {{ logger?: any, nc: any, isClusterWorker: boolean }} env
223
+ * @returns {import('./contract.js').BusDriver}
224
+ * @throws {MegaValidationError} 명시 driver 전제 부재 또는 알 수 없는 driver.
225
+ */
226
+ function selectDriver(requested, { logger, nc, isClusterWorker }) {
227
+ switch (requested) {
228
+ case 'memory':
229
+ return new MemoryBusDriver()
230
+ case 'cluster':
231
+ if (!isClusterWorker) {
232
+ throw new MegaValidationError('bus.driver_unavailable', "bus.driver='cluster' requires running as a cluster worker (mega start --cluster=N). Use 'auto' or 'memory' for single-process.", { details: { requested } })
233
+ }
234
+ return new ClusterBusDriver()
235
+ case 'nats':
236
+ if (!nc) {
237
+ throw new MegaValidationError('bus.driver_unavailable', "bus.driver='nats' requires a NATS bus adapter — set config bus.nats to a buses alias.", { details: { requested } })
238
+ }
239
+ return new NatsBusDriver({ nc })
240
+ case 'auto': {
241
+ if (nc) {
242
+ logger?.debug?.({ driver: 'nats' }, 'bus: auto-selected nats driver')
243
+ return new NatsBusDriver({ nc })
244
+ }
245
+ if (isClusterWorker) {
246
+ logger?.warn?.('bus: auto-selected cluster driver (no NATS) — delivery is single-node only, NOT across nodes. Use NATS for true distributed messaging.')
247
+ return new ClusterBusDriver()
248
+ }
249
+ logger?.warn?.('bus: auto-selected memory driver (no NATS, no cluster) — single-process only, NOT distributed. Use NATS (or cluster) in production.')
250
+ return new MemoryBusDriver()
251
+ }
252
+ default:
253
+ throw new MegaValidationError('bus.invalid_driver', `unknown bus.driver '${requested}'. Use 'auto' | 'nats' | 'cluster' | 'memory'.`, { details: { requested } })
254
+ }
255
+ }
256
+
257
+ /**
258
+ * 메시지 버스 manager 를 만든다 — driver 자동 폴백 포함. boot 가 1회 호출해 `ctx.bus` 에 부착한다.
259
+ * @param {{ driver?: string, nats?: string, prefix?: string, defaultPersist?: boolean, requestTimeoutMs?: number }} [busConfig]
260
+ * @param {{ logger?: any, nc?: any, isClusterWorker?: boolean }} [deps] - `nc` boot 가 bus 어댑터에서 빌린 NatsConnection(없으면 null).
261
+ * @returns {BusManager}
262
+ */
263
+ export function createBusManager(busConfig = {}, deps = {}) {
264
+ const { logger, nc = null, isClusterWorker = false } = deps
265
+ const requested = busConfig.driver ?? 'auto'
266
+ const driver = selectDriver(requested, { logger, nc, isClusterWorker })
267
+ const manager = new BusManager(driver, { defaults: resolveDefaults(busConfig), logger })
268
+ logger?.debug?.({ requested, selected: driver.name }, 'bus: manager created')
269
+ return manager
270
+ }
271
+
272
+ /**
273
+ * `ctx.bus` 콜러블에 사용자 API 메서드를 얹는다 — `ctx.bus(alias)`(기존)와 `ctx.bus.emit(...)`(신규) 공존.
274
+ * @param {Function & Record<string, any>} busAccessor - `(alias) => MegaBusAdapter` 콜러블(ctx-builder 산출).
275
+ * @param {BusManager} manager @returns {Function & Record<string, any>} 같은 accessor(체이닝).
276
+ */
277
+ export function attachBusApi(busAccessor, manager) {
278
+ busAccessor.emit = manager.emit.bind(manager)
279
+ busAccessor.on = manager.on.bind(manager)
280
+ busAccessor.off = manager.off.bind(manager)
281
+ busAccessor.request = manager.request.bind(manager)
282
+ busAccessor.with = manager.with.bind(manager)
283
+ busAccessor.stats = manager.stats.bind(manager)
284
+ return busAccessor
285
+ }