mega-framework 0.1.5 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mega-framework",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
5
5
  "type": "module",
6
6
  "engines": {
package/sample/crud/.env CHANGED
@@ -1,27 +1,175 @@
1
+ # =============================================================================
2
+ # sample-crud 환경변수 (.env) — 프레임워크 전체 설정 참조
3
+ # =============================================================================
4
+ # CLI(`mega start`/`worker`/`scheduler`)가 부팅 전에 이 파일을 process.env 로
5
+ # 자동 로드한다(Node `process.loadEnvFile`, 20.6+, ADR-152). 이미 설정된 실제
6
+ # 환경변수는 덮어쓰지 않는다(실 env 우선 — `--env-file` 과 동일). 그 뒤 mega.config.js·
7
+ # apps/<app>/app.config.js 가 `process.env.X` 로 이 값들을 참조한다.
8
+ #
9
+ # [표기 규칙]
10
+ # KEY=value → 이 데모(crud)가 실제로 쓰는 값.
11
+ # # KEY=value → 프레임워크는 지원하지만 이 데모는 안 쓰는 옵션(참고용 — 필요 시 주석 해제).
12
+ #
13
+ # ⚠️ 아래 시크릿/비밀번호는 로컬 도커 데모 전용. 운영 배포 전 반드시 교체할 것.
14
+ # =============================================================================
15
+
16
+
17
+ # ── 실행 환경 ────────────────────────────────────────────────────────────────
18
+ # development | production | test. pretty 로깅·정적자산 캐싱·i18n 자동완성 등에 영향.
19
+ # 보통 npm 스크립트(dev=development / start=production)가 설정하므로 .env 에선 생략 가능.
20
+ # NODE_ENV=development
21
+
22
+
23
+ # ── 서버 (mega.config.js > server) ───────────────────────────────────────────
24
+ # HTTP listen 포트 — server.port. CLI `--port N` 이 우선(ADR-146).
1
25
  PORT=3000
2
- DATABASE_URL=postgres://mega:dkTkqkfl12@localhost:5432/mega_test
3
- MEGA_CLUSTER_WORKERS=8
26
+ # 세션 쿠키 HMAC 서명 시크릿(필수) — server.sessionSecret. 32자 이상 랜덤 강제(ADR-129/155).
4
27
  SESSION_SECRET=Zz4VoSzf0sYMEoqASu8G_wx5l3uKi2MlHsxDK3MSkoE
28
+ # cluster 워커 프로세스 수(CLI 가 읽음, ADR-154) — 정수 N 또는 max(CPU 코어 수). 미설정/1=단일.
29
+ # 우선순위: `--cluster` 플래그 > MEGA_CLUSTER_WORKERS > server.cluster config.
30
+ MEGA_CLUSTER_WORKERS=8
31
+
32
+
33
+ # ── ASP: Application-layer Secure Protocol (mega.config.js > asp) ─────────────
34
+ # /ws/chat 의 E:/P: 프레임 키 유도 masterSecret(필수) — asp.masterSecret (ADR-127/158).
35
+ # 클라이언트(WASM MegaSocket)도 같은 secret 으로 키를 유도하는 공유키 구조라 데모 전용 값을 둔다.
36
+ ASP_MASTER_SECRET=demo-asp-master-7Qe2mWzR1tYbN8sLpKvX0cAfH4dG6jU
37
+
38
+
39
+ # ── 데이터베이스 (mega.config.js > services.databases) ────────────────────────
40
+ # Postgres(필수) — databases.primary.url. 모델·마이그레이션(mega migrate)·세션 사용자.
41
+ DATABASE_URL=postgres://mega:dkTkqkfl12@localhost:5432/mega_test
42
+ # MongoDB(필수) — databases.mongo.url. /demo/notes 컬렉션(ADR-108). url path 의 dbName 추출,
43
+ # authSource=인증 DB(보통 admin).
44
+ MONGO_URL=mongodb://mega:dkTkqkfl12@localhost:27017/mega_test?authSource=admin
45
+ # MariaDB(미사용) — 프레임워크는 mariadb 드라이버 지원. 쓰려면 mega.config 의 services.databases 에
46
+ # { driver:'mariadb', url: process.env.MARIA_URL } 추가. (url 은 query string 미지원)
47
+ # MARIA_URL=mariadb://mega:dkTkqkfl12@localhost:3306/mega_test
48
+
49
+
50
+ # ── Redis 캐시 (mega.config.js > services.caches, app.config > session) ───────
51
+ # 논리 DB 인덱스를 분리해 키 충돌 회피(/0 세션 · /1 demo · /2 rate · /3 lock).
52
+ # 세션 store(app.config.js session.store.url, ADR-129/155).
5
53
  REDIS_SESSION_URL=redis://:dkTkqkfl12@localhost:6379/0
54
+ # brute-force 카운터(ctx.bruteForce, ADR-049/130) — caches.rate.
6
55
  REDIS_RATE_URL=redis://:dkTkqkfl12@localhost:6379/2
56
+ # /demo/redis·cron·jobs 데모 캐시 — caches.demo.
7
57
  REDIS_DEMO_URL=redis://:dkTkqkfl12@localhost:6379/1
58
+ # 분산 락(redlock, ADR-113) — caches.lock(locks.main 이 빌려 씀). /demo/cron leader election.
8
59
  REDIS_LOCK_URL=redis://:dkTkqkfl12@localhost:6379/3
9
- MONGO_URL=mongodb://mega:dkTkqkfl12@localhost:27017/mega_test?authSource=admin
60
+ # 범용 캐시(미사용) — 별도 캐시 어댑터가 필요하면 services.caches 에 추가해 참조.
61
+ # REDIS_CACHE_URL=redis://:dkTkqkfl12@localhost:6379/4
62
+
63
+
64
+ # ── NATS 버스 (mega.config.js > services.buses) ──────────────────────────────
65
+ # 잡 큐(EmailJob JetStream, ADR-119) — buses.jobs.url. `mega worker` 가 소비, 웹이 enqueue.
66
+ # JetStream 활성 서버 필요(nats-server -js).
10
67
  NATS_JOBS_URL=nats://localhost:4222
11
- ASP_MASTER_SECRET=demo-asp-master-7Qe2mWzR1tYbN8sLpKvX0cAfH4dG6jU
68
+ # 이벤트 버스(미사용) — pub/sub 등 두 번째 버스가 필요하면 services.buses 에 추가.
69
+ # NATS_EVENTS_URL=nats://localhost:4222
70
+
12
71
 
72
+ # ── WS Hub: 클러스터 채팅 브릿지 (ADR-059/065/137/176) ────────────────────────
73
+ # crud 는 app.config.js 의 bridgeHub 로 /ws/chat 을 클러스터 전파한다. 허브는 `mega ws-hub`(scripts/
74
+ # start-ws-hub.sh)로 별도 프로세스 기동. 아래 MEGA_WSHUB_* 는 src/cli/ws-hub.js 가 읽는다.
75
+ # --- 허브 서버가 읽는 값 (`mega ws-hub`) ---
76
+ # 수락 토큰 CSV(필수) — 브릿지 인증. 빈 값이면 허브 기동 실패. 운영 교체.
13
77
  MEGA_WSHUB_TOKENS=dev-bridge-token-change-me
14
- MEGA_WSHUB_TOKEN=dev-bridge-token-change-me
15
78
  MEGA_WSHUB_PORT=3100
16
79
  MEGA_WSHUB_HOST=0.0.0.0
80
+ # 허브 튜닝(선택) — 미설정 시 코드 기본값(heartbeat 25000 / maxPayload 1 MiB / 압축 off).
81
+ MEGA_WSHUB_HEARTBEAT_MS=25000
82
+ MEGA_WSHUB_MAX_PAYLOAD=1048576
83
+ MEGA_WSHUB_COMPRESSION=false
84
+ MEGA_WSHUB_COMPRESSION_THRESHOLD=1024 # MEGA_WSHUB_COMPRESSION=true 일 때만 적용
85
+ # --- 앱(브릿지 클라이언트)이 읽는 값 (app.config.js > bridgeHub) ---
17
86
  MEGA_WSHUB_URL=ws://localhost:3100
87
+ MEGA_WSHUB_TOKEN=dev-bridge-token-change-me
88
+ # 이 노드(브릿지) 식별자 — bridgeHub.bridgeId. roster/로그 구분용(클러스터 워커는 -w{id} suffix 자동).
89
+ BRIDGE_ID=crud-1
90
+
18
91
 
92
+ # ── OpenTelemetry 분산 트레이싱 (선택, ADR-104/114/126/163) ───────────────────
93
+ # MegaTracing.fromEnv() 가 부팅 시 읽는다(boot.js). ENABLED!=='true' 면 0 비용 no-op.
94
+ # /demo/tracing 데모가 요청별 trace_id 를 만들고 Zipkin 딥링크로 잇는다. docker 의
95
+ # otel-collector(:4318 OTLP HTTP)·zipkin(:9411) 가동 시 true 로 켠다(collector 부재 시 export 실패 로그).
19
96
  MEGA_OTEL_ENABLED=true
20
97
  MEGA_OTEL_SERVICE_NAME=sample-crud
21
98
  MEGA_OTEL_ENDPOINT=http://localhost:4318/v1/traces
22
- MEGA_OTEL_EXPORTER=otlp
23
- MEGA_OTEL_SAMPLING_RATIO=1.0
99
+ MEGA_OTEL_EXPORTER=otlp # otlp | zipkin | console | inmemory
100
+ MEGA_OTEL_SAMPLING_RATIO=1.0 # 0.0~1.0 또는 always_on | always_off
101
+ # Zipkin UI API base — /demo/tracing 딥링크 base(앱 코드가 읽음).
24
102
  MEGA_OTEL_ZIPKIN_API=http://localhost:9411/api/v2
103
+ # resource 속성(선택) — 미설정 시 생략.
104
+ # MEGA_OTEL_VERSION=1.0.0
105
+ # MEGA_OTEL_ENVIRONMENT=local
106
+
107
+
108
+ # ── Prometheus 메트릭 (선택, ADR-072/131) ────────────────────────────────────
109
+ # crud 는 mega.config.js 의 health.exposeMetrics:true 로 /metrics 를 켠다(config 주도 — env 아님).
110
+ # 아래 env 는 MegaMetrics.fromEnv() 를 직접 호출하는 커스텀 부팅에서만 효과(기본 boot 는 config 사용).
111
+ # METRICS_ENABLED=true # 또는 MEGA_METRICS_ENABLED
112
+ # MEGA_METRICS_SERVICE_NAME=sample-crud
113
+ # MEGA_METRICS_ENVIRONMENT=local
114
+
115
+
116
+ # ── 로깅 (mega.config.js > logger, ADR-023/141) ──────────────────────────────
117
+ # pino 레벨 — logger.level. fatal | error | warn | info | debug | trace.
118
+ LOG_LEVEL=info
119
+ # (참고) 파일/텔레그램 sink 등은 .env 가 아니라 mega.config.js 의 logger.sinks 배열로 설정한다.
120
+
121
+
122
+ # ── 데모 (앱 코드가 읽음) ─────────────────────────────────────────────────────
123
+ # /demo/upload 저장 디렉터리. 상대경로=프로젝트 루트(cwd) 기준. 미설정 시 var/uploads.
25
124
  DEMO_UPLOAD_DIR=var/uploads
26
125
 
27
- BRIDGE_ID=crud-1
126
+
127
+ # =============================================================================
128
+ # Docker 인프라 (docker-compose.yml 이 읽음, ADR-103) — 전부 선택/주석
129
+ # =============================================================================
130
+ # 위 연결 문자열(DATABASE_URL 등)은 아래 도커 기본값과 정합한다. docker-compose.yml 에 인라인
131
+ # 디폴트가 있어 미설정이어도 컨테이너가 뜨지만, 포트/비번을 바꾸려면 아래를 켜고 위 URL 도 함께 맞춘다.
132
+ # 네이밍: MEGA_<SERVICE>_<FIELD>.
133
+ # MEGA_PG_PORT=5432
134
+ # MEGA_PG_USER=mega
135
+ # MEGA_PG_PASSWORD=dkTkqkfl12
136
+ # MEGA_PG_DB=mega_test
137
+ # MEGA_MONGO_PORT=27017
138
+ # MEGA_MONGO_USER=mega
139
+ # MEGA_MONGO_PASSWORD=dkTkqkfl12
140
+ # MEGA_MONGO_DB=mega_test
141
+ # MEGA_REDIS_PORT=6379
142
+ # MEGA_REDIS_PASSWORD=dkTkqkfl12
143
+ # MEGA_NATS_PORT=4222
144
+ # MEGA_NATS_MONITOR_PORT=8222
145
+ # MEGA_MARIA_PORT=3306
146
+ # MEGA_MARIA_ROOT_PASSWORD=dkTkqkfl12
147
+ # MEGA_MARIA_USER=mega
148
+ # MEGA_MARIA_PASSWORD=dkTkqkfl12
149
+ # MEGA_MARIA_DB=mega_test
150
+ # 컨테이너 restart 정책: 개발=unless-stopped / CI 일회성=no.
151
+ # MEGA_INFRA_RESTART=unless-stopped
152
+
153
+
154
+ # =============================================================================
155
+ # 어댑터 옵션 자동매핑 (ADR-109, 12-factor) — 전부 선택/주석
156
+ # =============================================================================
157
+ # services.<domain>.<key> 에 `envPrefix: 'PG'` 처럼 지정하면 MegaAdapterManager 가
158
+ # MEGA_<PREFIX>_<KEY> 를 읽어 어댑터 옵션에 병합(env 우선). url 직접 지정 대신 12-factor 분리용.
159
+ # (crud 는 url 직접 방식이라 미사용 — 아래는 문법 참고.)
160
+ # 문법: MEGA_<PREFIX>_<KEY>
161
+ # URL → url
162
+ # HOST / PORT / USER / PASSWORD → 연결필드 (PORT 만 정수, 나머지 문자열)
163
+ # DATABASE | DB → database / DBNAME → dbName(Mongo)
164
+ # POOL_<X> → pool.{camelCase(X)} (드라이버 공통, 값 자동 타입변환)
165
+ # OPTIONS_<X> → options.{드라이버 표기} (postgres/sqlite=snake, 그 외=camel; MS 대문자 보존)
166
+ # 공통 풀 인터페이스(min/max/idleTimeoutMs/acquireTimeoutMs/maxLifetimeMs) 예:
167
+ # MEGA_PG_POOL_MIN=0
168
+ # MEGA_PG_POOL_MAX=10
169
+ # MEGA_PG_POOL_IDLE_TIMEOUT_MS=10000
170
+ # 드라이버 특화 옵션 예:
171
+ # MEGA_PG_OPTIONS_SSL=true # → options.ssl
172
+ # MEGA_PG_OPTIONS_STATEMENT_TIMEOUT=30000 # → options.statement_timeout (pg=snake)
173
+ # MEGA_MONGO_OPTIONS_AUTH_SOURCE=admin # → options.authSource (mongo=camel)
174
+ # MEGA_MONGO_OPTIONS_SERVER_SELECTION_TIMEOUT_MS=5000 # → options.serverSelectionTimeoutMS
175
+ # MEGA_MARIA_OPTIONS_BIG_INT_STRATEGY=number # number(기본,2^53 초과 손실) | bigint | string
@@ -1,50 +1,175 @@
1
- # sample-crud 환경변수 (.env.example) — 복사해서 .env 로 쓰고 실제 값 채우기. .env 는 git 에 안 올림.
1
+ # =============================================================================
2
+ # sample-crud 환경변수 (.env.example) — 프레임워크 전체 설정 참조
3
+ # =============================================================================
4
+ # 복사해서 `.env` 로 쓰고 시크릿/비밀번호를 실제 값으로 채운다. CLI(`mega start`/`worker`/
5
+ # `scheduler`)가 부팅 전에 `.env` 를 process.env 로 자동 로드한다(Node `process.loadEnvFile`,
6
+ # 20.6+, ADR-152). 이미 설정된 실제 환경변수는 덮어쓰지 않는다(실 env 우선). 그 뒤 mega.config.js·
7
+ # apps/<app>/app.config.js 가 `process.env.X` 로 이 값들을 참조한다.
8
+ #
9
+ # [표기 규칙]
10
+ # KEY=value → 이 데모(crud)가 실제로 쓰는 값.
11
+ # # KEY=value → 프레임워크는 지원하지만 이 데모는 안 쓰는 옵션(참고용 — 필요 시 주석 해제).
12
+ #
13
+ # ⚠️ change-me 로 표시된 값은 반드시 교체할 것(시크릿은 32자 이상 랜덤 권장).
14
+ # =============================================================================
2
15
 
3
- # HTTP 포트
16
+
17
+ # ── 실행 환경 ────────────────────────────────────────────────────────────────
18
+ # development | production | test. pretty 로깅·정적자산 캐싱·i18n 자동완성 등에 영향.
19
+ # 보통 npm 스크립트(dev=development / start=production)가 설정하므로 .env 에선 생략 가능.
20
+ # NODE_ENV=development
21
+
22
+
23
+ # ── 서버 (mega.config.js > server) ───────────────────────────────────────────
24
+ # HTTP listen 포트 — server.port. CLI `--port N` 이 우선(ADR-146).
4
25
  PORT=3000
26
+ # 세션 쿠키 HMAC 서명 시크릿(필수) — server.sessionSecret. 32자 이상 랜덤 강제(ADR-129/155).
27
+ SESSION_SECRET=change-me-to-a-long-random-secret-at-least-32-chars
28
+ # cluster 워커 프로세스 수(CLI 가 읽음, ADR-154) — 정수 N 또는 max(CPU 코어 수). 미설정/1=단일.
29
+ # 우선순위: `--cluster` 플래그 > MEGA_CLUSTER_WORKERS > server.cluster config.
30
+ MEGA_CLUSTER_WORKERS=8
31
+
32
+
33
+ # ── ASP: Application-layer Secure Protocol (mega.config.js > asp) ─────────────
34
+ # /ws/chat 의 E:/P: 프레임 키 유도 masterSecret(필수) — asp.masterSecret (ADR-127/158).
35
+ # 클라이언트(WASM MegaSocket)도 같은 secret 으로 키를 유도하는 공유키 구조라 데모 전용 값을 둔다.
36
+ ASP_MASTER_SECRET=change-me-to-a-demo-asp-secret
5
37
 
6
- # cluster 워커 프로세스 수(ADR-154) — 정수 N 또는 max(CPU 코어 수). 미설정/1=단일 프로세스.
7
- # MEGA_CLUSTER_WORKERS=max
8
38
 
9
- # Postgres 연결 (필수) services.databases.primary.url 이 읽는다.
10
- # 예: docker compose postgres
39
+ # ── 데이터베이스 (mega.config.js > services.databases) ────────────────────────
40
+ # Postgres(필수) databases.primary.url. 모델·마이그레이션(mega migrate)·세션 사용자.
11
41
  DATABASE_URL=postgres://mega:change-me@localhost:5432/mega_test
42
+ # MongoDB(필수) — databases.mongo.url. /demo/notes 컬렉션(ADR-108). url path 의 dbName 추출,
43
+ # authSource=인증 DB(보통 admin).
44
+ MONGO_URL=mongodb://mega:change-me@localhost:27017/mega_test?authSource=admin
45
+ # MariaDB(미사용) — 프레임워크는 mariadb 드라이버 지원. 쓰려면 mega.config 의 services.databases 에
46
+ # { driver:'mariadb', url: process.env.MARIA_URL } 추가. (url 은 query string 미지원)
47
+ # MARIA_URL=mariadb://mega:change-me@localhost:3306/mega_test
12
48
 
13
- # 세션 쿠키 HMAC 서명 시크릿 (필수, ADR-155) — server.sessionSecret 이 읽는다. 충분히 긴 랜덤 문자열.
14
- SESSION_SECRET=change-me-to-a-long-random-secret
15
49
 
16
- # Redis 연결 (필수, ADR-155/157) 세션 store·brute-force·/demo/redis·cron·jobs 데모 캐시 + 분산 락(redlock).
17
- # DB 인덱스를 분리해 키 충돌을 피한다(/0 세션, /1 demo, /2 rate, /3 lock).
50
+ # ── Redis 캐시 (mega.config.js > services.caches, app.config > session) ───────
51
+ # 논리 DB 인덱스를 분리해 키 충돌 회피(/0 세션 · /1 demo · /2 rate · /3 lock).
52
+ # 세션 store(app.config.js session.store.url, ADR-129/155).
18
53
  REDIS_SESSION_URL=redis://:change-me@localhost:6379/0
54
+ # brute-force 카운터(ctx.bruteForce, ADR-049/130) — caches.rate.
19
55
  REDIS_RATE_URL=redis://:change-me@localhost:6379/2
56
+ # /demo/redis·cron·jobs 데모 캐시 — caches.demo.
20
57
  REDIS_DEMO_URL=redis://:change-me@localhost:6379/1
21
- # 분산 락(redlock, ADR-113) — services.caches.lock 이 읽는다. /demo/cron 스케줄의 클러스터 중복방지(leader election).
58
+ # 분산 락(redlock, ADR-113) — caches.lock(locks.main빌려 씀). /demo/cron leader election.
22
59
  REDIS_LOCK_URL=redis://:change-me@localhost:6379/3
60
+ # 범용 캐시(미사용) — 별도 캐시 어댑터가 필요하면 services.caches 에 추가해 참조.
61
+ # REDIS_CACHE_URL=redis://:change-me@localhost:6379/4
62
+
23
63
 
24
- # NATS 연결 (필수, ADR-119) services.buses.jobs.url 이 읽는다. /demo/jobs 잡 큐(EmailJob) JetStream 백엔드.
25
- # `mega worker` 프로세스가 소비하고 웹은 enqueue 한다. JetStream 활성 NATS 서버 필요(nats-server -js).
64
+ # ── NATS 버스 (mega.config.js > services.buses) ──────────────────────────────
65
+ # 큐(EmailJob JetStream, ADR-119) buses.jobs.url. `mega worker` 소비, 웹이 enqueue.
66
+ # JetStream 활성 서버 필요(nats-server -js).
26
67
  NATS_JOBS_URL=nats://localhost:4222
68
+ # 이벤트 버스(미사용) — pub/sub 등 두 번째 버스가 필요하면 services.buses 에 추가.
69
+ # NATS_EVENTS_URL=nats://localhost:4222
27
70
 
28
- # MongoDB 연결 (필수, ADR-157) — services.databases.mongo.url 이 읽는다. /demo/notes 데모 컬렉션의 백엔드.
29
- # url path 의 dbName(mega_test)을 어댑터가 추출한다. authSource 는 인증 DB(보통 admin).
30
- MONGO_URL=mongodb://mega:change-me@localhost:27017/mega_test?authSource=admin
31
71
 
32
- # ASP WebSocket 데모 masterSecret (필수, ADR-158) — asp.masterSecret 이 읽는다. /ws/chat 의 E:/P:
33
- # 프레임 유도에 쓰인다. ASP 클라이언트(WASM MegaSocket)도 같은 secret 으로 키를 만드는 공유-키
34
- # 구조라 데모 페이지가 값을 브라우저에 주입한다 운영 secret 과 섞이지 않게 데모 전용 값을 둔다.
35
- ASP_MASTER_SECRET=change-me-to-a-demo-asp-secret
72
+ # ── WS Hub: 클러스터 채팅 브릿지 (ADR-059/065/137/176) ────────────────────────
73
+ # crud app.config.js bridgeHub /ws/chat 클러스터 전파한다. 허브는 `mega ws-hub`(scripts/
74
+ # start-ws-hub.sh)로 별도 프로세스 기동. 아래 MEGA_WSHUB_* src/cli/ws-hub.js 읽는다.
75
+ # --- 허브 서버가 읽는 값 (`mega ws-hub`) ---
76
+ # 수락 토큰 CSV(필수) — 브릿지 인증. 빈 값이면 허브 기동 실패. 운영 교체.
77
+ MEGA_WSHUB_TOKENS=change-me-hub-token
78
+ MEGA_WSHUB_PORT=3100
79
+ MEGA_WSHUB_HOST=0.0.0.0
80
+ # 허브 튜닝(선택) — 미설정 시 코드 기본값(heartbeat 25000 / maxPayload 1 MiB / 압축 off).
81
+ # MEGA_WSHUB_HEARTBEAT_MS=25000
82
+ # MEGA_WSHUB_MAX_PAYLOAD=1048576
83
+ # MEGA_WSHUB_COMPRESSION=false
84
+ # MEGA_WSHUB_COMPRESSION_THRESHOLD=1024 # MEGA_WSHUB_COMPRESSION=true 일 때만 적용
85
+ # --- 앱(브릿지 클라이언트)이 읽는 값 (app.config.js > bridgeHub) ---
86
+ MEGA_WSHUB_URL=ws://localhost:3100
87
+ MEGA_WSHUB_TOKEN=change-me-hub-token
88
+ # 이 노드(브릿지) 식별자 — bridgeHub.bridgeId. roster/로그 구분용(클러스터 워커는 -w{id} suffix 자동).
89
+ BRIDGE_ID=crud-1
36
90
 
37
- # OpenTelemetry 트레이싱 (선택, ADR-104/126/163) — /demo/tracing 데모가 요청별 trace_id 를 만들고 Zipkin 으로
38
- # 잇는다. 미설정/false 트레이싱 OFF(데모는 비활성 안내를 보여줌). docker 의 otel-collector(:4318 OTLP HTTP)·
39
- # zipkin(:9411) 가동 시 true 켠다. exporter otlp → collector → zipkin 경로로 span 이 전달된다.
91
+
92
+ # ── OpenTelemetry 분산 트레이싱 (선택, ADR-104/114/126/163) ───────────────────
93
+ # MegaTracing.fromEnv() 부팅 읽는다(boot.js). ENABLED!=='true' 0 비용 no-op.
94
+ # /demo/tracing 데모가 요청별 trace_id 를 만들고 Zipkin 딥링크로 잇는다. docker 의
95
+ # otel-collector(:4318 OTLP HTTP)·zipkin(:9411) 가동 시 true 로 켠다(collector 부재 시 export 실패 로그).
40
96
  MEGA_OTEL_ENABLED=false
41
97
  MEGA_OTEL_SERVICE_NAME=sample-crud
42
98
  MEGA_OTEL_ENDPOINT=http://localhost:4318/v1/traces
43
- MEGA_OTEL_EXPORTER=otlp
44
- MEGA_OTEL_SAMPLING_RATIO=1.0
45
- # Zipkin UI API base — /demo/tracing 이 trace 딥링크 base(.../api/v2 떼 UI 루트)로 쓴다.
99
+ MEGA_OTEL_EXPORTER=otlp # otlp | zipkin | console | inmemory
100
+ MEGA_OTEL_SAMPLING_RATIO=1.0 # 0.0~1.0 또는 always_on | always_off
101
+ # Zipkin UI API base — /demo/tracing 딥링크 base( 코드가 읽음).
46
102
  MEGA_OTEL_ZIPKIN_API=http://localhost:9411/api/v2
103
+ # resource 속성(선택) — 미설정 시 생략.
104
+ # MEGA_OTEL_VERSION=1.0.0
105
+ # MEGA_OTEL_ENVIRONMENT=local
106
+
107
+
108
+ # ── Prometheus 메트릭 (선택, ADR-072/131) ────────────────────────────────────
109
+ # crud 는 mega.config.js 의 health.exposeMetrics:true 로 /metrics 를 켠다(config 주도 — env 아님).
110
+ # 아래 env 는 MegaMetrics.fromEnv() 를 직접 호출하는 커스텀 부팅에서만 효과(기본 boot 는 config 사용).
111
+ # METRICS_ENABLED=true # 또는 MEGA_METRICS_ENABLED
112
+ # MEGA_METRICS_SERVICE_NAME=sample-crud
113
+ # MEGA_METRICS_ENVIRONMENT=local
114
+
115
+
116
+ # ── 로깅 (mega.config.js > logger, ADR-023/141) ──────────────────────────────
117
+ # pino 레벨 — logger.level. fatal | error | warn | info | debug | trace.
118
+ LOG_LEVEL=info
119
+ # (참고) 파일/텔레그램 sink 등은 .env 가 아니라 mega.config.js 의 logger.sinks 배열로 설정한다.
47
120
 
48
- # 업로드 저장 디렉터리 (선택, ADR-163) — /demo/upload 이 파일을 저장할 위치. 상대경로는 프로젝트 루트
49
- # (cwd) 기준, 절대경로면 그대로. 미설정 시 var/uploads(.gitignore). 다운로드도 같은 위치에서 소스로 읽는다.
121
+
122
+ # ── 데모 (앱 코드가 읽음) ─────────────────────────────────────────────────────
123
+ # /demo/upload 저장 디렉터리. 상대경로=프로젝트 루트(cwd) 기준. 미설정 시 var/uploads.
50
124
  DEMO_UPLOAD_DIR=var/uploads
125
+
126
+
127
+ # =============================================================================
128
+ # Docker 인프라 (docker-compose.yml 이 읽음, ADR-103) — 전부 선택/주석
129
+ # =============================================================================
130
+ # 위 연결 문자열(DATABASE_URL 등)은 아래 도커 기본값과 정합한다. docker-compose.yml 에 인라인
131
+ # 디폴트가 있어 미설정이어도 컨테이너가 뜨지만, 포트/비번을 바꾸려면 아래를 켜고 위 URL 도 함께 맞춘다.
132
+ # 네이밍: MEGA_<SERVICE>_<FIELD>.
133
+ # MEGA_PG_PORT=5432
134
+ # MEGA_PG_USER=mega
135
+ # MEGA_PG_PASSWORD=change-me
136
+ # MEGA_PG_DB=mega_test
137
+ # MEGA_MONGO_PORT=27017
138
+ # MEGA_MONGO_USER=mega
139
+ # MEGA_MONGO_PASSWORD=change-me
140
+ # MEGA_MONGO_DB=mega_test
141
+ # MEGA_REDIS_PORT=6379
142
+ # MEGA_REDIS_PASSWORD=change-me
143
+ # MEGA_NATS_PORT=4222
144
+ # MEGA_NATS_MONITOR_PORT=8222
145
+ # MEGA_MARIA_PORT=3306
146
+ # MEGA_MARIA_ROOT_PASSWORD=change-me
147
+ # MEGA_MARIA_USER=mega
148
+ # MEGA_MARIA_PASSWORD=change-me
149
+ # MEGA_MARIA_DB=mega_test
150
+ # 컨테이너 restart 정책: 개발=unless-stopped / CI 일회성=no.
151
+ # MEGA_INFRA_RESTART=unless-stopped
152
+
153
+
154
+ # =============================================================================
155
+ # 어댑터 옵션 자동매핑 (ADR-109, 12-factor) — 전부 선택/주석
156
+ # =============================================================================
157
+ # services.<domain>.<key> 에 `envPrefix: 'PG'` 처럼 지정하면 MegaAdapterManager 가
158
+ # MEGA_<PREFIX>_<KEY> 를 읽어 어댑터 옵션에 병합(env 우선). url 직접 지정 대신 12-factor 분리용.
159
+ # (crud 는 url 직접 방식이라 미사용 — 아래는 문법 참고.)
160
+ # 문법: MEGA_<PREFIX>_<KEY>
161
+ # URL → url
162
+ # HOST / PORT / USER / PASSWORD → 연결필드 (PORT 만 정수, 나머지 문자열)
163
+ # DATABASE | DB → database / DBNAME → dbName(Mongo)
164
+ # POOL_<X> → pool.{camelCase(X)} (드라이버 공통, 값 자동 타입변환)
165
+ # OPTIONS_<X> → options.{드라이버 표기} (postgres/sqlite=snake, 그 외=camel; MS 대문자 보존)
166
+ # 공통 풀 인터페이스(min/max/idleTimeoutMs/acquireTimeoutMs/maxLifetimeMs) 예:
167
+ # MEGA_PG_POOL_MIN=0
168
+ # MEGA_PG_POOL_MAX=10
169
+ # MEGA_PG_POOL_IDLE_TIMEOUT_MS=10000
170
+ # 드라이버 특화 옵션 예:
171
+ # MEGA_PG_OPTIONS_SSL=true # → options.ssl
172
+ # MEGA_PG_OPTIONS_STATEMENT_TIMEOUT=30000 # → options.statement_timeout (pg=snake)
173
+ # MEGA_MONGO_OPTIONS_AUTH_SOURCE=admin # → options.authSource (mongo=camel)
174
+ # MEGA_MONGO_OPTIONS_SERVER_SELECTION_TIMEOUT_MS=5000 # → options.serverSelectionTimeoutMS
175
+ # MEGA_MARIA_OPTIONS_BIG_INT_STRATEGY=number # number(기본,2^53 초과 손실) | bigint | string
@@ -15,6 +15,13 @@ export default {
15
15
  port: Number(process.env.PORT ?? 3000),
16
16
  // 세션 쿠키 HMAC 서명 시크릿(global 스코프, ADR-129). boot 이 앱에 주입한다. .env 의 SESSION_SECRET.
17
17
  sessionSecret: process.env.SESSION_SECRET,
18
+ // (예시·미사용) listen 호스트 — 기본 '0.0.0.0'. `mega start --host H`(CLI)가 우선.
19
+ // host: '0.0.0.0',
20
+ // (예시·미사용) cluster 워커 수 — CLI `--cluster` > .env MEGA_CLUSTER_WORKERS > 이 값(ADR-154). 정수 N | 'max'.
21
+ // cluster: 'max',
22
+ // (예시·미사용) 메트릭/트레이싱 resource 의 service.name·version(health.serviceName 미지정 시 폴백).
23
+ // serviceName: 'sample-crud',
24
+ // version: '0.1.0',
18
25
  },
19
26
 
20
27
  // ASP(Application-layer Secure Protocol) masterSecret — global 스코프 시크릿(ADR-127, scope-registry).
@@ -24,6 +31,8 @@ export default {
24
31
  // 이 값을 브라우저에 주입한다 — 그래서 운영 secret 과 분리된 데모 전용 값을 .env 의 ASP_MASTER_SECRET 에 둔다.
25
32
  asp: {
26
33
  masterSecret: process.env.ASP_MASTER_SECRET,
34
+ // (예시·미사용) HTTP body/query 암호화 옵션(enabledPaths·driftMs·nonceCache 등)은 **앱 스코프** —
35
+ // apps/<app>/app.config.js 의 asp.http 에 둔다. 여기 global 블록엔 masterSecret 만(scope-registry).
27
36
  },
28
37
 
29
38
  // 전역 어댑터(ADR-102/106/109) — globalKey 로 선언, 앱은 app.config.js 의 별명으로 참조.
@@ -33,6 +42,11 @@ export default {
33
42
  primary: {
34
43
  driver: 'postgres',
35
44
  url: process.env.DATABASE_URL,
45
+ // (예시·미사용) url 대신 discrete 도 가능: { host, port, user, password, database } (url 과 상호배타).
46
+ // (예시·미사용) 공통 풀 인터페이스 — 드라이버별 키로 자동 매핑(ADR-109). 단위 *Ms=밀리초.
47
+ // pool: { min: 0, max: 10, idleTimeoutMs: 10_000 },
48
+ // (예시·미사용) 드라이버 네이티브 옵션 passthrough(pg=snake_case).
49
+ // options: { ssl: false, statement_timeout: 30_000 },
36
50
  },
37
51
  // DB 'mongo' — Document DB 어댑터(ADR-108). notes 데모 컬렉션(Note 모델 static adapter='mongo')의 공유
38
52
  // 인스턴스. url path 의 dbName(mega_test)을 어댑터가 추출한다. .env 의 MONGO_URL(authSource=admin).
@@ -40,6 +54,12 @@ export default {
40
54
  driver: 'mongodb',
41
55
  url: process.env.MONGO_URL,
42
56
  },
57
+ // (예시·미사용) MariaDB 어댑터(ADR-105). 쓰려면 주석 해제 + .env 의 MARIA_URL 설정.
58
+ // 모델은 static adapter='maria'(globalKey)로 닿고, app.config.js databases 에 별명 추가.
59
+ // maria: {
60
+ // driver: 'mariadb',
61
+ // url: process.env.MARIA_URL,
62
+ // },
43
63
  },
44
64
  caches: {
45
65
  // redis 캐시 'rate' — brute-force(ctx.bruteForce, ADR-049/130)의 원자적 INCR 백엔드. .env 의 REDIS_RATE_URL(db 2).
@@ -60,6 +80,12 @@ export default {
60
80
  driver: 'redis',
61
81
  url: process.env.REDIS_LOCK_URL,
62
82
  },
83
+ // (예시·미사용) 범용 캐시 — 일반 키/값 캐싱이 필요하면 주석 해제 + .env 의 REDIS_CACHE_URL 설정.
84
+ // 기존 캐시(rate/demo/lock)와 키 충돌을 피해 별도 논리 DB(/4 등)를 권장. app.config.js caches 에 별명 추가.
85
+ // cache: {
86
+ // driver: 'redis',
87
+ // url: process.env.REDIS_CACHE_URL,
88
+ // },
63
89
  },
64
90
  // NATS 버스 'jobs' — 잡 큐(EmailJob, ADR-119)의 JetStream 백엔드. producer(웹)는 ctx.bus('jobs').native(nc)로
65
91
  // enqueue, consumer(`mega worker`)는 같은 버스로 소비한다. .env 의 NATS_JOBS_URL.
@@ -68,6 +94,11 @@ export default {
68
94
  driver: 'nats',
69
95
  url: process.env.NATS_JOBS_URL,
70
96
  },
97
+ // (예시·미사용) 이벤트 버스 — pub/sub 등 두 번째 NATS 버스가 필요하면 주석 해제 + .env 의 NATS_EVENTS_URL.
98
+ // events: {
99
+ // driver: 'nats',
100
+ // url: process.env.NATS_EVENTS_URL,
101
+ // },
71
102
  },
72
103
  // 분산 락 'main' — redlock(ADR-113). caches.lock(Redis) 을 빌려 단일 노드 redlock 을 구성한다. /demo/cron
73
104
  // 스케줄(CronCounterSchedule.static lock)의 클러스터 중복방지(leader election)에 쓰인다.
@@ -79,6 +110,17 @@ export default {
79
110
  },
80
111
  },
