mega-framework 0.1.9 → 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 (90) 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/jobs-controller.js +22 -2
  7. package/sample/crud/apps/main/controllers/lock-controller.js +117 -0
  8. package/sample/crud/apps/main/jobs/email-job.js +37 -2
  9. package/sample/crud/apps/main/locales/server/en.json +36 -1
  10. package/sample/crud/apps/main/locales/server/ko.json +36 -1
  11. package/sample/crud/apps/main/public/js/bus-demo.js +131 -0
  12. package/sample/crud/apps/main/public/js/lock-demo.js +122 -0
  13. package/sample/crud/apps/main/routes/bus.js +43 -0
  14. package/sample/crud/apps/main/routes/lock.js +35 -0
  15. package/sample/crud/apps/main/services/jobs-demo-service.js +22 -15
  16. package/sample/crud/apps/main/views/bus/index.ejs +80 -0
  17. package/sample/crud/apps/main/views/jobs/index.ejs +9 -3
  18. package/sample/crud/apps/main/views/layouts/main.ejs +2 -0
  19. package/sample/crud/apps/main/views/lock/index.ejs +99 -0
  20. package/sample/crud/docs/guide/03-service-model-db.md +48 -0
  21. package/sample/crud/docs/guide/05-scheduler-job-worker.md +29 -2
  22. package/sample/crud/docs/guide/09-distributed-lock-and-bus.md +224 -0
  23. package/sample/crud/docs/guide/10-multi-app.md +74 -0
  24. package/sample/crud/mega.config.js +32 -0
  25. package/sample/crud/package.json +3 -2
  26. package/sample/multi/.env +16 -0
  27. package/sample/multi/.env.example +17 -0
  28. package/sample/multi/README.md +54 -0
  29. package/sample/multi/apps/admin/app.config.js +24 -0
  30. package/sample/multi/apps/admin/controllers/admin-controller.js +42 -0
  31. package/sample/multi/apps/admin/public/js/admin.js +31 -0
  32. package/sample/multi/apps/admin/routes/pages.js +11 -0
  33. package/sample/multi/apps/admin/views/index.ejs +33 -0
  34. package/sample/multi/apps/web/app.config.js +30 -0
  35. package/sample/multi/apps/web/controllers/web-controller.js +45 -0
  36. package/sample/multi/apps/web/public/js/web.js +24 -0
  37. package/sample/multi/apps/web/routes/pages.js +13 -0
  38. package/sample/multi/apps/web/views/index.ejs +51 -0
  39. package/sample/multi/mega.config.js +42 -0
  40. package/sample/multi/package.json +20 -0
  41. package/sample/simple/package.json +2 -2
  42. package/src/adapters/nats-adapter.js +39 -44
  43. package/src/adapters/nats-codec.js +38 -0
  44. package/src/cli/commands/scaffold.js +1 -0
  45. package/src/cli/index.js +50 -1
  46. package/src/core/app-registry.js +69 -0
  47. package/src/core/boot.js +99 -0
  48. package/src/core/bus/cluster-bus.js +190 -0
  49. package/src/core/bus/contract.js +123 -0
  50. package/src/core/bus/index.js +285 -0
  51. package/src/core/bus/memory-bus.js +103 -0
  52. package/src/core/bus/nats-bus.js +203 -0
  53. package/src/core/config-validator.js +118 -1
  54. package/src/core/ctx-builder.js +14 -1
  55. package/src/core/index.js +2 -0
  56. package/src/core/lock/cluster-lock.js +174 -0
  57. package/src/core/lock/contract.js +123 -0
  58. package/src/core/lock/fifo-waitlist.js +93 -0
  59. package/src/core/lock/index.js +292 -0
  60. package/src/core/lock/memory-lock.js +162 -0
  61. package/src/core/lock/redis-lock.js +276 -0
  62. package/src/core/mega-app.js +29 -0
  63. package/src/core/migration/generate.js +1 -1
  64. package/src/core/migration/journal.js +1 -1
  65. package/src/core/scope-registry.js +9 -0
  66. package/src/eslint-plugin/no-direct-model-import.js +2 -2
  67. package/src/index.js +2 -0
  68. package/src/lib/mega-job-queue.js +71 -47
  69. package/types/adapters/mega-adapter.d.ts +1 -1
  70. package/types/adapters/nats-adapter.d.ts +4 -4
  71. package/types/adapters/nats-codec.d.ts +13 -0
  72. package/types/adapters/redlock-adapter.d.ts +1 -1
  73. package/types/core/app-registry.d.ts +22 -0
  74. package/types/core/bus/cluster-bus.d.ts +45 -0
  75. package/types/core/bus/contract.d.ts +164 -0
  76. package/types/core/bus/index.d.ts +100 -0
  77. package/types/core/bus/memory-bus.d.ts +45 -0
  78. package/types/core/bus/nats-bus.d.ts +41 -0
  79. package/types/core/index.d.ts +1 -0
  80. package/types/core/lock/cluster-lock.d.ts +44 -0
  81. package/types/core/lock/contract.d.ts +181 -0
  82. package/types/core/lock/fifo-waitlist.d.ts +38 -0
  83. package/types/core/lock/index.d.ts +96 -0
  84. package/types/core/lock/memory-lock.d.ts +58 -0
  85. package/types/core/lock/redis-lock.d.ts +43 -0
  86. package/types/core/mega-app.d.ts +10 -0
  87. package/types/core/scope-registry.d.ts +6 -0
  88. package/types/index.d.ts +1 -1
  89. package/types/lib/mega-job-queue.d.ts +27 -4
  90. package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
