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
package/src/core/boot.js CHANGED
@@ -46,6 +46,9 @@ import { MegaShutdown } from '../lib/mega-shutdown.js'
46
46
  import { buildLogger } from '../lib/mega-logger.js'
47
47
  import { buildFromGlobalConfig, connectAll, get as getAdapter, entries as adapterEntries } from '../adapters/adapter-manager.js'
48
48
  import { buildWorkers, startAll as startWorkers, contextProxy as workersContext } from './workers-manager.js'
49
+ import { createLockManager, setLockManager, attachLockApi } from './lock/index.js'
50
+ import { createBusManager, setBusManager, attachBusApi } from './bus/index.js'
51
+ import { setApp, _resetApps } from './app-registry.js'
49
52
  import { MegaWsCluster } from './ws-cluster.js'
50
53
  import { MegaWsRedisRoster } from './ws-roster.js'
51
54
  import * as MegaMetrics from '../lib/mega-metrics.js'
@@ -95,6 +98,33 @@ function composeAspConfig(globalAsp, appAsp) {
95
98
  return { ...appAsp, ...(masterSecret ? { masterSecret } : {}) }
96
99
  }
97
100
 
101
+ /**
102
+ * lock redis driver 가 빌릴 raw ioredis 를 cache 어댑터에서 빌린다(eval/duplicate 보유 검증). 글로벌·앱별 lock
103
+ * 스테이지가 공유한다. 없거나 raw redis 가 아니면 null(auto 면 폴백, 명시 redis 면 factory 가 fail-fast).
104
+ * @param {string | undefined} cacheKey - lock.cache(글로벌 services.caches 키). @param {any} [logger]
105
+ * @returns {any | null}
106
+ */
107
+ function borrowLockRedis(cacheKey, logger) {
108
+ if (!cacheKey) return null
109
+ const native = /** @type {any} */ (getAdapter('cache', cacheKey))?.native
110
+ if (native && typeof native.eval === 'function' && typeof native.duplicate === 'function') return native
111
+ logger?.warn?.({ cache: cacheKey }, 'boot.lock: configured cache adapter has no raw ioredis (eval/duplicate) — redis lock driver unavailable')
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * bus nats driver 가 빌릴 raw NatsConnection 을 bus 어댑터에서 빌린다(publish/subscribe/request 보유 검증).
117
+ * @param {string | undefined} natsKey - bus.nats(글로벌 services.buses 키). @param {any} [logger]
118
+ * @returns {any | null}
119
+ */
120
+ function borrowBusNc(natsKey, logger) {
121
+ if (!natsKey) return null
122
+ const native = /** @type {any} */ (getAdapter('bus', natsKey))?.native
123
+ if (native && typeof native.publish === 'function' && typeof native.subscribe === 'function' && typeof native.request === 'function') return native
124
+ logger?.warn?.({ nats: natsKey }, 'boot.bus: configured bus adapter has no NATS native (publish/subscribe/request) — nats bus driver unavailable')
125
+ return null
126
+ }
127
+
98
128
  /**
99
129
  * boot/CLI 컨텍스트 — `db/cache/bus/lock` 을 글로벌 키로 직접 조회하고, `workers` 는 `static name` 으로
100
130
  * lookup 한다(앱 별명 변환 없음 — 둘 다 글로벌 자원). worker/scheduler CLI 도 같은 형태를 재사용한다
@@ -222,6 +252,50 @@ const PREPARE_STEPS = [
222
252
  st.logger?.debug?.('boot.adapters connected')
223
253
  },
224
254
  },
255
+ {
256
+ name: 'lock',
257
+ needs: ['global', 'host'],
258
+ run: (st) => {
259
+ // 분산 락 manager 자동배선 (ADR-226) — `ctx.lock.with/.acquire/...` 사용자 API. driver 자동 폴백:
260
+ // redis cache 어댑터(config `lock.cache`) → redis(진짜 분산) / cluster 워커 → cluster(단일 노드) /
261
+ // 둘 다 없음 → memory(분산 미보장, 경고). 명시 driver 의 전제 부재는 factory 가 fail-fast(P7).
262
+ // adapters 스테이지 이후라 cache 의 raw ioredis(`.native`)가 connect 된 상태다. setLockManager 싱글톤을
263
+ // ctx-builder 가 읽어 모든 ctx(HTTP·worker·scheduler)의 lock accessor 에 API 를 얹는다.
264
+ // 글로벌 lock manager — 앱별 lock 설정이 없는 앱의 fallback + worker/scheduler boot ctx(MegaApp 없음)용.
265
+ // 앱별 분리(ADR-229)는 apps 스테이지가 app.config.lock 이 있을 때만 별도 manager 를 만들어 덮어쓴다.
266
+ const lockCfg = /** @type {any} */ (st.global).lock ?? {}
267
+ const manager = createLockManager(lockCfg, { logger: st.logger, redisClient: borrowLockRedis(lockCfg.cache, st.logger), isClusterWorker: nodeCluster.isWorker })
268
+ setLockManager(manager)
269
+ // 'app' stage — 어댑터 disconnect 보다 먼저 정리(redis 구독 연결 quit·타이머 해제가 cache 끊기기 전에).
270
+ MegaShutdown.register('mega-lock', async () => {
271
+ await manager.close()
272
+ setLockManager(null)
273
+ })
274
+ st.logger?.debug?.({ driver: manager.driverName, configured: lockCfg.driver ?? 'auto' }, 'boot.lock manager ready (ADR-226)')
275
+ },
276
+ },
277
+ {
278
+ name: 'bus',
279
+ needs: ['global', 'host'],
280
+ run: (st) => {
281
+ // 메시지 버스 manager 자동배선 (ADR-227) — `ctx.bus.emit/.on/.request/...` 사용자 API. driver 자동 폴백:
282
+ // NATS bus 어댑터(config `bus.nats`) → nats(진짜 분산) / cluster 워커 → cluster(단일 노드) /
283
+ // 둘 다 없음 → memory(분산 미보장, 경고). 명시 driver 의 전제 부재는 factory 가 fail-fast(P7).
284
+ // adapters 스테이지 이후라 NATS 어댑터의 raw NatsConnection(`.native`)이 connect 된 상태다. 새 연결을 만들지
285
+ // 않고 빌려 쓰므로 클러스터 broadcast(ADR-176)·잡 큐(ADR-119)와 같은 nc 를 공유한다.
286
+ // 글로벌 bus manager — 앱별 bus 설정이 없는 앱의 fallback + worker/scheduler boot ctx 용. 앱별 분리(ADR-229)는
287
+ // apps 스테이지가 app.config.bus 가 있을 때만 별도 manager 로 덮어쓴다.
288
+ const busCfg = /** @type {any} */ (st.global).bus ?? {}
289
+ const manager = createBusManager(busCfg, { logger: st.logger, nc: borrowBusNc(busCfg.nats, st.logger), isClusterWorker: nodeCluster.isWorker })
290
+ setBusManager(manager)
291
+ // 'app' stage — 어댑터 disconnect 보다 먼저 정리(영속 consumer·구독이 nc 끊기기 전에).
292
+ MegaShutdown.register('mega-bus', async () => {
293
+ await manager.close()
294
+ setBusManager(null)
295
+ })
296
+ st.logger?.debug?.({ driver: manager.driverName, configured: busCfg.driver ?? 'auto' }, 'boot.bus manager ready (ADR-227)')
297
+ },
298
+ },
225
299
  {
226
300
  name: 'health-auto-checks',
227
301
  needs: ['global'],
@@ -492,8 +566,33 @@ const BOOT_STEPS = [
492
566
  st.logger?.debug?.({ app: name, filesLoaded }, 'boot.routes loaded')
493
567
  st.server.mount(app)
494
568
  megaApps.push(app)
569
+ // ctx 없는 영역의 getApp() 접근점에 등록(ADR-228).
570
+ setApp(app)
571
+
572
+ // 앱별 lock/bus 분리(ADR-229) — app.config.lock/bus 가 있으면 그 앱 전용 manager 를 만들어 이 앱의
573
+ // adapterAccessors 에 덮어쓴다(ctx.<app>·getApp(name) 둘 다 앱 manager 를 본다). 미지정 앱은 생성자가
574
+ // 이미 붙인 글로벌 manager 를 그대로 쓴다(중복 연결 없음). 앱 설정은 글로벌과 shallow merge(앱 키 우선).
575
+ const isWorker = nodeCluster.isWorker
576
+ const appLockCfg = /** @type {any} */ (config).lock
577
+ if (appLockCfg) {
578
+ const merged = { .../** @type {any} */ (st.global).lock, ...appLockCfg }
579
+ const lockMgr = createLockManager(merged, { logger: st.logger, redisClient: borrowLockRedis(merged.cache, st.logger), isClusterWorker: isWorker })
580
+ attachLockApi(/** @type {any} */ (app.adapterAccessors.lock), lockMgr)
581
+ MegaShutdown.register(`mega-lock:${name}`, async () => lockMgr.close())
582
+ st.logger?.debug?.({ app: name, driver: lockMgr.driverName }, 'boot.lock app-scoped manager (ADR-229)')
583
+ }
584
+ const appBusCfg = /** @type {any} */ (config).bus
585
+ if (appBusCfg) {
586
+ const merged = { .../** @type {any} */ (st.global).bus, ...appBusCfg }
587
+ const busMgr = createBusManager(merged, { logger: st.logger, nc: borrowBusNc(merged.nats, st.logger), isClusterWorker: isWorker })
588
+ attachBusApi(/** @type {any} */ (app.adapterAccessors.bus), busMgr)
589
+ MegaShutdown.register(`mega-bus:${name}`, async () => busMgr.close())
590
+ st.logger?.debug?.({ app: name, driver: busMgr.driverName }, 'boot.bus app-scoped manager (ADR-229)')
591
+ }
495
592
  }
496
593
  st.megaApps = megaApps
594
+ // 재부팅/테스트 격리 — 'app' stage 에서 레지스트리 비움(어댑터 disconnect 와 같은 시점, getApp 가 stale 앱 X).
595
+ MegaShutdown.register('mega-app-registry', () => _resetApps())
497
596
  },
498
597
  },
499
598
  {
@@ -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
+ }