81
112
 
113
+ // (예시·미사용) Embedded WS Hub(ADR-137) — `mega ws-hub` 별도 프로세스 대신 같은 프로세스에 허브를 띄움
114
+ // (single-node). 켜면 app.config.js bridgeHub.url 을 이 host:port 로 맞춘다. acceptedTokens 필수(빈 값=throw).
115
+ // wsHub: {
116
+ // enabled: true,
117
+ // port: 3100,
118
+ // host: '127.0.0.1',
119
+ // acceptedTokens: (process.env.MEGA_WSHUB_TOKENS ?? '').split(',').filter(Boolean),
120
+ // heartbeatMs: 25_000,
121
+ // // compression: { enabled: false },
122
+ // },
123
+
82
124
  // ── 클러스터 전송 선택(ADR-176, 앱당 하나·상호배타) ───────────────────────────────────────────
83
125
  // 이 샘플은 현재 **WS Hub**(app.config 의 bridgeHub → `mega ws-hub` 서버, localhost:3100)로 채팅을
84
126
  // 클러스터 전파한다. boot 가 bridgeHub 를 보고 app.connectHub 를 자동 호출한다(개발자 배선 불요).
@@ -101,11 +143,20 @@ export default {
101
143
  // CPU 워커 풀(ADR-124) — `mega start`(boot)가 ctx.workers['hash'] 로 배선한다(config.workers).
102
144
  workers: [HashWorker],
103
145
 
146
+ // (예시·미사용) 플러그인 — 명시 등록만(auto-discovery 없음, ADR-079). 문자열 또는 { name, options }.
147
+ // plugins: [{ name: 'my-plugin', options: {} }],
148
+
104
149
  // pino 로깅(ADR-023/141) — console sink, dev pretty. redact 로 민감필드를 sink 출력 전 메인스레드에서
105
150
  // 마스킹한다(token/password/secret/authorization). /demo/logs 데모가 이 마스킹을 시연한다(ADR-163).
106
151
  logger: {
107
- level: 'debug',
108
- sinks: [{ type: 'console', pretty: true }],
152
+ level: process.env.LOG_LEVEL ?? 'info',
153
+ // sinks 출력처 배열. console(dev pretty) 외에 file(pino-roll)·telegram sink 지원.
154
+ sinks: [
155
+ { type: 'console', pretty: true },
156
+ // (예시·미사용) 파일 sink — 날짜 로테이션 + keep N.
157
+ // { type: 'file', path: './logs/app.log', rotation: 'daily', keep: 14 },
158
+ // (예시·미사용) telegram sink — warn 이상 알림. botToken/chatId 는 .env 등 시크릿으로 주입(코드·git 직접 X).
159
+ ],
109
160
  redact: ['*.password', '*.token', '*.secret', '*.authorization', 'password', 'token', 'secret'],
110
161
  },
111
162
 
@@ -114,5 +165,13 @@ export default {
114
165
  exposeMetrics: true,
115
166
  metricsPath: '/metrics',
116
167
  metricsAllowList: ['127.0.0.1', '::1'],
168
+ // (예시·미사용) liveness/readiness 경로 — 기본 '/health' · '/health/ready'.
169
+ // paths: { live: '/health', ready: '/health/ready' },
170
+ // (예시·미사용) 메트릭 resource service.name — 미지정 시 server.serviceName → MEGA_OTEL_SERVICE_NAME 폴백.
171
+ // serviceName: 'sample-crud',
117
172
  },
173
+
174
+ // OpenTelemetry 분산 트레이싱 — **config 블록이 아니라 .env 의 MEGA_OTEL_\*** 로 설정한다
175
+ // (MegaTracing.fromEnv, boot.js). MEGA_OTEL_ENABLED='true' + MEGA_OTEL_SERVICE_NAME 필수.
176
+ // (`tracing` 키는 스키마엔 있으나 현재 부팅이 소비하지 않음 — 죽은 설정 회피 위해 블록을 두지 않음.)
118
177
  }
