mega-framework 0.1.10 → 0.1.13
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 +110 -6
- 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/templates/model/code-mongo.tpl +1 -9
- package/templates/model/code.tpl +1 -10
- package/templates/model/test-mongo.tpl +1 -23
- package/templates/model/test.tpl +0 -17
- 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,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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* MemoryBusDriver — 단일 프로세스 in-memory 메시지 버스 driver (ADR-227).
|
|
4
|
+
*
|
|
5
|
+
* Node 이벤트루프 위의 구독 목록 fan-out. **단일 프로세스 안에서만** 전달하므로 멀티 프로세스·멀티 노드에는
|
|
6
|
+
* 닿지 않는다 — factory 가 memory 를 고를 때 부팅 경고를 낸다(`src/core/bus/index.js`). 개발/테스트, 또는
|
|
7
|
+
* 분산이 불필요한 단일 인스턴스용. wildcard 는 {@link matchSubject} 로 자체 매칭한다.
|
|
8
|
+
*
|
|
9
|
+
* driver 계약은 {@link import('./contract.js').BusDriver} 를 따른다(typedef — 런타임 implements 아님).
|
|
10
|
+
* 핸들러 호출은 manager 가 넘긴 wrapper 라 **절대 reject 하지 않는다**(사용자 에러는 manager 가 잡아 로깅) —
|
|
11
|
+
* 그래서 driver 는 `void handler(...)` 로 안전하게 던지고 fan-out 을 막지 않는다.
|
|
12
|
+
*
|
|
13
|
+
* @module core/bus/memory-bus
|
|
14
|
+
*/
|
|
15
|
+
import { matchSubject } from './contract.js'
|
|
16
|
+
import { MegaInternalError } from '../../errors/http-errors.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} MemorySub - 구독 레코드.
|
|
20
|
+
* @property {string} pattern @property {(envelope: import('./contract.js').BusEnvelope, reply?: import('./contract.js').ReplyFn, subject?: string) => any} handler
|
|
21
|
+
* @property {boolean} ordered @property {Promise<void>} tail - ordered 직렬화용 꼬리 프라미스.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export class MemoryBusDriver {
|
|
25
|
+
/** @type {Map<number, MemorySub>} 구독 id → 레코드. */ #subs = new Map()
|
|
26
|
+
/** @type {number} 구독 id 시퀀스. */ #seq = 0
|
|
27
|
+
|
|
28
|
+
/** @type {'memory'} */
|
|
29
|
+
get name() {
|
|
30
|
+
return 'memory'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 매칭 구독에 fan-out. memory 는 persist 무시(opts 는 계약 정합용). @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ persist?: boolean }} [_opts] @returns {Promise<void>} */
|
|
34
|
+
async publish(subject, envelope, _opts = {}) {
|
|
35
|
+
for (const sub of this.#subs.values()) {
|
|
36
|
+
if (matchSubject(sub.pattern, subject)) this.#deliver(sub, envelope, subject)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 구독 등록. @param {string} pattern @param {MemorySub['handler']} handler @param {{ ordered?: boolean }} opts @returns {Promise<import('./contract.js').Subscription>} */
|
|
41
|
+
async subscribe(pattern, handler, { ordered = false } = {}) {
|
|
42
|
+
const id = ++this.#seq
|
|
43
|
+
this.#subs.set(id, { pattern, handler, ordered, tail: Promise.resolve() })
|
|
44
|
+
return {
|
|
45
|
+
unsubscribe: async () => {
|
|
46
|
+
this.#subs.delete(id)
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* req/reply — 매칭 핸들러에 replyFn 을 주고 첫 응답을 반환. 응답자 없으면 no_responders, 시한 초과면 timeout.
|
|
53
|
+
* @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ timeout: number }} opts
|
|
54
|
+
* @returns {Promise<import('./contract.js').BusEnvelope>}
|
|
55
|
+
*/
|
|
56
|
+
async request(subject, envelope, { timeout }) {
|
|
57
|
+
const matches = [...this.#subs.values()].filter((s) => matchSubject(s.pattern, subject))
|
|
58
|
+
if (matches.length === 0) {
|
|
59
|
+
throw new MegaInternalError('bus.no_responders', `bus request("${subject}"): no subscriber matches the subject.`, { details: { subject } })
|
|
60
|
+
}
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
let settled = false
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
if (settled) return
|
|
65
|
+
settled = true
|
|
66
|
+
reject(new MegaInternalError('bus.request_timeout', `bus request("${subject}") timed out after ${timeout}ms.`, { details: { subject, timeout } }))
|
|
67
|
+
}, timeout)
|
|
68
|
+
timer.unref?.()
|
|
69
|
+
/** @type {import('./contract.js').ReplyFn} */
|
|
70
|
+
const reply = (replyEnv) => {
|
|
71
|
+
if (settled) return // 첫 응답만 채택(NATS request 의미).
|
|
72
|
+
settled = true
|
|
73
|
+
clearTimeout(timer)
|
|
74
|
+
resolve(replyEnv)
|
|
75
|
+
}
|
|
76
|
+
for (const sub of matches) this.#deliver(sub, envelope, subject, reply)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @returns {Promise<{ driver: string, subscriptions: number }>} */
|
|
81
|
+
async stats() {
|
|
82
|
+
return { driver: 'memory', subscriptions: this.#subs.size }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 정리 — 구독 비움. @returns {Promise<void>} */
|
|
86
|
+
async close() {
|
|
87
|
+
this.#subs.clear()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 한 구독에 전달 — ordered 면 꼬리 프라미스에 직렬 연결, 아니면 즉시 호출. handler 는 reject 안 하므로 안전.
|
|
92
|
+
* `subject` 는 매칭된 **구체** subject(wildcard 가 아닌 실제 발행 subject) — 핸들러가 어디로 왔는지 안다.
|
|
93
|
+
* @param {MemorySub} sub @param {import('./contract.js').BusEnvelope} envelope @param {string} subject @param {import('./contract.js').ReplyFn} [reply]
|
|
94
|
+
* @returns {void}
|
|
95
|
+
*/
|
|
96
|
+
#deliver(sub, envelope, subject, reply) {
|
|
97
|
+
if (sub.ordered) {
|
|
98
|
+
sub.tail = sub.tail.then(() => sub.handler(envelope, reply, subject))
|
|
99
|
+
} else {
|
|
100
|
+
void sub.handler(envelope, reply, subject)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* NatsBusDriver — NATS 기반 **분산** 메시지 버스 driver (ADR-227).
|
|
4
|
+
*
|
|
5
|
+
* 멀티 프로세스·멀티 노드에 닿는 디폴트 driver. NATS core pub/sub(실시간 fan-out·비영속) 위에서 동작하고,
|
|
6
|
+
* `persist:true` 옵션이면 JetStream(영속 저장 + 재시작 후 재전달)으로 전환한다. NATS 가 wildcard·request/reply 를
|
|
7
|
+
* native 로 지원하므로 그대로 위임한다(매처 자체 구현 불필요). nats v3(`@nats-io/*`, ADR-225) 위에서 돈다.
|
|
8
|
+
*
|
|
9
|
+
* NATS 연결은 **bus 어댑터가 소유한 것을 빌린다**(`config.bus.nats` = 버스 어댑터 alias). 별도 연결을 만들지
|
|
10
|
+
* 않으므로 클러스터 broadcast(ADR-176)·잡 큐(ADR-119)와 **같은 NatsConnection** 을 공유한다 — close 에서 nc 를
|
|
11
|
+
* 닫지 않는다(생애주기는 어댑터 소유).
|
|
12
|
+
*
|
|
13
|
+
* # 영속(persist) — 비영속과 subject 공간 분리
|
|
14
|
+
* JetStream 스트림은 설정된 subject 로 **들어오는 모든 메시지**를 저장하므로, core(비영속) 메시지까지 스트림에
|
|
15
|
+
* 잡히면 이중 저장된다. 이를 막으려 영속 메시지는 내부 prefix `_mbusp.` 를 붙인 별도 subject 로 보낸다 —
|
|
16
|
+
* 영속 emit↔on 은 이 공간에서 만나고, core 와 섞이지 않는다. 스트림 1개(`MEGABUS_PERSIST`)가 `_mbusp.>` 를
|
|
17
|
+
* 잡고, 영속 구독은 그 위에 ephemeral consumer(filter_subject=패턴)를 만든다.
|
|
18
|
+
*
|
|
19
|
+
* driver 계약은 {@link import('./contract.js').BusDriver} 를 따른다(typedef — 런타임 implements 아님).
|
|
20
|
+
* @module core/bus/nats-bus
|
|
21
|
+
*/
|
|
22
|
+
import { encodeJson, decodeJson } from '../../adapters/nats-codec.js'
|
|
23
|
+
import { MegaInternalError } from '../../errors/http-errors.js'
|
|
24
|
+
|
|
25
|
+
/** 영속 메시지 subject 내부 prefix(core 와 분리). */
|
|
26
|
+
const PERSIST_PREFIX = '_mbusp.'
|
|
27
|
+
/** 영속 스트림 이름(공유 — subject prefix 로 앱 격리). */
|
|
28
|
+
const PERSIST_STREAM = 'MEGABUS_PERSIST'
|
|
29
|
+
|
|
30
|
+
export class NatsBusDriver {
|
|
31
|
+
/** @type {any} 빌린 NatsConnection(bus 어댑터 소유). */ #nc
|
|
32
|
+
/** @type {any} @nats-io/jetstream 모듈(lazy). */ #js = null
|
|
33
|
+
/** @type {(ms: number) => number} nanos(ms→ns) — @nats-io/nats-core(lazy). */ #nanos = (ms) => ms
|
|
34
|
+
/** @type {any} JetStreamClient(lazy). */ #jsc = null
|
|
35
|
+
/** @type {any} JetStreamManager(lazy). */ #jsm = null
|
|
36
|
+
/** @type {Promise<void> | null} JetStream 초기화 직렬화. */ #jsInit = null
|
|
37
|
+
/** @type {boolean} 영속 스트림 보장 완료. */ #streamEnsured = false
|
|
38
|
+
/** @type {Set<{ stop: () => void }>} 활성 영속 consumer(close 에서 정리). */ #persistSubs = new Set()
|
|
39
|
+
|
|
40
|
+
/** @param {{ nc: any }} opts - `nc` 빌린 NatsConnection(`busAdapter.native`). */
|
|
41
|
+
constructor({ nc }) {
|
|
42
|
+
this.#nc = nc
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @type {'nats'} */
|
|
46
|
+
get name() {
|
|
47
|
+
return 'nats'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 발행 — persist 면 JetStream(영속), 아니면 core pub/sub(비영속).
|
|
52
|
+
* @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ persist?: boolean }} opts
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
*/
|
|
55
|
+
async publish(subject, envelope, { persist = false } = {}) {
|
|
56
|
+
if (!persist) {
|
|
57
|
+
this.#nc.publish(subject, encodeJson(envelope))
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
await this.#ensureJetStream()
|
|
61
|
+
await this.#jsc.publish(`${PERSIST_PREFIX}${subject}`, encodeJson(envelope))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 구독 — persist 면 JetStream ephemeral consumer, 아니면 core 구독. core 는 wildcard·reply 를 native 지원.
|
|
66
|
+
* @param {string} pattern @param {(envelope: import('./contract.js').BusEnvelope, reply?: import('./contract.js').ReplyFn, subject?: string) => any} handler
|
|
67
|
+
* @param {{ persist?: boolean, ordered?: boolean }} opts @returns {Promise<import('./contract.js').Subscription>}
|
|
68
|
+
*/
|
|
69
|
+
async subscribe(pattern, handler, { persist = false, ordered = false } = {}) {
|
|
70
|
+
if (persist) return this.#subscribePersistent(pattern, handler, ordered)
|
|
71
|
+
let tail = Promise.resolve()
|
|
72
|
+
const sub = this.#nc.subscribe(pattern, {
|
|
73
|
+
callback: (/** @type {any} */ err, /** @type {any} */ msg) => {
|
|
74
|
+
if (err) return // 구독 에러(드물게)는 NATS 가 별도 보고 — 콜백 인자로 온 err 는 무시 가능(P4: 비치명적).
|
|
75
|
+
const envelope = decodeJson(msg.data)
|
|
76
|
+
// request 로 온 메시지면(reply subject 존재) replyFn 제공. msg.subject = 매칭된 구체 subject(NATS native).
|
|
77
|
+
const reply = msg.reply ? (/** @type {any} */ env) => this.#nc.publish(msg.reply, encodeJson(env)) : undefined
|
|
78
|
+
if (ordered) tail = tail.then(() => handler(envelope, reply, msg.subject))
|
|
79
|
+
else void handler(envelope, reply, msg.subject)
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
return {
|
|
83
|
+
unsubscribe: async () => {
|
|
84
|
+
sub.unsubscribe()
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* req/reply — NATS native request. 첫 응답 envelope 반환. v3 에러 클래스로 timeout/no-responders 판별.
|
|
91
|
+
* @param {string} subject @param {import('./contract.js').BusEnvelope} envelope @param {{ timeout: number }} opts
|
|
92
|
+
* @returns {Promise<import('./contract.js').BusEnvelope>}
|
|
93
|
+
*/
|
|
94
|
+
async request(subject, envelope, { timeout }) {
|
|
95
|
+
try {
|
|
96
|
+
const reply = await this.#nc.request(subject, encodeJson(envelope), { timeout })
|
|
97
|
+
return decodeJson(reply.data)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// v3(ADR-225): timeout/무응답이 에러 클래스. cold path 라 catch 에서 lazy import(모듈은 이미 로드 — 비용 0).
|
|
100
|
+
const { TimeoutError, NoRespondersError, RequestError } = await import('@nats-io/nats-core')
|
|
101
|
+
if (err instanceof TimeoutError) {
|
|
102
|
+
throw new MegaInternalError('bus.request_timeout', `bus request("${subject}") timed out after ${timeout}ms.`, { details: { subject, timeout }, cause: err })
|
|
103
|
+
}
|
|
104
|
+
if (err instanceof NoRespondersError || (err instanceof RequestError && err.isNoResponders())) {
|
|
105
|
+
throw new MegaInternalError('bus.no_responders', `bus request("${subject}"): no responders subscribed.`, { details: { subject }, cause: err })
|
|
106
|
+
}
|
|
107
|
+
throw err
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @returns {Promise<{ driver: string, subscriptions: number }>} 영속 구독 수(core 구독은 NATS 측 관리라 미집계). */
|
|
112
|
+
async stats() {
|
|
113
|
+
return { driver: 'nats', subscriptions: this.#persistSubs.size }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 정리 — 영속 consumer 정지. nc 는 어댑터 소유라 닫지 않는다. @returns {Promise<void>} */
|
|
117
|
+
async close() {
|
|
118
|
+
for (const sub of this.#persistSubs) sub.stop()
|
|
119
|
+
this.#persistSubs.clear()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 영속 구독 — 영속 스트림 위에 ephemeral consumer 를 만들고 consume 루프로 배달 + ack.
|
|
124
|
+
* @param {string} pattern @param {Function} handler @param {boolean} ordered
|
|
125
|
+
* @returns {Promise<import('./contract.js').Subscription>}
|
|
126
|
+
*/
|
|
127
|
+
async #subscribePersistent(pattern, handler, ordered) {
|
|
128
|
+
await this.#ensureJetStream()
|
|
129
|
+
const filter = `${PERSIST_PREFIX}${pattern}`
|
|
130
|
+
// ephemeral consumer(durable_name 없음 — inactive 시 서버가 자동 삭제). filter 로 패턴 한정.
|
|
131
|
+
const ci = await this.#jsm.consumers.add(PERSIST_STREAM, {
|
|
132
|
+
ack_policy: this.#js.AckPolicy.Explicit,
|
|
133
|
+
filter_subject: filter,
|
|
134
|
+
inactive_threshold: this.#nanos(30_000), // 끊긴 consumer 30s 후 서버 정리.
|
|
135
|
+
})
|
|
136
|
+
const consumer = await this.#jsc.consumers.get(PERSIST_STREAM, ci.name)
|
|
137
|
+
const messages = await consumer.consume()
|
|
138
|
+
let stopped = false
|
|
139
|
+
let tail = Promise.resolve()
|
|
140
|
+
// 배달 루프 — 별도 비동기. envelope 디코드 → 핸들러 → ack. 영속은 request/reply 미지원(reply 없음).
|
|
141
|
+
;(async () => {
|
|
142
|
+
for await (const msg of messages) {
|
|
143
|
+
if (stopped) break
|
|
144
|
+
const envelope = decodeJson(msg.data)
|
|
145
|
+
// 저장 subject 는 내부 persist prefix 가 붙어 있으니 떼고 넘긴다 → manager 는 user prefix 만 안다.
|
|
146
|
+
const subject = msg.subject.startsWith(PERSIST_PREFIX) ? msg.subject.slice(PERSIST_PREFIX.length) : msg.subject
|
|
147
|
+
if (ordered) tail = tail.then(() => handler(envelope, undefined, subject))
|
|
148
|
+
else void handler(envelope, undefined, subject)
|
|
149
|
+
msg.ack()
|
|
150
|
+
}
|
|
151
|
+
})().catch(() => {
|
|
152
|
+
// consume 루프 종료(stop/연결단절)는 정상 흐름 — unsubscribe/close 가 stop 한다. 비치명적.
|
|
153
|
+
})
|
|
154
|
+
const entry = {
|
|
155
|
+
stop: () => {
|
|
156
|
+
stopped = true
|
|
157
|
+
messages.stop()
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
this.#persistSubs.add(entry)
|
|
161
|
+
return {
|
|
162
|
+
unsubscribe: async () => {
|
|
163
|
+
entry.stop()
|
|
164
|
+
this.#persistSubs.delete(entry)
|
|
165
|
+
try {
|
|
166
|
+
await this.#jsm.consumers.delete(PERSIST_STREAM, ci.name)
|
|
167
|
+
} catch (e) {
|
|
168
|
+
// 이미 자동 삭제(inactive_threshold)됐을 수 있음 — not-found 는 무해. 그 외는 전파.
|
|
169
|
+
if (!(e instanceof this.#js.JetStreamApiError && e.code === this.#js.JetStreamApiCodes.ConsumerNotFound)) throw e
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** JetStream client/manager lazy 초기화 + 영속 스트림 멱등 보장(최초 persist 사용 시 1회). @returns {Promise<void>} */
|
|
176
|
+
async #ensureJetStream() {
|
|
177
|
+
if (this.#streamEnsured) return
|
|
178
|
+
if (this.#jsInit) return this.#jsInit
|
|
179
|
+
this.#jsInit = (async () => {
|
|
180
|
+
// nanos 는 @nats-io/nats-core, JetStream client/manager·enum 은 @nats-io/jetstream(ADR-225). 병렬 lazy import.
|
|
181
|
+
const [js, core] = await Promise.all([import('@nats-io/jetstream'), import('@nats-io/nats-core')])
|
|
182
|
+
this.#js = js
|
|
183
|
+
this.#nanos = core.nanos
|
|
184
|
+
this.#jsc = js.jetstream(this.#nc)
|
|
185
|
+
this.#jsm = await js.jetstreamManager(this.#nc)
|
|
186
|
+
try {
|
|
187
|
+
await this.#jsm.streams.info(PERSIST_STREAM)
|
|
188
|
+
} catch (e) {
|
|
189
|
+
if (e instanceof js.JetStreamApiError && e.code === js.JetStreamApiCodes.StreamNotFound) {
|
|
190
|
+
await this.#jsm.streams.add({ name: PERSIST_STREAM, subjects: [`${PERSIST_PREFIX}>`], retention: js.RetentionPolicy.Limits, storage: js.StorageType.File })
|
|
191
|
+
} else {
|
|
192
|
+
throw e
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
this.#streamEnsured = true
|
|
196
|
+
})()
|
|
197
|
+
try {
|
|
198
|
+
await this.#jsInit
|
|
199
|
+
} finally {
|
|
200
|
+
this.#jsInit = null
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|