mega-framework 0.1.4 → 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.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/sample/crud/.env +156 -8
  3. package/sample/crud/.env.example +153 -28
  4. package/sample/crud/mega.config.js +61 -2
  5. package/sample/crud/package.json +2 -2
  6. package/sample/crud/yarn.lock +1 -1
  7. package/src/cli/commands/new.js +115 -67
  8. package/src/cli/commands/scaffold.js +6 -12
  9. package/src/cli/index.js +133 -12
  10. package/src/core/boot.js +30 -1
  11. package/src/core/config-validator.js +25 -0
  12. package/src/core/mega-app.js +25 -21
  13. package/src/core/mega-cluster.js +50 -12
  14. package/src/core/scope-registry.js +0 -1
  15. package/src/lib/mega-logger.js +1 -1
  16. package/src/lib/mega-shutdown.js +51 -13
  17. package/sample/crud/test/apps/main/auth-flow.integration.test.js +0 -177
  18. package/sample/crud/test/apps/main/auth-service.test.js +0 -93
  19. package/sample/crud/test/apps/main/chat-channel.test.js +0 -149
  20. package/sample/crud/test/apps/main/cron-demo-service.test.js +0 -93
  21. package/sample/crud/test/apps/main/demo-flow.integration.test.js +0 -386
  22. package/sample/crud/test/apps/main/email-job.test.js +0 -76
  23. package/sample/crud/test/apps/main/guide-service.test.js +0 -68
  24. package/sample/crud/test/apps/main/hash-task.test.js +0 -30
  25. package/sample/crud/test/apps/main/jobs-demo-service.test.js +0 -88
  26. package/sample/crud/test/apps/main/logs-demo-service.test.js +0 -85
  27. package/sample/crud/test/apps/main/metrics-demo-service.test.js +0 -90
  28. package/sample/crud/test/apps/main/note-service.test.js +0 -68
  29. package/sample/crud/test/apps/main/perf-service.test.js +0 -121
  30. package/sample/crud/test/apps/main/perf.integration.test.js +0 -202
  31. package/sample/crud/test/apps/main/redis-demo-service.test.js +0 -98
  32. package/sample/crud/test/apps/main/tracing-demo-service.test.js +0 -90
  33. package/sample/crud/test/apps/main/upload-demo-service.test.js +0 -61
  34. package/sample/crud/test/apps/main/user-service.test.js +0 -65
  35. package/sample/crud/test/apps/main/ws-chat.integration.test.js +0 -233
  36. package/templates/project/app.config.tpl +0 -8
  37. package/templates/project/app.config.views.tpl +0 -37
  38. package/templates/project/ecosystem.config.tpl +0 -10
  39. package/templates/project/env.tpl +0 -12
  40. package/templates/project/gitignore.tpl +0 -8
  41. package/templates/project/locales/client/en.json.tpl +0 -3
  42. package/templates/project/locales/client/ko.json.tpl +0 -3
  43. package/templates/project/locales/server/en.json.tpl +0 -17
  44. package/templates/project/locales/server/ko.json.tpl +0 -17
  45. package/templates/project/mega.config.tpl +0 -11
  46. package/templates/project/package.tpl +0 -25
  47. package/templates/project/public/css/app.css +0 -101
  48. package/templates/project/public/js/app.js +0 -54
  49. package/templates/project/public/js/theme-init.js +0 -12
  50. package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +0 -7
  51. package/templates/project/public/vendor/bootstrap/bootstrap.min.css +0 -6
  52. package/templates/project/readme.tpl +0 -48
  53. package/templates/project/route.test.tpl +0 -13
  54. package/templates/project/route.test.views.tpl +0 -15
  55. package/templates/project/route.tpl +0 -10
  56. package/templates/project/route.views.tpl +0 -10
  57. package/templates/project/views/index.ejs.tpl +0 -58
  58. package/templates/project/views/layout.ejs.tpl +0 -73
  59. package/templates/project/vitest.config.tpl +0 -8
@@ -1,26 +1,94 @@
1
1
  // @ts-check