@@ -7,7 +7,7 @@
7
7
  "node": ">=20"
8
8
  },
9
9
  "scripts": {
10
- "dev": "NODE_ENV=development mega start",
10
+ "dev": "mega start --watch",
11
11
  "start": "NODE_ENV=production mega start",
12
12
  "migrate": "mega migrate",
13
13
  "migrate:down": "mega migrate:down",
@@ -25,4 +25,4 @@
25
25
  "concurrently": "^9.0.0",
26
26
  "vitest": "^4.0.0"
27
27
  }
28
- }
28
+ }
@@ -1234,7 +1234,7 @@ marked@^18.0.5:
1234
1234
  integrity sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==
1235
1235
 
1236
1236
  "mega-framework@file:../..":
1237
- version "0.1.4"
1237
+ version "0.1.5"
1238
1238
  dependencies:
1239
1239
  "@fastify/cookie" "^11.0.2"
1240
1240
  "@fastify/cors" "^11.2.0"
package/src/cli/index.js CHANGED
@@ -21,6 +21,7 @@
21
21
  import { existsSync } from 'node:fs'
22
22
  import { join } from 'node:path'
23
23
  import os from 'node:os'
24
+ import { spawn as nodeSpawn } from 'node:child_process'
24
25
  import { bootApp, prepareRuntime } from '../core/boot.js'