@@ -0,0 +1,54 @@
1
+ # sample-multi — 한 프로세스 멀티앱 (vhost) 데모
2
+
3
+ 한 프로세스에 **두 앱**을 띄우고, `Host` 헤더로 라우팅(vhost, ADR-063)하며, **앱별 lock/bus 분리**(ADR-229)와
4
+ ctx 없는 영역의 **cross-app 호출**(`getApp`, ADR-228)을 보여주는 최소 데모입니다.
5
+
6
+ - **web** (`a.com`) — 앱 전용 lock(**redis**) + bus(NATS, prefix `web.`)
7
+ - **admin** (`b.com`) — 앱별 설정 없음 → **글로벌 fallback** (lock=**memory**, bus prefix `glob.`)
8
+
9
+ > 이 데모는 멀티앱 패턴(vhost·앱별 lock/bus·cross-app)에 집중한 **목적형 최소 앱 2개**입니다. 풀 기능
10
+ > 데모는 단일앱 `sample/crud`(DB·캐시·잡·WS·ASP 등) / `sample/simple`(최소 E2E)을 보세요.
11
+
12
+ ## 실행
13
+
14
+ ```bash
15
+ cd sample/multi
16
+ cp .env.example .env # REDIS_WEBLOCK_URL / NATS_EVENTS_URL 확인 (redis·nats 필요)
17
+ npm install
18
+ npm run dev
19
+ ```
20
+
21
+ `/etc/hosts` 에 `a.com` / `b.com` 이 `127.0.0.1` 로 매핑돼 있어야 합니다(이미 설정돼 있다고 가정 — 없으면
22
+ `.env.example` 안내 참고). 접속:
23
+
24
+ | URL | 앱 | 무엇 |
25
+ | --- | --- | --- |
26
+ | http://a.com:3200/ | web | 셸 + 엔드포인트 안내 |
27
+ | http://a.com:3200/whoami | web | lock=`redis`, bus=`nats` (앱 전용) |
28
+ | http://a.com:3200/lock | web | redis 락으로 임계구역 실행 |
29
+ | http://a.com:3200/emit | web | `web.order.created` 발행(admin 미수신) |
30
+ | http://a.com:3200/cross | web | `getApp('admin').bus.emit('notice')` → admin 으로 cross-app |
31
+ | http://b.com:3200/whoami | admin | lock=`memory`, bus=`nats`(글로벌) |
32
+ | http://b.com:3200/received | admin | 글로벌 버스로 받은 이벤트 — web 의 cross-app `notice` 가 보임, `web.` 발행은 격리로 없음 |
33
+
34
+ ## 무엇을 보여주나
35
+
36
+ - **vhost**: 한 포트(3200) 한 번 listen, `a.com`/`b.com` 이 다른 앱으로 라우팅. 같은 host 를 두 앱에 매핑하면
37
+ 부팅 시 `config.host_collision` 로 fail-fast(ADR-067).
38
+ - **앱별 lock 분리**: `a.com/whoami` 는 `redis`, `b.com/whoami` 는 `memory` — 같은 키여도 다른 manager 라 격리.
39
+ - **앱별 bus prefix 격리**: web 의 `web.` 발행을 admin(`glob.`)이 못 받음.
40
+ - **cross-app**: web 의 `/cross` 가 `getApp('admin').bus` 로 admin 의 글로벌 버스에 직접 발행 → `b.com/received` 에 도착.
41
+
42
+ ## 문제 해결 — `host.not_mounted`
43
+
44
+ `{"error":{"code":"host.not_mounted"}}` 가 보이면 **다른 mega 앱이 그 포트를 점유**하고 있어, 이 앱이 listen 에
45
+ 실패하고 브라우저가 옛 서버를 때리는 것입니다(예: `sample/crud`가 3000 에서 도는 중). 확인·해결:
46
+
47
+ ```bash
48
+ lsof -nP -iTCP:3200 -sTCP:LISTEN # 누가 점유 중인지
49
+ # → 옛 서버를 끄거나, PORT 를 비어있는 값으로 바꿔 실행: PORT=3201 npm run dev
50
+ ```
51
+
52
+ 이 앱은 점유된 포트면 `listen EADDRINUSE` 로 **명확히 실패**합니다 — 그 로그가 보이면 포트를 바꾸세요.
53
+
54
+ 자세한 설명은 프레임워크 가이드 `docs/guide/10-multi-app.md` 참고.
@@ -0,0 +1,24 @@
1
+ // @ts-check
2
+ /**
3
+ * apps/admin/app.config.js — `admin` 앱(b.com). **앱별 lock/bus 미지정** → 글로벌 fallback 을 쓴다(ADR-229):
4
+ * - lock: 글로벌(memory) — web 의 redis 락과 격리(다른 manager).
5
+ * - bus: 글로벌(NATS, prefix `glob.`) — web 의 `web.` 와 subject 공간이 갈린다.
6
+ * 멀티앱이라도 앱이 lock/bus 를 정의하지 않으면 종전처럼 글로벌을 그대로 쓴다(호환).
7
+ */
8
+ export default {
9
+ name: 'admin',
10
+ hosts: ['b.com'],
11
+
12
+ views: { dir: 'apps/admin/views' },
13
+
14
+ // 정적 자산 — 데모 페이지의 클라이언트 JS(/static/js/admin.js)를 서빙(CSP script-src 'self' 호환).
15
+ staticAssets: { enabled: true, dir: 'apps/admin/public', prefix: '/static' },
16
+
17
+ // helmet CSP — 로컬 http 데모라 `upgrade-insecure-requests` 끄기(null). 안 끄면 브라우저가 https 로 강제
18
+ // 업그레이드해 연결 실패. useDefaults 라 나머지 보안 헤더는 유지(운영 https 에선 이 override 제거 권장).
19
+ helmet: {
20
+ contentSecurityPolicy: {
21
+ directives: { upgradeInsecureRequests: null },
22
+ },
23
+ },
24
+ }
@@ -0,0 +1,42 @@
1
+ // @ts-check
2
+ /**
3
+ * AdminController — `admin` 앱(b.com) 컨트롤러. 앱별 lock/bus 미지정이라 **글로벌**(memory 락 + glob. 버스)을 쓴다.
4
+ *
5
+ * 첫 요청에서 글로벌 버스의 `>`(= glob.>)를 구독해 받은 이벤트를 링버퍼에 모은다. web 의 `/cross`
6
+ * (getApp('admin').bus.emit('notice'))가 보낸 cross-app 이벤트가 여기로 들어온다. web 의 `web.` 발행은
7
+ * prefix 가 달라 들어오지 않는다(격리).
8
+ */
9
+
10
+ /** 수신 버퍼(워커별 인메모리). @type {any[]} */
11
+ const received = []
12
+ const MAX = 30
13
+ let subscribed = false
14
+
15
+ /** 글로벌 버스 1회 구독(워커별 멱등). @param {any} ctx @returns {Promise<void>} */
16
+ async function ensureSubscribed(ctx) {
17
+ if (subscribed) return
18
+ subscribed = true
19
+ await ctx.bus.on('>', (/** @type {any} */ payload, /** @type {any} */ _meta, /** @type {any} */ subject) => {
20
+ received.push({ subject, payload, at: new Date().toISOString() })
21
+ if (received.length > MAX) received.shift()
22
+ })
23
+ }
24
+
25
+ export class AdminController {
26
+ /** GET / — 셸 페이지. @param {any} _req @param {any} _reply @param {any} ctx */
27
+ static async home(_req, _reply, ctx) {
28
+ await ensureSubscribed(ctx)
29
+ return ctx.render('index', { app: 'admin', host: 'b.com' })
30
+ }
31
+
32
+ /** GET /whoami — 글로벌 lock/bus driver. @param {any} _req @param {any} _reply @param {any} ctx */
33
+ static async whoami(_req, _reply, ctx) {
34
+ return { app: 'admin', lock: (await ctx.lock.stats()).driver, bus: (await ctx.bus.stats()).driver, pid: process.pid }
35
+ }
36
+
37
+ /** GET /received — 글로벌 버스(glob.>)로 받은 이벤트(web 의 cross-app 포함, web. 발행은 격리되어 없음). @param {any} _req @param {any} _reply @param {any} ctx */
38
+ static async received(_req, _reply, ctx) {
39
+ await ensureSubscribed(ctx)
40
+ return { app: 'admin', count: received.length, events: received.slice().reverse() }
41
+ }
42
+ }
@@ -0,0 +1,31 @@
1
+ // @ts-check
2
+ /* admin 앱 데모 — whoami / 수신 이벤트(received)를 fetch 해 인라인 표시 + 2초 자동 새로고침. */
3
+ ;(function () {
4
+ var out = document.getElementById('out')
5
+ function show(/** @type {any} */ obj) {
6
+ if (out) out.textContent = JSON.stringify(obj, null, 2)
7
+ }
8
+ function load(/** @type {string} */ url) {
9
+ fetch(url, { headers: { accept: 'application/json' } })
10
+ .then(function (res) {
11
+ return res.json()
12
+ })
13
+ .then(function (j) {
14
+ show(j && j.data != null ? j.data : j)
15
+ })
16
+ .catch(function (e) {
17
+ if (out) out.textContent = '오류: ' + e.message
18
+ })
19
+ }
20
+
21
+ var whoami = document.querySelector('button[data-get="/whoami"]')
22
+ if (whoami) whoami.addEventListener('click', function () { load('/whoami') })
23
+ var refresh = document.getElementById('refresh')
24
+ if (refresh) refresh.addEventListener('click', function () { load('/received') })
25
+
26
+ // 첫 로드 + 2초 폴링(탭 숨김 시 멈춤) — web 의 cross-app 이벤트가 도착하는 것을 실시간으로 본다.
27
+ load('/received')
28
+ setInterval(function () {
29
+ if (!document.hidden) load('/received')
30
+ }, 2000)
31
+ })()
@@ -0,0 +1,11 @@
1
+ // @ts-check
2
+ /**
3
+ * apps/admin 라우트 — 자동 로딩. 글로벌 lock/bus(fallback) 사용 + cross-app 으로 받은 이벤트 표시.
4
+ */
5
+ import { AdminController } from '../controllers/admin-controller.js'
6
+
7
+ export default (/** @type {any} */ router) => {
8
+ router.http.get('/', AdminController.home)
9
+ router.http.get('/whoami', AdminController.whoami) // 글로벌 lock/bus driver 확인
10
+ router.http.get('/received', AdminController.received) // 글로벌 버스(glob.>)로 받은 이벤트 — web 의 cross-app 포함
11
+ }
@@ -0,0 +1,33 @@
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>admin (b.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: #6c757d; 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 #6c757d; background: #fff; color: #495057; border-radius: 6px; cursor: pointer; }
12
+ button:hover { background: #6c757d; color: #fff; }
13
+ .out { background: #f8f9fa; border: 1px solid #e3e6ea; border-radius: 6px; padding: .5rem .7rem; font-family: ui-monospace, monospace; font-size: .85rem; white-space: pre-wrap; min-height: 1.4rem; margin: .3rem 0; }
14
+ .desc { color: #555; font-size: .9rem; margin: .1rem 0 .3rem; }
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <h1>admin 앱 <span class="tag">b.com</span></h1>
19
+ <p>이 앱은 <strong>앱별 lock/bus 설정이 없어 글로벌 fallback</strong>(lock=memory, bus prefix <code>glob.</code>)을
20
+ 씁니다 (ADR-229). 같은 프로세스의 <code>web</code>(a.com)은 앱 전용 redis 락 + <code>web.</code> 버스를 씁니다.</p>
21
+
22
+ <div>
23
+ <button data-get="/whoami">whoami</button>
24
+ <button id="refresh">수신 이벤트 새로고침</button>
25
+ </div>
26
+ <div class="desc">이 앱의 lock/bus driver — memory / nats(glob.). 아래는 글로벌 버스로 받은 이벤트입니다.</div>
27
+ <div class="out" id="out"></div>
28
+ <p class="desc"><code>web</code> 의 <code>/cross</code>(getApp('admin').bus)가 보낸 <code>notice</code> 가 여기 들어옵니다(cross-app).
29
+ web 의 <code>web.</code> 발행은 prefix 격리로 들어오지 않습니다. web 앱은 <a href="http://a.com:3200/">a.com</a>.</p>
30
+
31
+ <script src="/static/js/admin.js"></script>
32
+ </body>
33
+ </html>
@@ -0,0 +1,30 @@
1
+ // @ts-check
2
+ /**
3
+ * apps/web/app.config.js — `web` 앱(a.com). **앱 전용 lock/bus**(ADR-229)로 admin 과 분리한다.
4
+ * - lock: redis(`webLock` 캐시) — admin 의 글로벌 memory 락과 다른 backend.
5
+ * - bus: NATS, prefix `web.` — 글로벌 `glob.` 와 subject 공간이 갈려 격리된다(같은 NATS 공유).
6
+ */
7
+ export default {
8
+ name: 'web',
9
+ hosts: ['a.com', 'localhost'],
10
+
11
+ views: { dir: 'apps/web/views' },
12
+
13
+ // 정적 자산 — 데모 페이지의 클라이언트 JS(/static/js/web.js)를 서빙(CSP script-src 'self' 호환).
14
+ staticAssets: { enabled: true, dir: 'apps/web/public', prefix: '/static' },
15
+
16
+ // helmet CSP — 로컬 http 데모라 `upgrade-insecure-requests` 를 끈다(null 로 디폴트 지시어 제거). 안 끄면
17
+ // 브라우저가 http://a.com:3200 을 https 로 강제 업그레이드해 (TLS 없음) 연결 실패한다. useDefaults 라 나머지
18
+ // 보안 헤더는 유지. (운영 https 배포에선 이 override 를 빼서 업그레이드를 다시 켜는 게 맞다.)
19
+ helmet: {
20
+ contentSecurityPolicy: {
21
+ directives: { upgradeInsecureRequests: null },
22
+ },
23
+ },
24
+
25
+ // 앱 전용 분산 락 — 글로벌(memory)과 달리 redis backend. lock.cache 는 글로벌 services.caches 키를 참조.
26
+ lock: { cache: 'webLock' },
27
+
28
+ // 앱 전용 버스 — 글로벌과 같은 NATS(`events`)지만 prefix 가 달라(web.) admin(glob.)과 격리된다.
29
+ bus: { nats: 'events', prefix: 'web.' },
30
+ }
@@ -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
  }