mega-framework 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) 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 +48 -0
  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/types/adapters/mega-adapter.d.ts +1 -1
  67. package/types/adapters/nats-adapter.d.ts +4 -4
  68. package/types/adapters/nats-codec.d.ts +13 -0
  69. package/types/adapters/redlock-adapter.d.ts +1 -1
  70. package/types/core/app-registry.d.ts +22 -0
  71. package/types/core/bus/cluster-bus.d.ts +45 -0
  72. package/types/core/bus/contract.d.ts +164 -0
  73. package/types/core/bus/index.d.ts +100 -0
  74. package/types/core/bus/memory-bus.d.ts +45 -0
  75. package/types/core/bus/nats-bus.d.ts +41 -0
  76. package/types/core/index.d.ts +1 -0
  77. package/types/core/lock/cluster-lock.d.ts +44 -0
  78. package/types/core/lock/contract.d.ts +181 -0
  79. package/types/core/lock/fifo-waitlist.d.ts +38 -0
  80. package/types/core/lock/index.d.ts +96 -0
  81. package/types/core/lock/memory-lock.d.ts +58 -0
  82. package/types/core/lock/redis-lock.d.ts +43 -0
  83. package/types/core/mega-app.d.ts +10 -0
  84. package/types/core/scope-registry.d.ts +6 -0
  85. package/types/index.d.ts +1 -1
  86. package/types/lib/mega-job-queue.d.ts +27 -4
  87. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -0,0 +1,45 @@