25
26
  import { MegaCluster } from '../core/mega-cluster.js'
26
27
  import { installPrimaryAggregator, installWorkerResponder } from '../core/cluster-metrics.js'
@@ -32,6 +33,7 @@ import { MegaPluginHost, loadPlugins } from '../lib/mega-plugin.js'
32
33
  import { MegaJobWorker } from '../lib/mega-job-worker.js'
33
34
  import { MegaScheduler } from '../lib/mega-schedule.js'
34
35
  import { MegaShutdown } from '../lib/mega-shutdown.js'
36
+ import { buildLogger } from '../lib/mega-logger.js'
35
37
  import * as MegaMetrics from '../lib/mega-metrics.js'
36
38
  import { SCAFFOLD_COMMANDS, runScaffoldCommand } from './commands/scaffold.js'
37
39
 
@@ -39,7 +41,7 @@ import { SCAFFOLD_COMMANDS, runScaffoldCommand } from './commands/scaffold.js'
39
41
  export const USAGE = `mega — MEGA-FRAMEWORK CLI
40
42
 
41
43
  Usage:
42
- mega start [--port N] [--host H] [--cluster N|max] [--root DIR] 앱 부팅 + HTTP listen (별칭: serve)
44
+ mega start [--port N] [--host H] [--cluster N|max] [--watch] [--root DIR] 앱 부팅 + HTTP listen (별칭: serve)
43
45
  mega worker [--root DIR] 잡 소비 워커 런타임 호스트
44
46
  mega scheduler [--root DIR] 분산 스케줄러 호스트
45
47
  mega migrate [--db KEY] [--root DIR] pending 마이그레이션 일괄 적용(up)
@@ -63,6 +65,8 @@ Options:
63
65
  --host H listen 호스트(start)
64
66
  --cluster X 워커 프로세스 수(start). 정수 N 또는 max(CPU 코어 수). 우선순위:
65
67
  --cluster > MEGA_CLUSTER_WORKERS env > server.cluster config. 1/미지정=단일 프로세스.
68
+ --watch dev watch 모드(start). 파일 변경 시 자동 재시작 — node 내장 --watch 로 자신을 재실행한다
69
+ (nodemon 불요). 단일 프로세스 강제 + NODE_ENV 미설정 시 development. apps/·mega.config.js 감시.
66
70
  `
67
71
 
68
72
  /** 프로젝트 `.env` 를 로드하지 않는 명령 — 스캐폴드 생성(new/g)·도움말. 그 외 런타임/부팅 명령은 로드. */
@@ -202,6 +206,51 @@ export function resolveHost(setting) {
202
206
  return setting
203
207
  }
204
208
 
