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,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": ">=
|
|
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": "^
|
|
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 메시지 버스 어댑터 (
|
|
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` 다. 본 어댑터는
|
|
20
|
-
* 사용자는 JS 값을 그대로 publish/subscribe
|
|
21
|
-
* 는 `null` 로 정규화(
|
|
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').
|
|
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
|
-
|
|
139
|
-
const
|
|
140
|
-
this.#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 는
|
|
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,
|
|
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,
|
|
260
|
-
return
|
|
260
|
+
const reply = await nc.request(subject, encodeJson(payload), { timeout })
|
|
261
|
+
return decodeJson(reply.data)
|
|
261
262
|
} catch (err) {
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
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 (
|
|
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,
|
|
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
|
-
* 구독
|
|
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 {
|
|
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 =
|
|
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(
|
|
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
|
+
}
|