1
+ // @ts-check
2
+ /**
3
+ * WebController — `web` 앱(a.com) 컨트롤러(ADR-074: 베이스 없음, 정적 메서드만).
4
+ *
5
+ * 앱 전용 lock(redis)·bus(prefix `web.`)를 그대로 쓴다. `/cross` 는 `getApp('admin')`(ADR-228)로 다른 앱의
6
+ * 글로벌 버스에 이벤트를 보내는 cross-app 호출을 보여준다.
7
+ */
8
+ import { getApp } from 'mega-framework'
9
+
10
+ /** @param {number} ms @returns {Promise<void>} */
11
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
12
+
13
+ export class WebController {
14
+ /** GET / — 셸 페이지. @param {any} _req @param {any} _reply @param {any} ctx */
15
+ static async home(_req, _reply, ctx) {
16
+ return ctx.render('index', { app: 'web', host: 'a.com' })
17
+ }
18
+
19
+ /** GET /whoami — 이 앱이 보는 lock/bus driver(앱별 분리 확인용). @param {any} _req @param {any} _reply @param {any} ctx */
20
+ static async whoami(_req, _reply, ctx) {
21
+ return { app: 'web', lock: (await ctx.lock.stats()).driver, bus: (await ctx.bus.stats()).driver, pid: process.pid }
22
+ }
23
+
24
+ /** GET /lock — web 전용 redis 락으로 임계구역 실행. admin 의 같은 키와 격리된다(다른 manager). @param {any} _req @param {any} _reply @param {any} ctx */
25
+ static async lock(_req, _reply, ctx) {
26
+ const held = await ctx.lock.with('shared:key', { ttl: 3000, waitMs: 2000 }, async () => {
27
+ await sleep(50)
28
+ return process.pid
29
+ })
30
+ return { app: 'web', lockDriver: (await ctx.lock.stats()).driver, ranOnPid: held }
31
+ }
32
+
33
+ /** GET /emit — web. prefix 로 발행. admin(glob.)은 못 받는다(prefix 격리). @param {any} _req @param {any} _reply @param {any} ctx */
34
+ static async emit(_req, _reply, ctx) {
35
+ await ctx.bus.emit('order.created', { from: 'web', ts: Date.now() })
36
+ return { app: 'web', emitted: 'order.created', prefix: 'web.', note: 'admin(glob.)은 prefix 가 달라 수신하지 않습니다.' }
37
+ }
38
+
39
+ /** GET /cross — getApp('admin').bus 로 admin 의 글로벌 버스에 직접 발행(cross-app). @param {any} _req @param {any} _reply @param {any} _ctx */
40
+ static async cross(_req, _reply, _ctx) {
41
+ // getApp('admin').bus 는 admin 의 manager(글로벌, glob. prefix) — admin 의 구독자가 받는다.
42
+ await getApp('admin').bus.emit('notice', { from: 'web', message: 'hello admin', ts: Date.now() })
43
+ return { from: 'web', target: 'admin', sentVia: "getApp('admin').bus.emit('notice')", check: 'b.com/received' }
44
+ }
45
+ }
@@ -0,0 +1,24 @@
1
+ // @ts-check
2
+ /* web 앱 데모 — 버튼 클릭 시 엔드포인트를 fetch 해 결과를 페이지 안에 인라인 표시(JSON 페이지로 이동 안 함). */
3
+ ;(function () {
4
+ var buttons = document.querySelectorAll('button[data-get]')
5
+ for (var i = 0; i < buttons.length; i++) {
6
+ buttons[i].addEventListener('click', function (/** @type {any} */ ev) {
7
+ var url = ev.target.getAttribute('data-get')
8
+ var outId = 'out-' + url.slice(1) // /whoami → out-whoami
9
+ var out = document.getElementById(outId)
10
+ if (out) out.textContent = '실행 중…'
11
+ fetch(url, { headers: { accept: 'application/json' } })
12
+ .then(function (res) {
13
+ return res.json()
14
+ })
15
+ .then(function (j) {
16
+ var data = j && j.data != null ? j.data : j
17
+ if (out) out.textContent = JSON.stringify(data, null, 2)
18
+ })
19
+ .catch(function (e) {
20
+ if (out) out.textContent = '오류: ' + e.message
21
+ })
22
+ })
23
+ }
24
+ })()
@@ -0,0 +1,13 @@
1
+ // @ts-check
2
+ /**
3
+ * apps/web 라우트 — 자동 로딩(loadRoutes, ADR-157). 멀티앱·앱별 lock/bus·cross-app 시연(인증 없음 — 데모).
4
+ */
5
+ import { WebController } from '../controllers/web-controller.js'
6
+
7
+ export default (/** @type {any} */ router) => {
8
+ router.http.get('/', WebController.home) // 셸 페이지
9
+ router.http.get('/whoami', WebController.whoami) // 이 앱의 host·lock/bus driver
10
+ router.http.get('/lock', WebController.lock) // ctx.lock.with — web 전용 redis 락
11
+ router.http.get('/emit', WebController.emit) // ctx.bus.emit — web. prefix(admin 미수신)
12
+ router.http.get('/cross', WebController.cross) // getApp('admin').bus.emit — admin 의 글로벌 버스로 cross-app
13
+ }
@@ -0,0 +1,51 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>web (a.com) — 멀티앱 데모</title>
7
+ <style>
8
+ body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.6; }
9
+ code { background: #f3f3f3; padding: .1rem .3rem; border-radius: 3px; }
10
+ .tag { background: #0d6efd; color: #fff; padding: .1rem .5rem; border-radius: 4px; font-size: .85rem; }
11
+ button { font: inherit; padding: .4rem .8rem; margin: .2rem .3rem .2rem 0; border: 1px solid #0d6efd; background: #fff; color: #0d6efd; border-radius: 6px; cursor: pointer; }
12
+ button:hover { background: #0d6efd; color: #fff; }
13
+ .row { display: flex; gap: .8rem; align-items: flex-start; margin: .4rem 0; flex-wrap: wrap; }
14
+ .out { flex: 1; min-width: 280px; background: #f8f9fa; border: 1px solid #e3e6ea; border-radius: 6px; padding: .4rem .6rem; font-family: ui-monospace, monospace; font-size: .85rem; white-space: pre-wrap; min-height: 1.4rem; }
15
+ .desc { color: #555; font-size: .9rem; margin: .1rem 0 .3rem; }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <h1>web 앱 <span class="tag">a.com</span></h1>
20
+ <p>이 앱은 <strong>앱 전용 lock(redis) + bus(prefix <code>web.</code>)</strong>를 씁니다 (ADR-229). 같은 프로세스의
21
+ <code>admin</code>(b.com)은 글로벌 lock(memory)/bus(prefix <code>glob.</code>)를 씁니다. 한 포트를 <code>Host</code>
22
+ 헤더로 나눠 라우팅합니다(vhost). 버튼을 누르면 결과가 아래에 바로 표시됩니다.</p>
23
+
24
+ <div class="row">
25
+ <button data-get="/whoami">whoami</button>
26
+ <div class="out" id="out-whoami"></div>
27
+ </div>
28
+ <div class="desc">이 앱이 보는 lock/bus driver — redis / nats (앱 전용).</div>
29
+
30
+ <div class="row">
31
+ <button data-get="/lock">lock 실행</button>
32
+ <div class="out" id="out-lock"></div>
33
+ </div>
34
+ <div class="desc">web 전용 redis 락으로 임계구역 실행 (<code>ctx.lock.with</code>).</div>
35
+
36
+ <div class="row">
37
+ <button data-get="/emit">emit (web.)</button>
38
+ <div class="out" id="out-emit"></div>
39
+ </div>
40
+ <div class="desc"><code>web.order.created</code> 발행 — admin(glob.)은 prefix 가 달라 받지 못합니다(격리).</div>
41
+
42
+ <div class="row">
43
+ <button data-get="/cross">cross-app</button>
44
+ <div class="out" id="out-cross"></div>
45
+ </div>
46
+ <div class="desc"><code>getApp('admin').bus.emit('notice')</code> → admin 의 글로벌 버스로 직접 전달. 결과는
47
+ <a href="http://b.com:3200/">b.com</a> 의 "수신 이벤트"에서 확인하세요.</div>
48
+
49
+ <script src="/static/js/web.js"></script>
50
+ </body>
51
+ </html>
@@ -0,0 +1,42 @@
1
+ // @ts-check
2
+ /**
3
+ * sample/multi — 한 프로세스에 **두 앱**을 vhost 로 띄우는 멀티앱 데모 (ADR-063 vhost + ADR-229 앱별 lock/bus).
4
+ *
5
+ * - `web`(a.com): 앱 전용 lock(redis `webLock`) + 앱 전용 bus(NATS, prefix `web.`).
6
+ * - `admin`(b.com): 앱별 설정 없음 → **글로벌** lock(memory) + 글로벌 bus(NATS, prefix `glob.`)를 fallback 으로.
7
+ *
8
+ * 한 포트(3200) 한 번 listen, 요청은 `Host` 헤더로 앱에 라우팅된다. lock/bus 는 앱마다 다른 manager 라
9
+ * 같은 키여도 격리되고, `getApp('admin')` 으로 다른 앱의 자원을 cross-app 호출할 수 있다.
10
+ *
11
+ * Global-only 스코프(ADR-061): 활성 앱 whitelist·전역 services·server·logger. App-only 키는 apps/<name>/app.config.js.
12
+ */
13
+ export default {
14
+ apps: ['web', 'admin'],
15
+
16
+ server: {
17
+ // 3200 디폴트 — sample/crud·sample/simple(둘 다 3000)과 겹치지 않게(같은 머신에서 동시에 띄울 수 있게).
18
+ // 포트가 이미 점유돼 있으면 boot 가 `listen EADDRINUSE` 로 실패한다(브라우저가 옛 서버를 때리는 혼동 방지).
19
+ port: Number(process.env.PORT ?? 3200),
20
+ },
21
+
22
+ // 전역 services — 앱들이 별명으로 참조(ADR-102). lock.cache/bus.nats 는 여기 키를 가리킨다.
23
+ services: {
24
+ caches: {
25
+ // web 앱 전용 분산 락 backend(redis). admin 은 이 cache 를 안 쓴다(글로벌 memory 락).
26
+ webLock: { driver: 'redis', url: process.env.REDIS_WEBLOCK_URL },
27
+ },
28
+ buses: {
29
+ // 두 앱이 공유하는 NATS. prefix 로 subject 공간을 나눠 격리한다(web. vs glob.).
30
+ events: { driver: 'nats', url: process.env.NATS_EVENTS_URL },
31
+ },
32
+ },
33
+
34
+ // 글로벌 bus(fallback) — admin 처럼 앱별 bus 미지정 앱이 쓴다. prefix 'glob.'.
35
+ // 글로벌 lock 은 생략 → 자동 폴백 memory(redis cache 미지정). admin 이 이 memory 락을 쓴다.
36
+ bus: { nats: 'events', prefix: 'glob.' },
37
+
38
+ logger: {
39
+ level: 'info',
40
+ sinks: [{ type: 'console', pretty: true }],
41
+ },
42
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "sample-multi",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "scripts": {
10
+ "dev": "NODE_ENV=development mega start",
11
+ "start": "NODE_ENV=production mega start",
12
+ "test": "mega test"
13
+ },
14
+ "dependencies": {
15
+ "mega-framework": "file:../.."
16
+ },
17
+ "devDependencies": {
18
+ "vitest": "^4.1.8"
19
+ }
20
+ }
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
7
- "node": ">=20"
7
+ "node": ">=22"
8
8
  },
9
9
  "scripts": {
10
10
  "dev": "NODE_ENV=development mega start",
@@ -19,7 +19,7 @@
19
19
  "mega-framework": "file:../.."
20
20
  },
21
21
  "devDependencies": {
22
- "concurrently": "^9.0.0",
22
+ "concurrently": "^10.0.3",
23
23
  "vitest": "^4.1.8"
24
24
  }
25
25
  }
@@ -1,10 +1,14 @@
1
1
  // @ts-check
2
2
  /**
3
- * MegaNatsAdapter — NATS 메시지 버스 어댑터 (`nats` 공식 driver 래퍼, ADR-112).
3
+ * MegaNatsAdapter — NATS 메시지 버스 어댑터 (`@nats-io/*` v3 driver 래퍼, ADR-112/225).
4
4
  *
5
5
  * **첫 bus 도메인 어댑터**(`MegaBusAdapter` 첫 구체). DB/cache 와 달리 pub/sub·req/reply·
6
6
  * queue group 메시징 인터페이스를 구현한다.
7
7
  *
8
+ * # nats v3 (`@nats-io/*`) — v2 `nats` 단일 패키지에서 분리됨 (ADR-225)
9
+ * core connect 는 `@nats-io/transport-node`, 타입은 `@nats-io/nats-core`. `JSONCodec()` 팩토리가
10
+ * 제거돼 본 어댑터는 {@link import('./nats-codec.js')} 의 공유 JSON 코덱을 쓴다.
11
+ *
8
12
  * # 표준 표면 (MegaBusAdapter 상속)
9
13
  * - `_connect()` — `connect({ servers, ...auth, ...options })` (driver 는 connect 시점 lazy import).
10
14
  * connect() 자체가 서버 응답까지 기다리므로 별도 ping 불필요(실패 시 throw=검증).
@@ -15,10 +19,10 @@
15
19
  * - `getStats()` — 베이스 stats + nats 특화(server/연결 stats: inMsgs/outMsgs/inBytes/outBytes).
16
20
  * - `publish/subscribe/request` + `enqueue/process` — 아래 인터페이스.
17
21
  *
18
- * # 직렬화 = JSONCodec
19
- * payload 는 NATS wire 에서 `Uint8Array` 다. 본 어댑터는 `JSONCodec` 인코드/디코드를 표준화해
20
- * 사용자는 JS 값을 그대로 publish/subscribe 한다(수신 측에서 같은 값으로 복원). `undefined` payload
21
- * 는 `null` 로 정규화(JSONCodec undefined 를 인코드하지 못함).
22
+ * # 직렬화 = 공유 JSON 코덱 (ADR-225 — v3 JSONCodec 제거)
23
+ * payload 는 NATS wire 에서 `Uint8Array` 다. 본 어댑터는 {@link import('./nats-codec.js')}
24
+ * `encodeJson`/`decodeJson` 로 인코드/디코드를 표준화해 사용자는 JS 값을 그대로 publish/subscribe
25
+ * 한다(수신 측에서 같은 값으로 복원). `undefined` payload 는 `null` 로 정규화(JSON undefined 미표현).
22
26
  *
23
27
  * # queue (job) 인터페이스 — 단순 publish + queue group (jetstream 미사용, ADR-112)
24
28
  * `enqueue(job, msg)` = 해당 subject 로 **단순 publish**. `process(job, handler)` = **queue group**
@@ -59,6 +63,7 @@
59
63
  import { MegaValidationError, MegaInternalError } from '../errors/http-errors.js'
60
64
  import { MegaBusAdapter } from './mega-bus-adapter.js'
61
65
  import { resolveConnection, assertPlainObject } from './adapter-options.js'
66
+ import { encodeJson, decodeJson } from './nats-codec.js'
62
67
  import * as Registry from './registry.js'
63
68
 
64
69
  /** NATS 기본 클라이언트 포트(discrete 모드에서 port 미지정 시). */
@@ -78,11 +83,9 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 30000
78
83
  */
79
84
 
80
85
  export class MegaNatsAdapter extends MegaBusAdapter {
81
- /** @type {import('nats').NatsConnection | null} 연결된 NatsConnection (connect 후에만). */
86
+ /** @type {import('@nats-io/nats-core').NatsConnection | null} 연결된 NatsConnection (connect 후에만). */
82
87
  #nc = null
83
- /** @type {import('nats').Codec<any> | null} JSONCodec (connect 시 생성). */
84
- #codec = null
85
- /** @type {import('nats').ConnectionOptions} _connect 에서 `connect()` 에 넘길 옵션(생성자에서 고정). */
88
+ /** @type {import('@nats-io/nats-core').ConnectionOptions} _connect 에서 `connect()` 에 넘길 옵션(생성자에서 고정). */
86
89
  #connectOptions
87
90
 
88
91
  /**
@@ -107,7 +110,7 @@ export class MegaNatsAdapter extends MegaBusAdapter {
107
110
  const conn = resolveConnection(config, { driver: 'nats', dbConflictsWithUrl: false })
108
111
  assertPlainObject('options', config.options, { driver: 'nats' })
109
112
 
110
- /** @type {import('nats').ConnectionOptions} */
113
+ /** @type {import('@nats-io/nats-core').ConnectionOptions} */
111
114
  const connectOptions = {}
112
115
  // options(passthrough) 먼저 — 아래 servers/auth 가 항상 이긴다(연결 필수값 보호).
113
116
  if (config.options !== undefined) Object.assign(connectOptions, config.options)
@@ -135,10 +138,9 @@ export class MegaNatsAdapter extends MegaBusAdapter {
135
138
  * @returns {Promise<void>}
136
139
  */
137
140
  async _connect() {
138
- const { connect, JSONCodec } = await import('nats')
139
- const nc = await connect(this.#connectOptions)
140
- this.#nc = nc
141
- this.#codec = JSONCodec()
141
+ // v3: core connect `@nats-io/transport-node`(노드 전송) v2 `nats` 단일 패키지 대체(ADR-225).
142
+ const { connect } = await import('@nats-io/transport-node')
143
+ this.#nc = await connect(this.#connectOptions)
142
144
  }
143
145
 
144
146
  /**
@@ -151,7 +153,6 @@ export class MegaNatsAdapter extends MegaBusAdapter {
151
153
  if (this.#nc !== null) {
152
154
  const nc = this.#nc
153
155
  this.#nc = null
154
- this.#codec = null
155
156
  // 이미 닫혀 있으면(서버측 절단 등) drain 이 throw 할 수 있어 가드.
156
157
  if (!nc.isClosed()) await nc.drain()
157
158
  }
@@ -160,7 +161,7 @@ export class MegaNatsAdapter extends MegaBusAdapter {
160
161
  /**
161
162
  * raw NatsConnection handle (ADR-009). `connect()` 후에만 베이스 `native` getter 가 호출한다.
162
163
  * @protected
163
- * @returns {import('nats').NatsConnection}
164
+ * @returns {import('@nats-io/nats-core').NatsConnection}
164
165
  */
165
166
  _native() {
166
167
  if (this.#nc === null) {
@@ -193,7 +194,7 @@ export class MegaNatsAdapter extends MegaBusAdapter {
193
194
 
194
195
  /**
195
196
  * 누적 통계 + nats 특화(server + 연결 stats). 연결 전이면 server/stats 는 undefined.
196
- * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, server: string | undefined, nats: import('nats').Stats | undefined }}
197
+ * @returns {ReturnType<import('./mega-adapter.js').MegaAdapter['getStats']> & { driver: string, server: string | undefined, nats: import('@nats-io/nats-core').Stats | undefined }}
197
198
  */
198
199
  getStats() {
199
200
  const nc = this.#nc
@@ -211,15 +212,15 @@ export class MegaNatsAdapter extends MegaBusAdapter {
211
212
  // ──────────────────────────────────────────────────────────────────────
212
213
 
213
214
  /**
214
- * fire-and-forget 발행 (ack X). payload 는 JSONCodec 인코드.
215
+ * fire-and-forget 발행 (ack X). payload 는 공유 JSON 코덱(encodeJson)으로 인코드.
215
216
  * @param {string} subject
216
217
  * @param {any} payload
217
218
  * @returns {Promise<void>}
218
219
  */
219
220
  async publish(subject, payload) {
220
221
  return this._instrument('publish', { subject }, async () => {
221
- const nc = /** @type {import('nats').NatsConnection} */ (this.#nc)
222
- nc.publish(subject, this.#encode(payload))
222
+ const nc = /** @type {import('@nats-io/nats-core').NatsConnection} */ (this.#nc)
223
+ nc.publish(subject, encodeJson(payload))
223
224
  })
224
225
  }
225
226
 
@@ -233,7 +234,7 @@ export class MegaNatsAdapter extends MegaBusAdapter {
233
234
  */
234
235
  async subscribe(subject, handler) {
235
236
  return this._instrument('subscribe', { subject }, async () => {
236
- const nc = /** @type {import('nats').NatsConnection} */ (this.#nc)
237
+ const nc = /** @type {import('@nats-io/nats-core').NatsConnection} */ (this.#nc)
237
238
  const sub = nc.subscribe(subject, {
238
239
  callback: (err, msg) => this.#dispatch('subscribe', subject, handler, err, msg),
239
240
  })
@@ -254,20 +255,22 @@ export class MegaNatsAdapter extends MegaBusAdapter {
254
255
  */
255
256
  async request(subject, payload, { timeout = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
256
257
  return this._instrument('request', { subject, timeout }, async () => {
257
- const nc = /** @type {import('nats').NatsConnection} */ (this.#nc)
258
+ const nc = /** @type {import('@nats-io/nats-core').NatsConnection} */ (this.#nc)
258
259
  try {
259
- const reply = await nc.request(subject, this.#encode(payload), { timeout })
260
- return /** @type {import('nats').Codec<any>} */ (this.#codec).decode(reply.data)
260
+ const reply = await nc.request(subject, encodeJson(payload), { timeout })
261
+ return decodeJson(reply.data)
261
262
  } catch (err) {
262
- // NATS 타임아웃/무응답을 명시 에러 코드로 변환(silent 무시 X). 에러는 원본 전파.
263
- const code = /** @type {any} */ (err)?.code
264
- if (code === 'TIMEOUT') {
263
+ // v3(ADR-225): 타임아웃/무응답이 문자열 code('TIMEOUT'/'503')에서 **에러 클래스**로 바뀌었다.
264
+ // 클래스는 cold path catch 에서 lazy import(모듈은 _connect 가 이미 로드 — 비용 0). 명시
265
+ // 에러 코드로 변환(silent 무시 X). RequestError 는 no-responders 를 isNoResponders() 로 알린다.
266
+ const { TimeoutError, NoRespondersError, RequestError } = await import('@nats-io/nats-core')
267
+ if (err instanceof TimeoutError) {
265
268
  throw new MegaInternalError('bus.request_timeout', `nats request("${subject}") timed out after ${timeout}ms.`, {
266
269
  details: { subject, timeout },
267
270
  cause: err,
268
271
  })
269
272
  }
270
- if (code === '503') {
273
+ if (err instanceof NoRespondersError || (err instanceof RequestError && err.isNoResponders())) {
271
274
  throw new MegaInternalError('bus.no_responders', `nats request("${subject}"): no responders subscribed to the subject.`, {
272
275
  details: { subject },
273
276
  cause: err,
@@ -286,8 +289,8 @@ export class MegaNatsAdapter extends MegaBusAdapter {
286
289
  */
287
290
  async enqueue(jobName, payload) {
288
291
  return this._instrument('enqueue', { jobName }, async () => {
289
- const nc = /** @type {import('nats').NatsConnection} */ (this.#nc)
290
- nc.publish(jobName, this.#encode(payload))
292
+ const nc = /** @type {import('@nats-io/nats-core').NatsConnection} */ (this.#nc)
293
+ nc.publish(jobName, encodeJson(payload))
291
294
  })
292
295
  }
293
296
 
@@ -313,7 +316,7 @@ export class MegaNatsAdapter extends MegaBusAdapter {
313
316
  */
314
317
  async process(jobName, handler, { queue = jobName } = {}) {
315
318
  return this._instrument('process', { jobName, queue }, async () => {
316
- const nc = /** @type {import('nats').NatsConnection} */ (this.#nc)
319
+ const nc = /** @type {import('@nats-io/nats-core').NatsConnection} */ (this.#nc)
317
320
  nc.subscribe(jobName, {
318
321
  queue,
319
322
  callback: (err, msg) => this.#dispatch('process', jobName, (m) => handler(m), err, msg),
@@ -321,23 +324,15 @@ export class MegaNatsAdapter extends MegaBusAdapter {
321
324
  })
322
325
  }
323
326
 
324
- /**
325
- * payload → Uint8Array (JSONCodec). undefined 는 null 로 정규화(JSONCodec 가 undefined 미지원).
326
- * @param {any} payload @returns {Uint8Array}
327
- */
328
- #encode(payload) {
329
- return /** @type {import('nats').Codec<any>} */ (this.#codec).encode(payload === undefined ? null : payload)
330
- }
331
-
332
327
  /**
333
328
  * 구독/잡 콜백 공통 디스패처 — 에러·디코드·handler 호출을 표면화한다(silent 금지).
334
- * 구독 에러(NatsError)·디코드 실패·handler throw 를 모두 `console.error` 로 드러낸다.
329
+ * 구독 에러·디코드 실패·handler throw 를 모두 `console.error` 로 드러낸다.
335
330
  *
336
331
  * @param {'subscribe' | 'process'} kind
337
332
  * @param {string} subject
338
333
  * @param {(msg: any, replyFn?: (payload: any) => void) => any} handler
339
- * @param {import('nats').NatsError | null} err
340
- * @param {import('nats').Msg} msg
334
+ * @param {Error | null} err
335
+ * @param {import('@nats-io/nats-core').Msg} msg
341
336
  * @returns {void}
342
337
  */
343
338
  #dispatch(kind, subject, handler, err, msg) {
@@ -348,14 +343,14 @@ export class MegaNatsAdapter extends MegaBusAdapter {
348
343
  }
349
344
  let decoded
350
345
  try {
351
- decoded = /** @type {import('nats').Codec<any>} */ (this.#codec).decode(msg.data)
346
+ decoded = decodeJson(msg.data)
352
347
  } catch (decodeErr) {
353
348
  console.error(`[MegaNatsAdapter] ${kind}("${subject}") payload decode failed:`, decodeErr)
354
349
  return
355
350
  }
356
351
  // reply subject 가 있으면 replyFn 제공(request 응답용). subscribe 만 해당 — process 는 단방향.
357
352
  const replyFn =
358
- kind === 'subscribe' && msg.reply ? /** @param {any} p */ (p) => msg.respond(this.#encode(p)) : undefined
353
+ kind === 'subscribe' && msg.reply ? /** @param {any} p */ (p) => msg.respond(encodeJson(p)) : undefined
359
354
  try {
360
355
  const out = handler(decoded, replyFn)
361
356
  // handler 가 async 면 reject 도 표면화(떠다니는 promise 가 silent 실패되지 않게).
@@ -0,0 +1,38 @@
1
+ // @ts-check
2
+ /**
3
+ * NATS JSON wire 코덱 — `@nats-io/*` v3 에서 제거된 `JSONCodec()` 대체 (ADR-225).
4
+ *
5
+ * v2 `nats` 패키지는 `JSONCodec()` 팩토리로 JS 값 ↔ `Uint8Array` 변환을 제공했으나, v3
6
+ * (`@nats-io/nats-core`)에서 코덱 팩토리가 제거되고 메시지에 `.json()`/`.string()` 편의 메서드만
7
+ * 남았다. 본 모듈은 어댑터·잡 큐가 wire 에 싣는 payload 의 JSON 직렬화를 **한 곳에서** 정의해
8
+ * (`MegaNatsAdapter` publish ↔ 소비자 decode 라운드트립 일관성), v2 `JSONCodec` 의 의미를 보존한다:
9
+ * - encode: `undefined` 는 `null` 로 정규화(JSON 은 `undefined` 를 표현 못 함) 후 `JSON.stringify`.
10
+ * - decode: 빈 payload(길이 0)는 `null` 로(빈 발행을 graceful 처리). 그 외는 `JSON.parse`.
11
+ *
12
+ * TextEncoder/TextDecoder 는 Node 전역(웹 표준)이라 신규 의존성 0.
13
+ *
14
+ * @module adapters/nats-codec
15
+ */
16
+
17
+ const TE = new TextEncoder()
18
+ const TD = new TextDecoder()
19
+
20
+ /**
21
+ * JS 값 → NATS wire 바이트(JSON). `undefined` 는 `null` 로 정규화한다.
22
+ * @param {any} value
23
+ * @returns {Uint8Array}
24
+ */
25
+ export function encodeJson(value) {
26
+ return TE.encode(JSON.stringify(value === undefined ? null : value))
27
+ }
28
+
29
+ /**
30
+ * NATS wire 바이트(JSON) → JS 값. 빈 payload(길이 0)는 `null`. 파싱 실패는 throw(호출부가 처리).
31
+ * @param {Uint8Array} data
32
+ * @returns {any}
33
+ * @throws {SyntaxError} JSON 파싱 실패 시(silent 금지 — poison 메시지 감지에 사용).
34
+ */
35
+ export function decodeJson(data) {
36
+ if (!data || data.byteLength === 0) return null
37
+ return JSON.parse(TD.decode(data))
38
+ }
@@ -162,6 +162,7 @@ export function registerScaffoldCommands(program, { out, projectRoot, logger, re
162
162
  throw new Error(
163
163
  `Unknown generator '${kind}'. Supported: ${GENERATOR_KINDS.join(', ')}. ` +
164
164
  `(플러그인 generator 확인 실패: ${/** @type {any} */ (e).message ?? e})`,
165
+ { cause: e },
165
166
  )
166
167
  }
167
168
  if (def === undefined) {
package/src/cli/index.js CHANGED
@@ -396,11 +396,13 @@ async function runStart(opts, { argv, out, projectRoot, logger, spawn, selfPath
396
396
  }
397
397
 
398
398
  // 런타임 그래프 lazy 로드 — 부팅 명령에서만 프레임워크 전체를 지불한다(모듈 상단 주석).
399
- const [{ bootApp }, { MegaCluster }, { installPrimaryAggregator, installWorkerResponder }, { buildLogger }] =
399
+ const [{ bootApp }, { MegaCluster }, { installPrimaryAggregator, installWorkerResponder }, { installClusterLockMaster }, { installClusterBusMaster }, { buildLogger }] =
400
400
  await Promise.all([
401
401
  import('../core/boot.js'),
402
402
  import('../core/mega-cluster.js'),
403
403
  import('../core/cluster-metrics.js'),
404
+ import('../core/lock/cluster-lock.js'),
405
+ import('../core/bus/cluster-bus.js'),
404
406
  import('../lib/mega-logger.js'),
405
407
  ])
406
408
 
@@ -444,6 +446,12 @@ async function runStart(opts, { argv, out, projectRoot, logger, spawn, selfPath
444
446
  if (mega.isPrimary()) {
445
447
  // 마스터에 메트릭 집계기 설치(ADR-163) — 워커의 /metrics 요청을 받아 전 워커 합산해 회신.
446
448
  installPrimaryAggregator()
449
+ // 마스터에 분산 락 responder 설치(ADR-226) — cluster lock driver 워커들의 IPC 요청을 한 곳에서 직렬화.
450
+ // redis 없이 클러스터로만 돌 때 워커 간 상호배제를 마스터의 in-memory 락으로 제공한다(단일 노드 한정).
451
+ installClusterLockMaster()
452
+ // 마스터에 메시지 버스 라우터 설치(ADR-227) — cluster bus driver 워커들의 pub/sub IPC 를 fan-out.
453
+ // NATS 없이 클러스터로만 돌 때 워커 간 메시지 전달을 마스터 라우터로 제공한다(단일 노드 한정).
454
+ installClusterBusMaster()
447
455
  announce(masterLogger, `cluster master ${process.pid} forked ${workers} worker(s)`)
448
456
  }
449
457
  return 0
@@ -0,0 +1,69 @@
1
+ // @ts-check
2
+ /**
3
+ * 부팅된 MegaApp 프로세스 레지스트리 — **ctx 없는 영역**(백그라운드 setInterval·외부 SDK 콜백·테스트 헬퍼)에서
4
+ * `getApp()` 으로 booted 앱에 접근해 `app.lock` / `app.bus` 등 process-level 표면을 쓴다 (ADR-228).
5
+ *
6
+ * # 왜 필요한가
7
+ * `ctx.lock`/`ctx.bus` 는 요청 ctx 에만 있어, 요청 흐름 밖(타이머·외부 라이브러리 콜백)에선 잡을 수 없었다.
8
+ * lock/bus manager 는 **process 싱글톤**(`setLockManager`/`setBusManager`)이라 요청과 무관하게 같은 인스턴스다.
9
+ * `getApp()` 은 그 싱글톤을 들고 있는 MegaApp 을 돌려줘, ctx 가 있을 때와 **동일 표면**(app.lock/app.bus)을 제공한다.
10
+ *
11
+ * # ctx 와의 차이
12
+ * `app.*` 는 **요청 무관** 자원만 — `lock`/`bus`(process manager) · `db(alias)`/`cache(alias)`(앱 별명 해석) ·
13
+ * `log`/`name`. 요청-스코프(`req`/`user`/`session`/`reply`/요청 locale `t`)는 **없다** — 그건 ctx 전용이다.
14
+ *
15
+ * @module core/app-registry
16
+ */
17
+ import { MegaError } from '../errors/mega-error.js'
18
+
19
+ /** @type {Map<string, import('./mega-app.js').MegaApp>} name → booted MegaApp(등록 순서). */
20
+ const apps = new Map()
21
+
22
+ /**
23
+ * booted MegaApp 을 레지스트리에 등록한다(boot 의 apps 스테이지가 앱마다 1회 호출).
24
+ * @param {import('./mega-app.js').MegaApp} app
25
+ * @returns {void}
26
+ */
27
+ export function setApp(app) {
28
+ apps.set(app.name, app)
29
+ }
30
+
31
+ /**
32
+ * booted MegaApp 을 가져온다 — ctx 없는 영역의 표준 접근점.
33
+ * - `name` 지정: 그 이름의 앱(없으면 `app.not_found`).
34
+ * - `name` 생략 + 앱 1개: 그 앱.
35
+ * - `name` 생략 + 앱 0개: `app.not_initialized`(부팅 전 호출 — fail-fast).
36
+ * - `name` 생략 + 앱 2개 이상: `app.ambiguous`(이름 필수).
37
+ *
38
+ * @param {string} [name] - 앱 이름(멀티앱 프로세스에서 지정).
39
+ * @returns {import('./mega-app.js').MegaApp}
40
+ * @throws {MegaError} `app.not_initialized` | `app.ambiguous` | `app.not_found`
41
+ */
42
+ export function getApp(name) {
43
+ if (name !== undefined) {
44
+ const app = apps.get(name)
45
+ if (!app) {
46
+ throw new MegaError('app.not_found', `getApp('${name}') — no booted app named '${name}'. Booted: [${[...apps.keys()].join(', ') || '(none)'}].`, {
47
+ details: { name, booted: [...apps.keys()] },
48
+ })
49
+ }
50
+ return app
51
+ }
52
+ if (apps.size === 0) {
53
+ throw new MegaError('app.not_initialized', 'getApp() called before boot — no app is initialized. Call after bootApp() completes (e.g. afterBoot hook or post-listen background tasks).', { details: {} })
54
+ }
55
+ if (apps.size > 1) {
56
+ throw new MegaError('app.ambiguous', `getApp() is ambiguous — ${apps.size} apps booted ([${[...apps.keys()].join(', ')}]). Pass a name: getApp('<name>').`, { details: { booted: [...apps.keys()] } })
57
+ }
58
+ return /** @type {import('./mega-app.js').MegaApp} */ ([...apps.values()][0])
59
+ }
60
+
61
+ /** 등록된 앱이 있나(부팅 여부 가드 — `getApp` throw 없이 확인). @returns {boolean} */
62
+ export function hasApp() {
63
+ return apps.size > 0
64
+ }
65
+
66
+ /** 테스트 격리/재부팅용 — 레지스트리 비움(shutdown 에서도 호출). @returns {void} */
67
+ export function _resetApps() {
68
+ apps.clear()
69
+ }