209
+ /**
210
+ * `mega start --watch` 가 자신을 `node --watch` 로 재실행해야 하는지 (ADR-182). `--watch` 플래그가 있고
211
+ * 아직 watch 하에 재실행되지 않았을 때만 true — 가드 env(`MEGA_WATCH_REEXEC`)로 무한재귀를 막는다.
212
+ * @param {Record<string, string|boolean>} flags
213
+ * @param {Record<string, string|undefined>} env
214
+ * @returns {boolean}
215
+ */
216
+ export function shouldReexecForWatch(flags, env) {
217
+ return flags.watch === true && env.MEGA_WATCH_REEXEC !== '1'
218
+ }
219
+
220
+ /**
221
+ * `mega start --watch` → `node --watch … <self> start … --cluster 1` 재실행 명령을 빌드한다 (ADR-182).
222
+ *
223
+ * - `--watch` 인자는 **node 플래그**로 옮기고 mega 인자에선 제거한다.
224
+ * - **단일 프로세스 강제**(`--cluster 1`) — 멀티워커 watch 카오스 방지(기존 `--cluster` 값은 무시).
225
+ * - watchPaths 가 있으면 `--watch-path=…`(이게 모듈 그래프 watch 를 **대체**하므로 앱 소스·전역설정 경로를
226
+ * 명시해야 한다 — 실측 확인). 없으면 plain `--watch`(모듈 그래프 폴백).
227
+ *
228
+ * @param {Object} o
229
+ * @param {string[]} o.argv - 원본 mega argv(예: `['start','--watch','--port','3000']`).
230
+ * @param {string[]} o.watchPaths - 감시할 절대경로(존재하는 것만).
231
+ * @param {string} o.selfPath - mega 진입 스크립트(`process.argv[1]`).
232
+ * @param {string} o.execPath - node 바이너리(`process.execPath`).
233
+ * @returns {{ command: string, args: string[] }}
234
+ */
235
+ export function buildWatchCommand({ argv, watchPaths, selfPath, execPath }) {
236
+ const nodeFlags =
237
+ Array.isArray(watchPaths) && watchPaths.length > 0 ? watchPaths.map((p) => `--watch-path=${p}`) : ['--watch']
238
+ /** @type {string[]} mega 인자 — `--watch` 제거 + `--cluster`(값 포함) 제거. */
239
+ const megaArgs = []
240
+ for (let i = 0; i < argv.length; i++) {
241
+ const tok = argv[i]
242
+ if (tok === '--watch' || tok.startsWith('--watch=')) continue
243
+ if (tok === '--cluster') {
244
+ if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) i++ // 값 토큰도 건너뜀.
245
+ continue
246
+ }
247
+ if (tok.startsWith('--cluster=')) continue
248
+ megaArgs.push(tok)
249
+ }
250
+ megaArgs.push('--cluster', '1') // 단일 프로세스 강제.
251
+ return { command: execPath, args: [...nodeFlags, selfPath, ...megaArgs] }
252
+ }
253
+
205
254
  /**
206
255
  * `mega` CLI 진입점. 파싱 → 명령 분기. **이 함수는 process.exit 를 호출하지 않는다**(테스트 가능) —
207
256
  * exit code 를 반환하고, bin 래퍼가 `process.exitCode` 로 반영한다.
@@ -212,9 +261,11 @@ export function resolveHost(setting) {
212
261
  * @param {(msg: string) => void} [deps.err] - stderr writer(기본 console.error).
213
262
  * @param {string} [deps.cwd] - 기본 projectRoot(기본 process.cwd()).
214
263
  * @param {{ debug?: Function, info?: Function, warn?: Function }} [deps.logger]
264
+ * @param {Function} [deps.spawn] - child_process.spawn 주입(기본 node:child_process). `--watch` 재실행 테스트용.
265
+ * @param {string} [deps.selfPath] - mega 진입 스크립트 경로(기본 `process.argv[1]`). `--watch` 재실행 대상.
215
266
  * @returns {Promise<number>} exit code(0=성공, 1=실패/미지정 명령).
216
267
  */
