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.
Files changed (91) 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/lock-controller.js +117 -0
  7. package/sample/crud/apps/main/locales/server/en.json +31 -1
  8. package/sample/crud/apps/main/locales/server/ko.json +31 -1
  9. package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
  10. package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
  11. package/sample/crud/apps/main/routes/bus.js +43 -0
  12. package/sample/crud/apps/main/routes/lock.js +35 -0
  13. package/sample/crud/apps/main/services/jobs-demo-service.js +21 -14
  14. package/sample/crud/apps/main/views/bus/index.ejs +80 -0
  15. package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
  16. package/sample/crud/apps/main/views/lock/index.ejs +99 -0
  17. package/sample/crud/docs/guide/03-service-model-db.md +110 -6
  18. package/sample/crud/docs/guide/05-scheduler-job-worker.md +3 -1
  19. package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
  20. package/sample/crud/docs/guide/10-multi-app.md +74 -0
  21. package/sample/crud/mega.config.js +32 -0
  22. package/sample/crud/package.json +3 -2
  23. package/sample/multi/.env +16 -0
  24. package/sample/multi/.env.example +17 -0
  25. package/sample/multi/README.md +54 -0
  26. package/sample/multi/apps/admin/app.config.js +24 -0
  27. package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
  28. package/sample/multi/apps/admin/public/js/admin.js +31 -0
  29. package/sample/multi/apps/admin/routes/pages.js +11 -0
  30. package/sample/multi/apps/admin/views/index.ejs +33 -0
  31. package/sample/multi/apps/web/app.config.js +30 -0
  32. package/sample/multi/apps/web/controllers/web-controller.js +45 -0
  33. package/sample/multi/apps/web/public/js/web.js +24 -0
  34. package/sample/multi/apps/web/routes/pages.js +13 -0
  35. package/sample/multi/apps/web/views/index.ejs +51 -0
  36. package/sample/multi/mega.config.js +42 -0
  37. package/sample/multi/package.json +20 -0
  38. package/sample/simple/package.json +2 -2
  39. package/src/adapters/nats-adapter.js +39 -44
  40. package/src/adapters/nats-codec.js +38 -0
  41. package/src/cli/commands/scaffold.js +1 -0
  42. package/src/cli/index.js +9 -1
  43. package/src/core/app-registry.js +69 -0
  44. package/src/core/boot.js +99 -0
  45. package/src/core/bus/cluster-bus.js +190 -0
  46. package/src/core/bus/contract.js +123 -0
  47. package/src/core/bus/index.js +285 -0
  48. package/src/core/bus/memory-bus.js +103 -0
  49. package/src/core/bus/nats-bus.js +203 -0
  50. package/src/core/config-validator.js +118 -1
  51. package/src/core/ctx-builder.js +14 -1
  52. package/src/core/index.js +2 -0
  53. package/src/core/lock/cluster-lock.js +174 -0
  54. package/src/core/lock/contract.js +123 -0
  55. package/src/core/lock/fifo-waitlist.js +93 -0
  56. package/src/core/lock/index.js +292 -0
  57. package/src/core/lock/memory-lock.js +162 -0
  58. package/src/core/lock/redis-lock.js +276 -0
  59. package/src/core/mega-app.js +29 -0
  60. package/src/core/migration/generate.js +1 -1
  61. package/src/core/migration/journal.js +1 -1
  62. package/src/core/scope-registry.js +9 -0
  63. package/src/eslint-plugin/no-direct-model-import.js +2 -2
  64. package/src/index.js +2 -0
  65. package/src/lib/mega-job-queue.js +71 -47
  66. package/templates/model/code-mongo.tpl +1 -9
  67. package/templates/model/code.tpl +1 -10
  68. package/templates/model/test-mongo.tpl +1 -23
  69. package/templates/model/test.tpl +0 -17
  70. package/types/adapters/mega-adapter.d.ts +1 -1
  71. package/types/adapters/nats-adapter.d.ts +4 -4
  72. package/types/adapters/nats-codec.d.ts +13 -0
  73. package/types/adapters/redlock-adapter.d.ts +1 -1
  74. package/types/core/app-registry.d.ts +22 -0
  75. package/types/core/bus/cluster-bus.d.ts +45 -0
  76. package/types/core/bus/contract.d.ts +164 -0
  77. package/types/core/bus/index.d.ts +100 -0
  78. package/types/core/bus/memory-bus.d.ts +45 -0
  79. package/types/core/bus/nats-bus.d.ts +41 -0
  80. package/types/core/index.d.ts +1 -0
  81. package/types/core/lock/cluster-lock.d.ts +44 -0
  82. package/types/core/lock/contract.d.ts +181 -0
  83. package/types/core/lock/fifo-waitlist.d.ts +38 -0
  84. package/types/core/lock/index.d.ts +96 -0
  85. package/types/core/lock/memory-lock.d.ts +58 -0
  86. package/types/core/lock/redis-lock.d.ts +43 -0
  87. package/types/core/mega-app.d.ts +10 -0
  88. package/types/core/scope-registry.d.ts +6 -0
  89. package/types/index.d.ts +1 -1
  90. package/types/lib/mega-job-queue.d.ts +27 -4
  91. 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
+ }