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 +1 -1
- package/sample/crud/.env +156 -8
- package/sample/crud/.env.example +153 -28
- package/sample/crud/mega.config.js +61 -2
- package/sample/crud/package.json +2 -2
- package/sample/crud/yarn.lock +1 -1
- package/src/cli/index.js +132 -11
- package/src/core/boot.js +30 -1
- package/src/core/config-validator.js +25 -0
- package/src/core/mega-app.js +25 -21
- package/src/core/mega-cluster.js +50 -12
- package/src/core/scope-registry.js +0 -1
- package/src/lib/mega-logger.js +1 -1
- package/src/lib/mega-shutdown.js +51 -13
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/sample/crud/.env.example
CHANGED
|
@@ -1,50 +1,175 @@
|
|
|
1
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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
|
|
17
|
-
# DB 인덱스를 분리해 키
|
|
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) —
|
|
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
|
|
25
|
-
#
|
|
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
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
#
|
|
39
|
-
#
|
|
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
|
|
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
|
-
|
|
49
|
-
#
|
|
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: '
|
|
108
|
-
sinks
|
|
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
|
}
|
package/sample/crud/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"node": ">=20"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"dev": "
|
|
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
|
+
}
|
package/sample/crud/yarn.lock
CHANGED
|
@@ -1234,7 +1234,7 @@ marked@^18.0.5:
|
|
|
1234
1234
|
integrity sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==
|
|
1235
1235
|
|
|
1236
1236
|
"mega-framework@file:../..":
|
|
1237
|
-
version "0.1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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자). */
|
package/src/core/mega-app.js
CHANGED
|
@@ -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
|
-
// /
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
|
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',
|
package/src/core/mega-cluster.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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)
|
package/src/lib/mega-logger.js
CHANGED
|
@@ -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
|
|
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) {
|
package/src/lib/mega-shutdown.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
logShutdown('info', `hook '${name}' done`, { hook: name })
|
|
170
196
|
} catch (err) {
|
|
171
197
|
// silent 금지. 핸들러 실패 시 warn 로그 + 다음 hook 진행.
|
|
172
|
-
|
|
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
|
-
|
|
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,
|