217
- export async function runCli(argv, { out = console.log, err = console.error, cwd, logger } = {}) {
268
+ export async function runCli(argv, { out = console.log, err = console.error, cwd, logger, spawn = nodeSpawn, selfPath = process.argv[1] } = {}) {
218
269
  const { _, flags } = parseArgs(argv)
219
270
  const command = _[0]
220
271
  const projectRoot = typeof flags.root === 'string' ? flags.root : (cwd ?? process.cwd())
@@ -232,6 +283,35 @@ export async function runCli(argv, { out = console.log, err = console.error, cwd
232
283
  }
233
284
 
234
285
  if (command === 'start' || command === 'serve') {
286
+ // dev watch (ADR-182) — `--watch` 면 자신을 `node --watch` 로 재실행한다. 부모는 부팅하지 않고 watcher
287
+ // 자식만 띄운 뒤 그 exit code 를 그대로 반영한다(가드 env 로 무한재귀 방지). 단일 프로세스 강제 +
288
+ // NODE_ENV 미설정 시 development 기본. 시그널은 자식으로 전달해 Ctrl+C 가 watcher 까지 닿게 한다.
289
+ if (shouldReexecForWatch(flags, process.env)) {
290
+ const watchPaths = [join(projectRoot, 'apps'), join(projectRoot, 'mega.config.js')].filter(existsSync)
291
+ const { command: cmd, args } = buildWatchCommand({ argv, watchPaths, selfPath, execPath: process.execPath })
292
+ out(`mega: watch mode (single process) — restarting on changes in [${watchPaths.length ? watchPaths.join(', ') : 'module graph'}]`)
293
+ const child = spawn(cmd, args, {
294
+ stdio: 'inherit',
295
+ env: { ...process.env, MEGA_WATCH_REEXEC: '1', NODE_ENV: process.env.NODE_ENV ?? 'development' },
296
+ })
297
+ /** @type {NodeJS.Signals[]} */
298
+ const sigs = ['SIGINT', 'SIGTERM']
299
+ const forward = (/** @type {NodeJS.Signals} */ sig) => {
300
+ try {
301
+ child.kill(sig)
302
+ } catch {
303
+ // 자식이 이미 종료 중이면 kill 은 무의미 — 무시(비치명적).
304
+ }
305
+ }
306
+ for (const s of sigs) process.on(s, forward)
307
+ return await new Promise((resolve) => {
308
+ child.on('exit', (/** @type {number|null} */ code) => {
309
+ for (const s of sigs) process.removeListener(s, forward)
310
+ resolve(typeof code === 'number' ? code : 0)
311
+ })
312
+ })
313
+ }
314
+
235
315
  // listen 포트/호스트는 부팅(어댑터 connect) 전에 fail-closed 검증한다 — 잘못된 값을 늦게(listen 시점)
236
316
  // cryptic 에러로 만나거나 silent 강등(특권/랜덤 포트)하지 않도록(per ADR-167 후속).
237
317
  const port = resolvePort(flags.port)
@@ -246,35 +326,51 @@ export async function runCli(argv, { out = console.log, err = console.error, cwd
246
326
  }
247
327
  const workers = resolveClusterWorkers(clusterSetting)
248
328
 
329
+ // 시작 확인 메시지 — pino 로거가 있으면 그쪽 info 로(구조적·sink 라우팅, ADR-180), 없으면 CLI stdout
330
+ // (`out`)으로 폴백한다. listen/forked 는 운영 라이프사이클 이벤트라 로그로 남기는 게 정합.
331
+ const announce = (/** @type {any} */ lg, /** @type {string} */ msg) => {
332
+ if (lg && typeof lg.info === 'function') lg.info(msg)
333
+ else out(`mega: ${msg}`)
334
+ }
335
+
249
336
  // 워커 부팅 함수 — 단일/클러스터 모드 공통. 클러스터에선 각 워커 프로세스가 이 함수를 실행한다.
250
337
  const bootListen = async () => {
251
- const { server } = await bootApp(projectRoot, { listen: true, port, host, logger })
338
+ const { server, appLogger } = await bootApp(projectRoot, { listen: true, port, host, logger })
252
339
  MegaShutdown.register('mega-cli:server', async () => server.close())
253
340
  MegaShutdown.setupSignals()
254
341
  // 클러스터 워커면 메트릭 collect 응답기 설치(ADR-163) — 마스터의 집계 요청에 자기 메트릭 회신.
255
342
  // 단일 프로세스면 no-op(cluster.isWorker=false).
256
343
  installWorkerResponder()
257
- return server
344
+ return { server, appLogger }
258
345
  }
259
346
 
260
347
  if (workers === null) {
261
348
  // 단일 프로세스(클러스터 비활성) — 기존 경로 유지.
262
- const server = await bootListen()
263
- out(`mega: listening on [${server.hosts.join(', ')}]`)
349
+ const { server, appLogger } = await bootListen()
350
+ announce(appLogger, `listening on [${server.hosts.join(', ')}]`)
264
351
  return 0
265
352
  }
266
353
 
267
354
  // 클러스터 모드 — 마스터는 워커 N개 fork·respawn·graceful 협응(MegaCluster), 각 워커가 bootApp+listen.
268
355
  // Node cluster 가 마스터의 공유 listen 소켓을 워커들에 분배하므로 SO_REUSEPORT 불필요(ADR-030/154).
269
356
  const mega = new MegaCluster({ instances: workers })
357
+ // 마스터 조율 로그(SIGINT 수신·워커 종료/respawn 등)를 pino 로(ADR-180). 마스터는 bootApp 을 안 해
358
+ // pino 인스턴스가 없으므로 config 로 직접 만든다. 워커는 bootApp 의 appLogger 를 쓰니 primary 에서만
359
+ // 만들어(워커당 중복 transport worker 회피) start 전에 주입한다.
360
+ /** @type {any} */ let masterLogger = null
361
+ if (mega.isPrimary()) {
362
+ const { global: masterGlobal } = await loadAndValidateConfig(projectRoot)
363
+ masterLogger = buildLogger(/** @type {any} */ (masterGlobal).logger)
364
+ mega.setLogger(masterLogger)
365
+ }
270
366
  await mega.start(async () => {
271
- const server = await bootListen()
272
- out(`mega: worker ${process.pid} listening on [${server.hosts.join(', ')}]`)
367
+ const { server, appLogger } = await bootListen()
368
+ announce(appLogger, `worker ${process.pid} listening on [${server.hosts.join(', ')}]`)
273
369
  })
274
370
  if (mega.isPrimary()) {
275
371
  // 마스터에 메트릭 집계기 설치(ADR-163) — 워커의 /metrics 요청을 받아 전 워커 합산해 회신.
276
372
  installPrimaryAggregator()
277
- out(`mega: cluster master ${process.pid} forked ${workers} worker(s)`)
373
+ announce(masterLogger, `cluster master ${process.pid} forked ${workers} worker(s)`)
278
374
  }
279
375
  return 0
280
376
  }
@@ -352,6 +448,29 @@ export function collectRegistrations(global, host, kind) {
352
448
  return [...staticPart, ...dynamicPart]
353
449
  }
354
450
 
451
+ /**
452
+ * CLI 호스트(worker/scheduler) 로거 배선 — `bin/mega.js` 는 `runCli` 에 logger 를 주입하지 않으므로
453
+ * (undefined), config 의 pino 로거를 만들어 종료 로그(`MegaShutdown.setLogger`)·flush 훅에 쓴다. 이로써
454
+ * `mega worker`/`mega scheduler` 의 자기 로그·종료 로그가 console 이 아니라 pino 로 나간다(ADR-180).
455
+ * 주입(테스트)이 있으면 그쪽을 그대로 쓰고 pino 는 만들지 않는다(side-effect 회피).
456
+ * @param {Object} global - global config(`logger` 보유 가능).
457
+ * @param {{ info?: Function, warn?: Function } | undefined} injectedLogger - 주입 로거(테스트) 또는 undefined.
458
+ * @returns {{ info?: Function, warn?: Function } | null} 호스트 로그에 쓸 로거(없으면 null).
459
+ */
460
+ function wireHostLogger(global, injectedLogger) {
461
+ if (injectedLogger) return injectedLogger
462
+ const appLogger = buildLogger(/** @type {any} */ (global).logger)
463
+ if (appLogger) {
464
+ // 종료 시퀀스 로그를 pino 로(ADR-178) + graceful 시 로그 버퍼 flush. flush 훅은 LIFO 상 가장 먼저
465
+ // 등록 = 가장 나중 실행이라, 다른 hook 들이 로그를 남긴 뒤 마지막에 drain 된다.
466
+ MegaShutdown.setLogger(appLogger)
467
+ MegaShutdown.register('mega-logger', async () => {
468
+ await new Promise((resolve) => appLogger.flush(() => resolve(undefined)))
469
+ })
470
+ }
471
+ return appLogger
472
+ }
473
+
355
474
  /**
356
475
  * `mega worker` 호스트 골격 — config + 어댑터 connect + ctx + `MegaJobWorker` 인스턴스 + graceful.
357
476
  * 등록 소스 = `config.jobs` + 플러그인 `mega.jobs.register` 분(`collectRegistrations`, ADR-123).
@@ -362,6 +481,7 @@ export function collectRegistrations(global, host, kind) {
362
481
  export async function runWorkerHost(projectRoot, logger) {
363
482
  // bootApp 과 같은 토대(config → 플러그인 install → 어댑터 connect → ctx).
364
483
  const { global, host, ctx } = await prepareRuntime(projectRoot, { ping: false, logger })
484
+ const log = wireHostLogger(global, logger)
365
485
  const worker = new MegaJobWorker({ ctx })
366
486
  // 잡 메트릭 (ADR-132) — prepareRuntime 이 health.exposeMetrics 시 이미 MegaMetrics.init 했고,
367
487
  // 옵트인 OFF 면 subscribeJobs 는 no-op() 을 반환한다. start 전에 구독해 enqueue(dispatch)부터 잡는다. 구독은
@@ -371,7 +491,7 @@ export async function runWorkerHost(projectRoot, logger) {
371
491
  // 미선언·중복을 부팅 시 fail-fast.
372
492
  const jobs = collectRegistrations(global, host, 'jobs')
373
493
  for (const JobClass of jobs) worker.register(/** @type {any} */ (JobClass))
374
- logger?.info?.({ count: jobs.length }, 'worker.jobs registered')
494
+ log?.info?.({ count: jobs.length }, 'worker.jobs registered')
375
495
  await worker.start()
376
496
  MegaShutdown.register('mega-worker', async () => {
377
497
  await worker.stop()
@@ -388,12 +508,13 @@ export async function runWorkerHost(projectRoot, logger) {
388
508
  */
389
509
  export async function runSchedulerHost(projectRoot, logger) {
390
510
  const { global, host, ctx } = await prepareRuntime(projectRoot, { ping: false, logger })
511
+ const log = wireHostLogger(global, logger)
391
512
  const scheduler = new MegaScheduler({ ctx })
392
513
  // 등록 소스(ADR-123) = config.schedules(정적) + 플러그인 host.listSchedules()(동적). register 가
393
514
  // cron 미선언·중복을 부팅 시 fail-fast.
394
515
  const schedules = collectRegistrations(global, host, 'schedules')
395
516
  for (const TaskClass of schedules) scheduler.register(/** @type {any} */ (TaskClass))
396
- logger?.info?.({ count: schedules.length }, 'scheduler.schedules registered')
517
+ log?.info?.({ count: schedules.length }, 'scheduler.schedules registered')
397
518
  scheduler.start()
398
519
  MegaShutdown.register('mega-scheduler', async () => {
399
520
  await scheduler.stop()
package/src/core/boot.js CHANGED
@@ -66,6 +66,7 @@ import { MegaWsHub } from '../cli/ws-hub.js'
66
66
  * @property {MegaApp[]} megaApps - 생성된 MegaApp 인스턴스(등록 순서).
67
67
  * @property {BootContext} ctx - lifecycle hook 에 넘긴 boot context.
68
68
  * @property {import('../cli/ws-hub.js').MegaWsHub | null} wsHub - embedded wsHub(ADR-137, `wsHub.enabled` OFF 면 null).
69
+ * @property {import('pino').Logger | null} appLogger - 공유 pino 로거(ADR-141, logger 비활성이면 null). CLI 시작 메시지 등에 재사용.
69
70
  */
70
71
 
71
72
  /**
@@ -188,6 +189,28 @@ export async function prepareRuntime(projectRoot, { ping = false, logger } = {})
188
189
  return { global, apps, host, ctx, wsHub }
189
190
  }
190
191
 
192
+ /**
193
+ * `global.server` 의 운영 옵션을 각 앱 Fastify 인스턴스 옵션으로 매핑한다(ADR-181, 04-data-models
194
+ * §MegaServerConfig). 미지정 키는 생략해 Fastify 기본값을 따른다. boot 가 모든 MegaApp 에 동일 주입한다
195
+ * (port/host 는 MegaServer.listen 이, 아래 런타임 옵션은 Fastify 인스턴스가 소비).
196
+ * - trustProxy / trustedProxies → Fastify `trustProxy`(프록시/LB 뒤 req.ip·X-Forwarded-* 신뢰).
197
+ * trustedProxies(목록)가 있으면 그것을, 없으면 trustProxy(boolean/number/string)를 그대로 넘긴다.
198
+ * - timeouts.requestMs → Fastify `requestTimeout`(요청 수신 제한, slow-loris 보호).
199
+ * - keepAliveMs → Fastify `keepAliveTimeout`(keep-alive 소켓 idle 제한, ALB 정합).
200
+ * @param {any} server - `global.server` config(없으면 빈 객체).
201
+ * @returns {{ trustProxy?: any, requestTimeout?: number, keepAliveTimeout?: number }}
202
+ */
203
+ export function serverFastifyOptions(server) {
204
+ const s = server ?? {}
205
+ /** @type {any} */
206
+ const out = {}
207
+ const trust = s.trustedProxies !== undefined ? s.trustedProxies : s.trustProxy
208
+ if (trust !== undefined) out.trustProxy = trust
209
+ if (s.timeouts && s.timeouts.requestMs !== undefined) out.requestTimeout = s.timeouts.requestMs
210
+ if (s.keepAliveMs !== undefined) out.keepAliveTimeout = s.keepAliveMs
211
+ return out
212
+ }
213
+
191
214
  /**
192
215
  * embedded wsHub 기동 (ADR-137) — `cfg.enabled === true` 일 때만 `MegaWsHub` 를 같은 프로세스에 띄우고
193
216
  * `MegaShutdown` 에 drain 종료 hook 을 등록한다. 아니면 `null`(미기동). 검증(빈 토큰 등)은 MegaWsHub
@@ -244,6 +267,9 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
244
267
  // 않게(로그가 종료 과정 끝까지 살아 있도록) — 마지막 flush 단계(07-sequence §6).
245
268
  const appLogger = buildLogger(/** @type {any} */ (global).logger)
246
269
  if (appLogger) {
270
+ // 전역 에러 핸들러(unhandledRejection/uncaughtException, ADR-178)가 fatal 로그에 쓸 공유 로거를 주입한다.
271
+ // process 레벨 핸들러는 이 DI 그래프 밖이라 MegaShutdown 모듈 스코프로 넘긴다(globalThis 오염 회피).
272
+ MegaShutdown.setLogger(appLogger)
247
273
  MegaShutdown.register('mega-logger', async () => {
248
274
  await new Promise((resolve) => appLogger.flush(() => resolve(undefined)))
249
275
  })
@@ -275,6 +301,9 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
275
301
  // 운영 관측성 config 는 Global-only(ADR-072/131) — global `health` 블록을 모든 앱에 주입한다(/metrics
276
302
  // 라우트 등록 + 보안 면제 + IP allowList). sessionSecret 주입과 동일 패턴.
277
303
  health: /** @type {any} */ (global).health,
304
+ // server 운영 옵션(trustProxy/requestTimeout/keepAliveTimeout) → Fastify 인스턴스 옵션(ADR-181).
305
+ // Global-only 라 모든 앱에 동일 주입. MegaApp 이 Fastify({ ...fastifyOptions }) 로 전달.
306
+ fastifyOptions: serverFastifyOptions(serverCfg),
278
307
  plugins: host.fastifyPlugins,
279
308
  globalMiddlewares: host.globalMiddlewares,
280
309
  })
@@ -388,5 +417,5 @@ export async function bootApp(projectRoot, { listen = true, port, host: listenHo
388
417
  }
389
418
 
390
419
  logger?.debug?.('boot.done')
391
- return { server, host, config: global, apps, megaApps, ctx, wsHub }
420
+ return { server, host, config: global, apps, megaApps, ctx, wsHub, appLogger }
392
421
  }
@@ -94,6 +94,31 @@ export function validateGlobalConfig(globalConfig) {
94
94
  // 앱에서 registerSession 이 fail-fast 로 잡으므로(여긴 server.sessionSecret 가 "정의됐을 때만"
95
95
  // 강도를 검증) — 정의됐다면 (a) 기본 placeholder 거부, (b) ≥32자 강제(부팅 fail-fast, ADR-062).
96
96
  validateSessionSecret(globalConfig.server?.sessionSecret)
97
+
98
+ // 7) server 런타임 타임아웃(ADR-181) — 정의됐다면 음 아닌 정수만(Fastify requestTimeout/keepAliveTimeout).
99
+ validateServerTimeouts(globalConfig.server)
100
+ }
101
+
102
+ /**
103
+ * `server.timeouts.requestMs` · `server.keepAliveMs` 검증(ADR-181, Fastify 인스턴스 옵션으로 매핑).
104
+ * 정의됐다면 음 아닌 정수(ms)만 허용 — 잘못된 값은 부팅 fail-fast. `trustProxy`/`trustedProxies` 는
105
+ * 타입 유연(boolean/number/string/array)이라 Fastify 에 위임(여기서 검증 안 함). 미정의는 통과(선택 키).
106
+ * @param {any} server - `globalConfig.server`.
107
+ * @throws {MegaConfigError} `server.invalid_timeout`.
108
+ */
109
+ function validateServerTimeouts(server) {
110
+ const s = server ?? {}
111
+ /** @param {string} name @param {unknown} v */
112
+ const check = (name, v) => {
113
+ if (v === undefined) return
114
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 0) {
115
+ throw new MegaConfigError('server.invalid_timeout', `${name} must be a non-negative integer (ms). Got ${JSON.stringify(v)}.`, {
116
+ details: { value: v },
117
+ })
118
+ }
119
+ }
120
+ check('server.timeouts.requestMs', s.timeouts?.requestMs)
121
+ check('server.keepAliveMs', s.keepAliveMs)
97
122
  }
98
123
 
99
124
  /** sessionSecret 강도 검증의 최소 길이(바이트 수가 아닌 문자 길이 — base64url 32바이트 ≈ 43자). */
@@ -403,36 +403,40 @@ export class MegaApp {
403
403
  // onRoute 훅 등록 이후라 자동 envelope 적용됨. config.skip{Asp,Csrf,RateLimit} 3종이 각 보안 hook 의
404
404
  // 면제 신호다(ADR-072 면제 실효, ADR-127): ASP onRequest·CSRF preHandler·rate-limit allowList 가 검사.
405
405
  const HEALTH_EXEMPT = { skipAsp: true, skipCsrf: true, skipRateLimit: true }
406
-
407
- // /healthliveness (항상 200)
408
- this.fastify.get('/health', { config: HEALTH_EXEMPT }, async () => ({
409
- status: 'ok',
410
- app: this.name,
411
- uptime_ms: Math.floor(process.uptime() * 1000),
412
- ts: Date.now(),
413
- }))
414
-
415
- // /health/ready — readiness (checkAll 후 200 or 503)
416
- this.fastify.get('/health/ready', { config: HEALTH_EXEMPT }, async (req, reply) => {
417
- const snapshot = await MegaHealth.checkAll()
418
- if (!snapshot.ok) reply.code(503)
419
- return {
406
+ const healthCfg = opts.health && typeof opts.health === 'object' ? opts.health : {}
407
+ // 헬스 경로(ADR-072/181)설정 가능. 미지정/빈 문자열이면 기본 /health · /health/ready.
408
+ const livePath = typeof healthCfg.paths?.live === 'string' && healthCfg.paths.live.length > 0 ? healthCfg.paths.live : '/health'
409
+ const readyPath = typeof healthCfg.paths?.ready === 'string' && healthCfg.paths.ready.length > 0 ? healthCfg.paths.ready : '/health/ready'
410
+
411
+ // health 라우트 등록 — `health.enabled:false` 면 생략(ADR-181). 기본(미지정/true) 등록한다.
412
+ if (healthCfg.enabled !== false) {
413
+ // liveness (항상 200)
414
+ this.fastify.get(livePath, { config: HEALTH_EXEMPT }, async () => ({
415
+ status: 'ok',
420
416
  app: this.name,
421
- ...snapshot,
422
417
  uptime_ms: Math.floor(process.uptime() * 1000),
423
418
  ts: Date.now(),
424
- }
425
- })
419
+ }))
420
+
421
+ // readiness (checkAll 후 200 or 503)
422
+ this.fastify.get(readyPath, { config: HEALTH_EXEMPT }, async (req, reply) => {
423
+ const snapshot = await MegaHealth.checkAll()
424
+ if (!snapshot.ok) reply.code(503)
425
+ return {
426
+ app: this.name,
427
+ ...snapshot,
428
+ uptime_ms: Math.floor(process.uptime() * 1000),
429
+ ts: Date.now(),
430
+ }
431
+ })
432
+ }
426
433
 
427
434
  // /metrics — Prometheus 옵트인 (ADR-072/131). exposeMetrics:true 일 때만 등록 →
428
435
  // 디폴트(미등록)는 Fastify 가 404(roadmap 검증 기준). /health 와 동일 보안 면제(HEALTH_EXEMPT).
429
436
  // 접근 제어 = IP allowList(ADR-131) — 빈 list 면 메인 포트 전체 노출(운영자 결정).
430
- const healthCfg = opts.health && typeof opts.health === 'object' ? opts.health : {}
431
437
  if (healthCfg.exposeMetrics === true) {
432
438
  const metricsPath = typeof healthCfg.metricsPath === 'string' && healthCfg.metricsPath.length > 0 ? healthCfg.metricsPath : '/metrics'
433
- // metricsPath 충돌 검증(ADR-072/04-data-models) — health 경로와 겹치면 부팅 throw(fail-fast).
434
- const livePath = healthCfg.paths?.live ?? '/health'
435
- const readyPath = healthCfg.paths?.ready ?? '/health/ready'
439
+ // metricsPath 충돌 검증(ADR-072/04-data-models) — health 경로(위에서 해석)와 겹치면 부팅 throw(fail-fast).
436
440
  if (metricsPath === livePath || metricsPath === readyPath) {
437
441
  throw new MegaConfigError(
438
442
  'health.metrics_path_conflict',
@@ -35,6 +35,8 @@ export class MegaCluster {
35
35
  * @param {number} [opts.gracePeriodMs=30000] - SIGTERM 후 강제 kill 까지 대기
36
36
  * @param {import('node:cluster').Cluster} [opts._cluster] - 테스트용 cluster 주입(기본 node:cluster). per ADR-165
37
37
  * @param {NodeJS.Process} [opts._proc] - 테스트용 process 주입(기본 전역 process). per ADR-165
38
+ * @param {{ info?: Function, warn?: Function, error?: Function } | null} [opts.logger] - 조율 로그용 pino 로거.
39
+ * 미설정이면 console 폴백(마스터는 bootApp 을 안 해 pino 인스턴스가 없을 수 있다, ADR-180).
38
40
  */
39
41
  constructor(opts = {}) {
40
42
  this._instances = resolveInstances(opts.instances)
@@ -45,6 +47,8 @@ export class MegaCluster {
45
47
  // cluster/process 를 주입 seam 으로 분리해 fake 로 in-process 단위 검증한다. per ADR-165
46
48
  this._cluster = opts._cluster ?? cluster
47
49
  this._proc = opts._proc ?? process
50
+ /** @type {{ info?: Function, warn?: Function, error?: Function } | null} 조율 로그용 로거(없으면 console). */
51
+ this._logger = opts.logger ?? null
48
52
  /** @type {Set<MegaWorker>} */
49
53
  this._workers = new Set()
50
54
  /** @type {(() => Promise<void>) | null} */
@@ -92,12 +96,43 @@ export class MegaCluster {
92
96
  return !this._cluster.isPrimary
93
97
  }
94
98
 
99
+ /**
100
+ * 조율 로그용 로거 주입(생성 후, start 전에 호출). 마스터는 bootApp 을 안 해 pino 가 없으므로 CLI 가
101
+ * config 로 만든 인스턴스를 넘긴다(ADR-180). 미주입이면 console 폴백.
102
+ * @param {{ info?: Function, warn?: Function, error?: Function } | null | undefined} logger
103
+ * @returns {void}
104
+ */
105
+ setLogger(logger) {
106
+ this._logger = logger ?? null
107
+ }
108
+
109
+ /**
110
+ * @private 조율 로그 — 주입 로거가 있으면 그 레벨로, 없으면 console 폴백(`[mega-cluster]` 접두).
111
+ * @param {'info'|'warn'|'error'} level @param {string} msg
112
+ */
113
+ _log(level, msg) {
114
+ const log = this._logger
115
+ const text = `[mega-cluster] ${msg}`
116
+ if (log && typeof (/** @type {any} */ (log)[level]) === 'function') /** @type {any} */ (log)[level](text)
117
+ else (level === 'warn' ? console.warn : level === 'error' ? console.error : console.log)(text)
118
+ }
119
+
95
120
  /**
96
121
  * @private 마스터 프로세스 부팅 시퀀스.
97
122
  * @param {() => Promise<void>} workerFn
98
123
  */
99
124
  async _startPrimary(workerFn) {
100
125
  this._workerFn = workerFn
126
+ // 멀티워커 + watch 방어(ADR-182) — 워커는 마스터의 `execArgv` 를 상속하므로, `--watch*` 가 있으면
127
+ // 제거해 각 워커가 자기-watch 로 폭주(파일 변경 시 제각각 재시작)하는 것을 막는다. dev 권장 경로는
128
+ // 단일 프로세스(`mega start --watch`)라 보통 안 타지만, 수동 `node --watch … mega start --cluster N`
129
+ // 케이스의 방어 가드다. 단언이 아닌 setupPrimary 로 fork 전에 1회 적용.
130
+ const execArgv = this._proc.execArgv ?? []
131
+ const cleaned = execArgv.filter((a) => !a.startsWith('--watch'))
132
+ if (cleaned.length !== execArgv.length && typeof this._cluster.setupPrimary === 'function') {
133
+ this._cluster.setupPrimary({ execArgv: cleaned })
134
+ this._log('warn', 'stripped --watch* from worker execArgv (multi-worker watch chaos guard, ADR-182)')
135
+ }
101
136
  // workerFn 은 마스터에서 사용 안 함 (정보 전달용 placeholder)
102
137
  for (let i = 0; i < this._instances; i++) {
103
138
  this._forkWorker()
@@ -107,13 +142,13 @@ export class MegaCluster {
107
142
  const onSignal = (/** @type {string} */ sig) => {
108
143
  if (this._shuttingDown) return
109
144
  this._shuttingDown = true
110
- console.log(`[mega-cluster] received ${sig}, broadcasting shutdown to ${this._workers.size} workers (grace ${this._gracePeriodMs}ms)`)
145
+ this._log('info', `received ${sig}, broadcasting shutdown to ${this._workers.size} workers (grace ${this._gracePeriodMs}ms)`)
111
146
  this._broadcastShutdown()
112
147
  // grace 초과 시 강제 kill. 핸들을 보관해 전원 정상종료(exit 핸들러) 시 취소 — 정상 종료가 이 타이머의
113
148
  // exit(1) 과 경합해 exit code 가 흔들리지 않게 한다(k8s/systemd 종료 판정 노이즈 제거).
114
149
  this._graceTimer = setTimeout(() => {
115
150
  for (const w of this._workers) {
116
- try { w.kill('SIGKILL') } catch (err) { console.warn('[mega-cluster] SIGKILL failed:', err.message) }
151
+ try { w.kill('SIGKILL') } catch (err) { this._log('warn', `SIGKILL failed: ${err.message}`) }
117
152
  }
118
153
  this._proc.exit(1)
119
154
  }, this._gracePeriodMs)
@@ -134,7 +169,7 @@ export class MegaCluster {
134
169
  if (this._workers.size === 0) {
135
170
  if (this._graceTimer) clearTimeout(this._graceTimer) // 전원 정상종료 — 강제-kill 타이머 취소(exit code 경합 제거).
136
171
  this._graceTimer = null
137
- console.log('[mega-cluster] all workers exited, primary exiting 0')
172
+ this._log('info', 'all workers exited, primary exiting 0')
138
173
  this._proc.exit(0)
139
174
  }
140
175
  return
@@ -152,19 +187,21 @@ export class MegaCluster {
152
187
  if (this._rapidCrashTimes.length >= this._maxRapidRespawn) {
153
188
  // H-1 — crash-loop 포기 시 그냥 return 하면 이벤트루프가 비어 exit 0(성공)으로 보인다 →
154
189
  // systemd/k8s 가 "정상 종료" 로 오판. 명시적 exit 1 로 비정상 종료를 알려 재시작을 유도한다.
155
- console.error(
156
- `[mega-cluster] rapid crash-loop detected (${this._rapidCrashTimes.length} rapid crashes within ${windowMs}ms), giving up — exiting 1. Last exit: code=${code}, signal=${signal}.`,
190
+ this._log(
191
+ 'error',
192
+ `rapid crash-loop detected (${this._rapidCrashTimes.length} rapid crashes within ${windowMs}ms), giving up — exiting 1. Last exit: code=${code}, signal=${signal}.`,
157
193
  )
158
194
  this._proc.exit(1)
159
195
  return // 실 process.exit 은 즉시 종료하나, 주입된 fake 는 계속 실행되므로 명시적 중단
160
196
  }
161
197
  }
162
- console.warn(
163
- `[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}, lifetime=${lifetimeMs}ms), respawning (rapid-crashes=${this._rapidCrashTimes.length}/${this._maxRapidRespawn} in ${windowMs}ms)`,
198
+ this._log(
199
+ 'warn',
200
+ `worker ${worker.process.pid} died (code=${code}, signal=${signal}, lifetime=${lifetimeMs}ms), respawning (rapid-crashes=${this._rapidCrashTimes.length}/${this._maxRapidRespawn} in ${windowMs}ms)`,
164
201
  )
165
202
  this._forkWorker()
166
203
  } else {
167
- console.warn(`[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}), respawn disabled`)
204
+ this._log('warn', `worker ${worker.process.pid} died (code=${code}, signal=${signal}), respawn disabled`)
168
205
  }
169
206
  })
170
207
  }
@@ -176,7 +213,7 @@ export class MegaCluster {
176
213
  w.send({ type: SHUTDOWN_MSG })
177
214
  } catch (err) {
178
215
  // 워커가 이미 disconnected — 무시 + 로그 (silent 금지)
179
- console.warn(`[mega-cluster] failed to send shutdown to worker ${w.process.pid}:`, err.message)
216
+ this._log('warn', `failed to send shutdown to worker ${w.process.pid}: ${err.message}`)
180
217
  }
181
218
  }
182
219
  }
@@ -199,8 +236,9 @@ export class MegaCluster {
199
236
  // 핸들러가 있었으면 true, 없으면 false → 무핸들러 경고.
200
237
  const hadListener = this._proc.emit('SIGTERM')
201
238
  if (!hadListener) {
202
- console.warn(
203
- `[mega-cluster] worker ${this._proc.pid} has no SIGTERM handler registered. Will be force-killed by master after grace period. ` +
239
+ this._log(
240
+ 'warn',
241
+ `worker ${this._proc.pid} has no SIGTERM handler registered. Will be force-killed by master after grace period. ` +
204
242
  `Register a SIGTERM handler (or use MegaShutdown.setupSignals) to enable graceful shutdown.`,
205
243
  )
206
244
  }
@@ -211,7 +249,7 @@ export class MegaCluster {
211
249
  try {
212
250
  await workerFn()
213
251
  } catch (err) {
214
- console.error(`[mega-cluster] worker ${this._proc.pid} workerFn failed:`, err)
252
+ this._log('error', `worker ${this._proc.pid} workerFn failed: ${/** @type {any} */ (err)?.stack ?? err}`)
215
253
  this._proc.exit(1)
216
254
  }
217
255
  }
@@ -16,7 +16,6 @@ export const GLOBAL_ONLY_KEYS = Object.freeze([
16
16
  'apps', // 활성 앱 whitelist (ADR-066)
17
17
  'asp', // masterSecret 등 시크릿
18
18
  'health', // /health, /health/ready
19
- 'tracing', // OpenTelemetry (ADR-077)
20
19
  'plugins', // 명시 등록 배열 (ADR-079)
21
20
  'jobs', // MegaJob 서브클래스 배열 — mega worker 가 소비 (ADR-123)
22
21
  'schedules', // MegaSchedule 서브클래스 배열 — mega scheduler 가 소비 (ADR-123)
@@ -133,7 +133,7 @@ function mergeRedact(userRedact) {
133
133
  /**
134
134
  * `logger` config → pino 옵션(또는 비활성 시 `null`). Fastify `logger` 또는 `pino()` 에 그대로 전달 가능.
135
135
  *
136
- * @param {unknown} config - `MegaLoggerConfig`(`{ level, sinks, redact?, includeRequestId? }`).
136
+ * @param {unknown} config - `MegaLoggerConfig`(`{ level, sinks, redact? }`).
137
137
  * @returns {{ level: string, mixin: Function, redact: { paths: string[] }, transport: { targets: any[] } } | null}
138
138
  */
139
139
  export function buildLoggerOptions(config) {
@@ -37,6 +37,14 @@ let globalErrorsRegistered = false
37
37
  let unhandledRejectionHandler = null
38
38
  /** @type {((err: Error, origin?: string) => void) | null} */
39
39
  let uncaughtExceptionHandler = null
40
+ /**
41
+ * 전역 에러 핸들러가 fatal 로그에 쓸 로거. boot 이 pino 공유 인스턴스(appLogger)를 주입한다(setLogger).
42
+ * 미설정(부팅 전 조기 크래시·로거 비활성 프로세스)이면 console.error 로 폴백한다. process 레벨 핸들러는
43
+ * bootApp 의 DI 그래프 밖이라 모듈 스코프 변수로 받아 둔다(전역 오염 회피, ADR-178).
44
+ * @typedef {{ info?: (...args: any[]) => void, warn?: (...args: any[]) => void, error?: (...args: any[]) => void, fatal?: (...args: any[]) => void }} ShutdownLogger
45
+ * @type {ShutdownLogger | null}
46
+ */
47
+ let shutdownLogger = null
40
48
 
41
49
  /**
42
50
  * cleanup hook 등록. 순서는 등록 역순(LIFO)으로 실행.
@@ -111,22 +119,40 @@ function setupGlobalErrorHandlers({ exitCode = 1 } = {}) {
111
119
  if (globalErrorsRegistered) return
112
120
  globalErrorsRegistered = true
113
121
  unhandledRejectionHandler = (reason) => {
114
- const log = /** @type {any} */ (globalThis).logger
115
122
  const err = reason instanceof Error ? reason : new Error(`unhandledRejection: ${String(reason)}`)
116
- if (log?.fatal) log.fatal({ err }, 'unhandledRejection — initiating graceful shutdown')
117
- else console.error('[mega-shutdown] unhandledRejection — initiating graceful shutdown:', err)
123
+ logShutdown('fatal', 'unhandledRejection — initiating graceful shutdown', { err })
118
124
  void now({ signal: 'unhandledRejection', exitCode })
119
125
  }
120
126
  uncaughtExceptionHandler = (err, origin) => {
121
- const log = /** @type {any} */ (globalThis).logger
122
- if (log?.fatal) log.fatal({ err, origin }, 'uncaughtException — initiating graceful shutdown')
123
- else console.error('[mega-shutdown] uncaughtException — initiating graceful shutdown:', err, origin)
127
+ logShutdown('fatal', 'uncaughtException initiating graceful shutdown', { err, origin })
124
128
  void now({ signal: 'uncaughtException', exitCode })
125
129
  }
126
130
  process.on('unhandledRejection', unhandledRejectionHandler)
127
131
  process.on('uncaughtException', uncaughtExceptionHandler)
128
132
  }
129
133
 
134
+ /**
135
+ * 종료 시퀀스 로깅 — 주입된 로거(setLogger)가 있으면 그 레벨 메서드로, 없으면 console 폴백.
136
+ * process 레벨 종료 경로라 로거 미주입(부팅 전·로거 비활성 프로세스)일 수 있어 항상 폴백을 갖춘다.
137
+ * `fatal` 은 console 에 없으므로 폴백에선 `console.error` 로 매핑한다. fields 는 구조적 로그의 payload
138
+ * (pino `log.level(fields, msg)`)이자 console 폴백의 보조 인자.
139
+ * @param {'info'|'warn'|'error'|'fatal'} level - 로그 레벨.
140
+ * @param {string} msg - 메시지.
141
+ * @param {Record<string, any>} [fields] - 구조적 필드(선택).
142
+ * @returns {void}
143
+ */
144
+ function logShutdown(level, msg, fields) {
145
+ const log = shutdownLogger
146
+ if (log && typeof (/** @type {any} */ (log)[level]) === 'function') {
147
+ if (fields) /** @type {any} */ (log)[level](fields, msg)
148
+ else /** @type {any} */ (log)[level](msg)
149
+ return
150
+ }
151
+ const consoleFn = level === 'warn' ? console.warn : level === 'info' ? console.log : console.error
152
+ if (fields) consoleFn(`[mega-shutdown] ${msg}`, fields)
153
+ else consoleFn(`[mega-shutdown] ${msg}`)
154
+ }
155
+
130
156
  /**
131
157
  * 즉시 graceful shutdown 트리거.
132
158
  *
@@ -138,17 +164,17 @@ function setupGlobalErrorHandlers({ exitCode = 1 } = {}) {
138
164
  async function now({ signal, exitCode = 0 } = {}) {
139
165
  // M-3 — 두 번째 shutdown 시그널: 이미 진행 중이면 grace 무시하고 즉시 force exit 1.
140
166
  if (isShuttingDownFlag) {
141
- console.error('[mega-shutdown] MegaShutdown: second shutdown signal — force exit 1')
167
+ logShutdown('error', 'second shutdown signal — force exit 1')
142
168
  process.exit(1)
143
169
  return // process.exit mock 시(테스트) 아래로 흐르지 않도록 가드
144
170
  }
145
171
  isShuttingDownFlag = true
146
172
 
147
- console.log(`[mega-shutdown] starting (signal=${signal ?? 'manual'}, handlers=${handlers.length}, grace=${gracePeriodMs}ms)`)
173
+ logShutdown('info', 'shutdown starting', { signal: signal ?? 'manual', handlers: handlers.length, graceMs: gracePeriodMs })
148
174
 
149
175
  // hardKill 보호 — hardKillMs 초과 시 강제 종료
150
176
  const hardKillTimer = setTimeout(() => {
151
- console.error('[mega-shutdown] grace period exceeded, force exit(1)')
177
+ logShutdown('error', 'grace period exceeded force exit(1)', { hardKillMs })
152
178
  process.exit(1)
153
179
  }, hardKillMs)
154
180
  hardKillTimer.unref()
@@ -158,7 +184,7 @@ async function now({ signal, exitCode = 0 } = {}) {
158
184
  const { name, fn } = handlers[i]
159
185
  if (exitedHandlers.has(i)) continue
160
186
  try {
161
- console.log(`[mega-shutdown] running '${name}'`)
187
+ logShutdown('info', `running hook '${name}'`, { hook: name })
162
188
  await Promise.race([
163
189
  Promise.resolve(fn()),
164
190
  new Promise((_, reject) =>
@@ -166,18 +192,28 @@ async function now({ signal, exitCode = 0 } = {}) {
166
192
  ),
167
193
  ])
168
194
  exitedHandlers.add(i)
169
- console.log(`[mega-shutdown] '${name}' done`)
195
+ logShutdown('info', `hook '${name}' done`, { hook: name })
170
196
  } catch (err) {
171
197
  // silent 금지. 핸들러 실패 시 warn 로그 + 다음 hook 진행.
172
- console.warn(`[mega-shutdown] '${name}' failed (continuing):`, err?.message ?? err)
198
+ logShutdown('warn', `hook '${name}' failed (continuing)`, { hook: name, err: /** @type {any} */ (err)?.message ?? err })
173
199
  }
174
200
  }
175
201
 
176
202
  clearTimeout(hardKillTimer)
177
- console.log(`[mega-shutdown] complete, exit(${exitCode})`)
203
+ logShutdown('info', `shutdown complete exit(${exitCode})`, { exitCode })
178
204
  process.exit(exitCode)
179
205
  }
180
206
 
207
+ /**
208
+ * 전역 에러 핸들러(unhandledRejection/uncaughtException)가 fatal 로그에 쓸 로거를 주입한다(ADR-178).
209
+ * boot 이 pino 공유 인스턴스(appLogger)를 만들 때 호출한다. 미호출이면 핸들러는 console.error 로 폴백한다.
210
+ * @param {ShutdownLogger | null | undefined} logger - pino 호환 로거(info/warn/error/fatal 사용) 또는 null.
211
+ * @returns {void}
212
+ */
213
+ function setLogger(logger) {
214
+ shutdownLogger = logger ?? null
215
+ }
216
+
181
217
  /**
182
218
  * 테스트용 — 등록된 hook 수 (디버그).
183
219
  * @returns {number}
@@ -202,6 +238,7 @@ function _reset() {
202
238
  unhandledRejectionHandler = null
203
239
  uncaughtExceptionHandler = null
204
240
  globalErrorsRegistered = false
241
+ shutdownLogger = null
205
242
  }
206
243
 
207
244
  /**
@@ -214,6 +251,7 @@ export const MegaShutdown = {
214
251
  isShuttingDown,
215
252
  setupSignals,
216
253
  setupGlobalErrorHandlers,
254
+ setLogger,
217
255
  now,
218
256
  registeredCount,
219
257
  _reset,