2
2
  /**
3
- * `mega new <project>` — 폴더에서 멀티앱 hello world 까지 명령 스캐폴드 (roadmap §336, ADR-142).
3
+ * `mega new <project>` — `sample/crud` 데모앱을 그대로 스캐폴드한다(ADR-179, 기존 미니멀/--views 모드 대체).
4
4
  *
5
- * 기본 `main` + `*.localhost` 호스트 + README · `.env.example` · `.gitignore` · `package.json`
6
- * (scripts + concurrently devDep + PM2 `ecosystem.config.cjs`) 를 만든다. `ejsMate` 옵트인이면 EJS +
7
- * ejs-mate 뷰 골격(ADR-011/136) + i18next 다국어(ADR-037~039) + Bootstrap 5 디자인(ADR-151)을 더한다
8
- * 렌더 라우트, ko/en 로케일, 그리고 토큰 치환 없이 그대로 복사하는 정적 자산(vendored Bootstrap +
9
- * 브랜드 CSS/JS). 신규 dep 설치는 하지 않고(네트워크 회피) 안내만 출력한다.
5
+ * 폴더에 14개 기능(auth·notes/users CRUD·WS 채팅·jobs·cron·metrics·logs·perf·redis·tracing·upload·
6
+ * worker·guide)을 모두 갖춘 멀티앱 데모를 찍어낸다. 원본은 레포의 `sample/crud`(npm `files` 포함돼
7
+ * 패키지에도 동봉) 트리를 **바이트 그대로 복사**하고, 프로젝트 이름이 박힌 소수 파일(package.json·README·
8
+ * ecosystem·.env)만 토큰 치환한다 `sample-crud` 프로젝트명, dep `file:../..` 실제 버전(`^x.y.z`).
9
+ * 로컬 인프라 기동용 루트 `docker-compose.yml` 함께 복사한다(개발자 학습용, ADR-103).
10
+ *
11
+ * 복사 제외: 빌드 산출물(node_modules·coverage)·로크파일(package-lock/yarn.lock)·런타임 로그(*.log)·VCS,
12
+ * 그리고 런타임 업로드 데이터(`var/uploads/*` — 개인정보 포함 가능). 업로드 데모가 쓰는 디렉토리는
13
+ * 빈 `.gitkeep` 으로만 만든다. 신규 dep 설치는 하지 않고(네트워크 회피) 안내만 출력한다.
10
14
  *
11
15
  * @module cli/commands/new
12
16
  */
13
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
14
- import { dirname, join, resolve } from 'node:path'
17
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
18
+ import { dirname, join, relative, resolve, sep } from 'node:path'
15
19
  import { fileURLToPath } from 'node:url'
16
- import { nameVariants, renderTemplate } from '../template-engine.js'
20
+ import { nameVariants } from '../template-engine.js'
21
+
22
+ /** 이 파일이 있는 디렉토리(src/cli/commands). 아래 원본 경로의 기준점. */
23
+ const HERE = dirname(fileURLToPath(import.meta.url))
24
+ /** 스캐폴드 원본 = 레포의 `sample/crud` 데모앱(src/cli/commands 기준 3단계 위 + sample/crud). */
25
+ const SAMPLE_ROOT = resolve(HERE, '../../../sample/crud')
26
+ /** 루트 `docker-compose.yml` — 로컬 인프라(postgres·mariadb·mongo·redis·nats·zipkin·otel) 기동용. */
27
+ const COMPOSE_SRC = resolve(HERE, '../../../docker-compose.yml')
28
+ /** 프레임워크 자체 package.json — 스캐폴드 dep 버전 핀(`^x.y.z`) 의 소스. */
29
+ const FRAMEWORK_PKG = resolve(HERE, '../../../package.json')
30
+
31
+ /** 복사 제외 디렉토리/파일 basename — 빌드 산출물·로크파일·VCS·런타임 데이터 디렉토리. */
32
+ const EXCLUDE_NAMES = new Set([
33
+ 'node_modules',
34
+ '.git',
35
+ 'coverage',
36
+ '.nyc_output',
37
+ '.vitest',
38
+ 'package-lock.json',
39
+ 'yarn.lock',
40
+ 'pnpm-lock.yaml',
41
+ '.DS_Store',
42
+ ])
43
+ /** 복사 제외 상대경로 prefix — 런타임 업로드 산출물(개인정보 포함 가능, 대신 .gitkeep 만 생성). */
44
+ const EXCLUDE_PREFIXES = ['var/uploads']
45
+ /** 프로젝트 이름 토큰 치환을 적용할 텍스트 파일(그 외는 바이트 그대로 복사 — 바이너리 보존). */
46
+ const TRANSFORM_FILES = new Set(['package.json', 'README.md', 'ecosystem.config.cjs', '.env', '.env.example'])
47
+
48
+ /**
49
+ * 한 상대경로가 복사 제외 대상인지.
50
+ * @param {string} rel - POSIX 슬래시 상대경로.
51
+ * @returns {boolean}
52
+ */
53
+ function isExcluded(rel) {
54
+ if (rel.endsWith('.log')) return true
55
+ if (EXCLUDE_PREFIXES.some((p) => rel === p || rel.startsWith(`${p}/`))) return true
56
+ return rel.split('/').some((seg) => EXCLUDE_NAMES.has(seg))
57
+ }
17
58
 
18
- /** templates/project 루트. src/cli/commands/new.js 기준 3단계 위 + templates/project. */
19
- const PROJECT_TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), '../../../templates/project')
59
+ /**
60
+ * 디렉토리를 재귀 순회해 복사 대상 파일의 상대경로(POSIX 슬래시) 목록을 만든다. 제외 디렉토리는
61
+ * 통째로 건너뛴다(node_modules 등 큰 트리 순회 비용 회피).
62
+ * @param {string} dir - 현재 순회 디렉토리(절대).
63
+ * @param {string} base - 상대경로 기준 루트(절대).
64
+ * @param {string[]} acc - 누적 결과.
65
+ * @returns {string[]}
66
+ */
67
+ function walk(dir, base, acc) {
68
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
69
+ const abs = join(dir, ent.name)
70
+ const rel = relative(base, abs).split(sep).join('/')
71
+ if (isExcluded(rel)) continue
72
+ if (ent.isDirectory()) walk(abs, base, acc)
73
+ else acc.push(rel)
74
+ }
75
+ return acc
76
+ }
20
77
 
21
- /** 프로젝트 템플릿을 읽어 토큰 치환. @param {string} file @param {Record<string,string>} vars @returns {string} */
22
- function renderProject(file, vars) {
23
- return renderTemplate(readFileSync(join(PROJECT_TEMPLATES, file), 'utf8'), vars)
78
+ /**
79
+ * 토큰 치환 — `sample-crud` 를 프로젝트명으로, package.json 의 모노레포 dep(`file:../..`) 실제 버전으로.
80
+ * @param {string} rel - 파일 상대경로.
81
+ * @param {string} content - 원본 텍스트.
82
+ * @param {{ name: string, version: string }} vars
83
+ * @returns {string}
84
+ */
85
+ function transformText(rel, content, vars) {
86
+ let out = content.replaceAll('sample-crud', vars.name)
87
+ if (rel === 'package.json') {
88
+ // 모노레포 로컬 링크 → 퍼블리시된 프레임워크 버전 핀. 스캐폴드된 프로젝트는 독립 패키지라 file: 가 안 풀린다.
89
+ out = out.replace('"file:../.."', `"^${vars.version}"`)
90
+ }
91
+ return out
24
92
  }
25
93
 
26
94
  /**
@@ -28,74 +96,54 @@ function renderProject(file, vars) {
28
96
  * @param {string} targetDir - 프로젝트 루트 절대/상대 경로.
29
97
  * @param {object} [opts]
30
98
  * @param {string} [opts.name] - 프로젝트 이름(기본: targetDir 의 마지막 세그먼트).
31
- * @param {boolean} [opts.ejsMate] - EJS + ejs-mate 뷰 골격 포함(기본 false).
32
99
  * @param {boolean} [opts.force] - 기존 파일 덮어쓰기(기본 false — 건너뜀).
33
100
  * @returns {{ root: string, written: string[], skipped: string[] }}
34
101
  */
35
- export function scaffoldProject(targetDir, { name, ejsMate = false, force = false } = {}) {
102
+ export function scaffoldProject(targetDir, { name, force = false } = {}) {
36
103
  const root = resolve(targetDir)
37
104
  const projectName = nameVariants(name ?? root.split(/[/\\]/).pop() ?? 'mega-app').kebab
38
- const vars = { name: projectName, Name: nameVariants(projectName).pascal }
39
-
40
- // 옵트인이면 GET / 렌더로 바꾸고(JSON hello 대신) 그에 맞는 테스트를 쓴다.
41
- /** @type {Array<{ rel: string, tpl: string }>} */
42
- const files = [
43
- { rel: 'mega.config.js', tpl: 'mega.config.tpl' },
44
- { rel: 'apps/main/app.config.js', tpl: ejsMate ? 'app.config.views.tpl' : 'app.config.tpl' },
45
- { rel: 'apps/main/routes/index.js', tpl: ejsMate ? 'route.views.tpl' : 'route.tpl' },
46
- { rel: 'test/apps/main/index.test.js', tpl: ejsMate ? 'route.test.views.tpl' : 'route.test.tpl' },
47
- { rel: 'package.json', tpl: 'package.tpl' },
48
- { rel: 'README.md', tpl: 'readme.tpl' },
49
- { rel: '.env.example', tpl: 'env.tpl' },
50
- { rel: '.gitignore', tpl: 'gitignore.tpl' },
51
- { rel: 'vitest.config.js', tpl: 'vitest.config.tpl' },
52
- { rel: 'ecosystem.config.cjs', tpl: 'ecosystem.config.tpl' },
53
- ]
54
- if (ejsMate) {
55
- files.push(
56
- { rel: 'apps/main/views/layouts/main.ejs', tpl: 'views/layout.ejs.tpl' },
57
- { rel: 'apps/main/views/index.ejs', tpl: 'views/index.ejs.tpl' },
58
- { rel: 'apps/main/locales/server/ko.json', tpl: 'locales/server/ko.json.tpl' },
59
- { rel: 'apps/main/locales/server/en.json', tpl: 'locales/server/en.json.tpl' },
60
- { rel: 'apps/main/locales/client/ko.json', tpl: 'locales/client/ko.json.tpl' },
61
- { rel: 'apps/main/locales/client/en.json', tpl: 'locales/client/en.json.tpl' },
62
- )
63
- }
64
-
65
- // 정적 자산 — 토큰 치환 없이 바이트 그대로 복사한다(vendored Bootstrap 5 min 번들 + 브랜드 CSS/JS).
66
- // 이들은 템플릿이 아니라 정적 파일이므로 renderTemplate(`{{token}}`)을 거치지 않는다.
67
- /** @type {Array<{ rel: string, src: string }>} */
68
- const assets = ejsMate
69
- ? [
70
- { rel: 'apps/main/public/vendor/bootstrap/bootstrap.min.css', src: 'public/vendor/bootstrap/bootstrap.min.css' },
71
- { rel: 'apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js', src: 'public/vendor/bootstrap/bootstrap.bundle.min.js' },
72
- { rel: 'apps/main/public/css/app.css', src: 'public/css/app.css' },
73
- { rel: 'apps/main/public/js/app.js', src: 'public/js/app.js' },
74
- { rel: 'apps/main/public/js/theme-init.js', src: 'public/js/theme-init.js' },
75
- ]
76
- : []
105
+ /** @type {string} */
106
+ const version = JSON.parse(readFileSync(FRAMEWORK_PKG, 'utf8')).version
107
+ const vars = { name: projectName, version }
77
108
 
78
109
  /** @type {string[]} */ const written = []
79
110
  /** @type {string[]} */ const skipped = []
80
- for (const { rel, tpl } of files) {
81
- const out = join(root, rel)
111
+
112
+ /**
113
+ * 출력 파일 1개를 기록(존재 + !force 면 skip).
114
+ * @param {string} out - 출력 절대경로.
115
+ * @param {() => void} write - 실제 기록 동작.
116
+ */
117
+ const emit = (out, write) => {
82
118
  if (existsSync(out) && !force) {
83
119
  skipped.push(out)
84
- continue
120
+ return
85
121
  }
86
122
  mkdirSync(dirname(out), { recursive: true })
87
- writeFileSync(out, renderProject(tpl, vars))
123
+ write()
88
124
  written.push(out)
89
125
  }
90
- for (const { rel, src } of assets) {
126
+
127
+ // 1) sample/crud 트리 복사(제외 규칙 적용). 토큰 치환 대상만 텍스트로 렌더, 그 외는 바이트 그대로.
128
+ for (const rel of walk(SAMPLE_ROOT, SAMPLE_ROOT, [])) {
129
+ const src = join(SAMPLE_ROOT, rel)
91
130
  const out = join(root, rel)
92
- if (existsSync(out) && !force) {
93
- skipped.push(out)
94
- continue
95
- }
96
- mkdirSync(dirname(out), { recursive: true })
97
- copyFileSync(join(PROJECT_TEMPLATES, src), out)
98
- written.push(out)
131
+ emit(out, () => {
132
+ if (TRANSFORM_FILES.has(rel)) {
133
+ writeFileSync(out, transformText(rel, readFileSync(src, 'utf8'), vars))
134
+ } else {
135
+ copyFileSync(src, out)
136
+ }
137
+ })
99
138
  }
139
+
140
+ // 2) 루트 docker-compose.yml — 로컬 인프라(개발자 학습용). 프로젝트 이름과 무관해 그대로 복사.
141
+ const compose = join(root, 'docker-compose.yml')
142
+ emit(compose, () => copyFileSync(COMPOSE_SRC, compose))
143
+
144
+ // 3) 업로드 데모가 쓰는 디렉토리 placeholder — 실제 업로드 데이터(개인정보 PDF 등)는 동봉하지 않는다.
145
+ const gitkeep = join(root, 'var/uploads/.gitkeep')
146
+ emit(gitkeep, () => writeFileSync(gitkeep, ''))
147
+
100
148
  return { root, written, skipped }
101
149
  }
@@ -7,7 +7,6 @@
7
7
  * @module cli/commands/scaffold
8
8
  */
9
9
  import { Command } from 'commander'
10
- import prompts from 'prompts'
11
10
  import { generate, GENERATOR_KINDS } from '../generators/index.js'
12
11
  import { scaffoldProject } from './new.js'
13
12
  import { runRoutesCommand } from './routes.js'
@@ -50,21 +49,16 @@ export async function runScaffoldCommand(argv, { out, err, projectRoot, logger }
50
49
 
51
50
  program
52
51
  .command('new <project>')
53
- .description(' 폴더에서 멀티앱 hello world 스캐폴드')
54
- .option('--views', 'EJS + ejs-mate 뷰 골격 포함')
52
+ .description('sample/crud 데모앱(14기능) 전체를 폴더에 스캐폴드')
55
53
  .option('--force', '기존 파일 덮어쓰기')
56
- .action(async (/** @type {string} */ project, /** @type {any} */ opts) => {
57
- // --views 미지정 + 대화형 터미널이면 ejs-mate 옵트인을 묻는다(비-TTY/CI 는 기본 false).
58
- let ejsMate = opts.views === true
59
- if (opts.views === undefined && process.stdin.isTTY) {
60
- const ans = /** @type {any} */ (await prompts({ type: 'confirm', name: 'views', message: 'EJS + ejs-mate 뷰 골격을 포함할까요?', initial: false }))
61
- ejsMate = ans.views === true
62
- }
54
+ .action((/** @type {string} */ project, /** @type {any} */ opts) => {
63
55
  const target = project === '.' ? projectRoot : `${projectRoot}/${project}`
64
- const r = scaffoldProject(target, { name: project === '.' ? undefined : project, ejsMate, force: opts.force === true })
56
+ const r = scaffoldProject(target, { name: project === '.' ? undefined : project, force: opts.force === true })
65
57
  out(`mega: scaffolded project at ${r.root}`)
66
58
  reportFiles(out, r, r.root)
67
- out('\n다음 단계:\n cd ' + project + '\n npm install\n npm run dev')
59
+ // 풀 데모는 로컬 인프라(postgres·redis·mongo·nats)가 있어야 부팅된다 docker compose 띄운 마이그레이션.
60
+ const cd = project === '.' ? '' : ` cd ${project}\n`
61
+ out(`\n다음 단계:\n${cd} npm install\n docker compose up -d # 로컬 인프라 기동(postgres·redis·mongo·nats)\n npm run migrate # DB 스키마 생성\n npm run dev # 개발 서버`)
68
62
  })
69
63
 
70
64
  program
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,13 +41,13 @@ 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)
46
48
  mega migrate:down [--db KEY] 마지막 적용 마이그레이션 1개 롤백(down)
47
49
  mega migrate:status [--db KEY] 적용/미적용 마이그레이션 목록
48
- mega new <project> [--views] [--force] 폴더에서 멀티앱 hello world 스캐폴드
50
+ mega new <project> [--force] sample/crud 데모앱(14기능) 전체를 스캐폴드
49
51
  mega g <kind> <name> [--app A] [--version vN] 코드+테스트 생성 (별칭: generate)
50
52
  [--kind K] [--lng L] [--force]
51
53
  mega routes [--root DIR] 등록 라우트 트리 출력
@@ -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자). */