@zntc/core 0.1.0

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/bin/zntc.mjs ADDED
@@ -0,0 +1,2327 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ZNTC CLI — Node.js/Bun 호환 CLI
5
+ *
6
+ * 내부적으로 @zntc/core NAPI 바인딩을 사용하여 트랜스파일/번들링을 수행.
7
+ * Watch/Serve는 JS 레이어에서 구현.
8
+ */
9
+
10
+ import { mkdirSync, existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { resolve, dirname, basename, extname, join, sep } from 'node:path';
12
+ import { createServer } from 'node:http';
13
+ import { createServer as createHttpsServer } from 'node:https';
14
+ import { createRequire } from 'node:module';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ import {
18
+ applyFlagAction,
19
+ KNOWN_FLAGS,
20
+ matchFlagFromRegistry,
21
+ normalizeFallback,
22
+ } from './cli-flags.mjs';
23
+ import { copyRnAssets } from './rn-asset-copy.mjs';
24
+ import {
25
+ buildRnBundleExtra,
26
+ buildRnBundleOverride,
27
+ buildRnDevServerInput,
28
+ } from './rn-dev-input.mjs';
29
+ import { applyColorPreference, printZntcBanner } from './banner.mjs';
30
+
31
+ function isMissingBuiltCore(error) {
32
+ if (!error || error.code !== 'ERR_MODULE_NOT_FOUND') return false;
33
+ const builtCorePath = fileURLToPath(new URL('../dist/index.js', import.meta.url));
34
+ return String(error.message ?? '').includes(builtCorePath);
35
+ }
36
+
37
+ async function loadCoreModule() {
38
+ try {
39
+ return await import('../dist/index.js');
40
+ } catch (error) {
41
+ if (!isMissingBuiltCore(error)) throw error;
42
+ console.error('error: @zntc/core JS bundle is missing');
43
+ console.error('');
44
+ console.error('note: zntc CLI runs the built JS entry at packages/core/dist/index.js.');
45
+ console.error('note: source TypeScript is not loaded directly by Node.');
46
+ console.error('');
47
+ console.error('help: run `bun run --cwd packages/core build:js` from the repository root.');
48
+ console.error(
49
+ 'help: for a full local build (NAPI binary 포함), run `bun run --cwd packages/core build:local`.',
50
+ );
51
+ process.exit(1);
52
+ }
53
+ }
54
+
55
+ const coreModule = await loadCoreModule();
56
+ const {
57
+ init,
58
+ transpile,
59
+ build,
60
+ buildAppSync,
61
+ buildSync,
62
+ envToDefine,
63
+ filterWorkspaces,
64
+ findConfigPath,
65
+ findModeConfigPath,
66
+ findWorkspacePath,
67
+ identifyWorkspaceEntries,
68
+ importAndResolveDefault,
69
+ KNOWN_CONFIG_KEYS,
70
+ loadConfig,
71
+ loadEnv,
72
+ loadIdentifiedConfig,
73
+ loadWorkspace,
74
+ mergeUserConfigs,
75
+ suggestKey,
76
+ tokenize,
77
+ configureProfile,
78
+ profileReport,
79
+ validateTsConfigRaw,
80
+ warnUnknownKeys,
81
+ } = coreModule;
82
+
83
+ export { KNOWN_FLAGS };
84
+ const requireFromCli = createRequire(import.meta.url);
85
+ const cliNodeModules = resolve(dirname(fileURLToPath(import.meta.url)), '../../..', 'node_modules');
86
+
87
+ // `@zntc/core` 패키지 version — dev server banner 의 v0.x.y 자리에 표시.
88
+ // dev / serve / RN dev 분기에서만 사용되므로 lazy 로 읽어 `zntc transpile` 같은
89
+ // one-shot CLI 의 cold start 비용 회피.
90
+ let cliVersionCache;
91
+ function getCliVersion() {
92
+ if (cliVersionCache !== undefined) return cliVersionCache;
93
+ try {
94
+ cliVersionCache = requireFromCli('../package.json').version;
95
+ } catch {
96
+ cliVersionCache = null;
97
+ }
98
+ return cliVersionCache;
99
+ }
100
+
101
+ // ─── CLI 인자 파싱 ───
102
+
103
+ function usageLines(command) {
104
+ if (command === 'dev') {
105
+ return [
106
+ 'Usage: zntc dev [root] [options]',
107
+ '',
108
+ 'Options:',
109
+ ' --host [host] Host to listen on (default: localhost)',
110
+ ' --port <port> Port to listen on (default: 12300)',
111
+ ' --open Open the app URL in the browser',
112
+ ' --mode <mode> Load mode-specific config and .env files',
113
+ ' --base <path> Base public path',
114
+ ' --entry-html <path> HTML entry file',
115
+ ' --public-dir <path|false> Public directory to serve',
116
+ ' --help, -h Show this help message',
117
+ ];
118
+ }
119
+ if (command === 'build') {
120
+ return [
121
+ 'Usage: zntc build [root] [options]',
122
+ '',
123
+ 'Options:',
124
+ ' --outdir <dir> Output directory',
125
+ ' --mode <mode> Load mode-specific config and .env files',
126
+ ' --base <path> Base public path',
127
+ ' --entry-html <path> HTML entry file',
128
+ ' --public-dir <path|false> Public directory to copy',
129
+ ' --minify Minify output',
130
+ ' --sourcemap[=mode] Emit source maps',
131
+ ' --help, -h Show this help message',
132
+ ];
133
+ }
134
+ if (command === 'preview') {
135
+ return [
136
+ 'Usage: zntc preview [outdir] [options]',
137
+ '',
138
+ 'Options:',
139
+ ' --host [host] Host to listen on (default: localhost)',
140
+ ' --port <port> Port to listen on (default: 12300)',
141
+ ' --strict-port Exit if the specified port is already in use',
142
+ ' --open Open the preview URL in the browser',
143
+ ' --base <path> Base public path',
144
+ ' --spa-fallback[=path] Serve an HTML fallback for app routes',
145
+ ' --certfile <path> HTTPS certificate file',
146
+ ' --keyfile <path> HTTPS key file',
147
+ ' --help, -h Show this help message',
148
+ ];
149
+ }
150
+ if (command === 'verify') {
151
+ return [
152
+ 'Usage: zntc verify <path-or-url> [options]',
153
+ '',
154
+ 'Loads the target in a headless Chromium and reports pageerror,',
155
+ 'console.error, 4xx responses, and request failures. Exits non-zero',
156
+ 'on any captured event so CI can gate on real browser runtime errors.',
157
+ '',
158
+ 'Options:',
159
+ ' --verify-timeout <ms> Page load timeout (default: 10000)',
160
+ ' --verify-ignore <pattern> Regex to skip matching console/url events (repeatable)',
161
+ ' --verify-allow-console-error console.error events do not affect exit code',
162
+ ' --verify-json Emit machine-readable report on stdout',
163
+ ' --verify-report <path> Write JSON report to file',
164
+ ' --help, -h Show this help message',
165
+ '',
166
+ 'Requires Playwright (peer/optional):',
167
+ ' npm install --save-dev playwright',
168
+ ' npx playwright install chromium',
169
+ ];
170
+ }
171
+ return [
172
+ 'Usage: zntc [options] <file.ts>',
173
+ ' zntc --bundle <entry.ts> -o out.js',
174
+ ' zntc --serve --bundle <entry.ts>',
175
+ ' zntc dev [root]',
176
+ ' zntc build [root]',
177
+ ' zntc preview [outdir]',
178
+ '',
179
+ 'Options:',
180
+ ' --bundle Bundle dependencies',
181
+ ' --packages=external Treat all bare package imports as external',
182
+ ' --pure:CALLEE Mark matching call/new expressions as removable when unused',
183
+ ' --line-limit=<n> Wrap generated output lines after safe token boundaries',
184
+ ' --conditions=<csv> Add custom package exports conditions',
185
+ ' --node-paths=<csv> Add bare specifier lookup directories',
186
+ ' --global:SPEC=NAME Map external specifier to IIFE/UMD global',
187
+ ' --intro=<text> Insert wrapper-internal text before bundle code',
188
+ ' --outro=<text> Insert wrapper-internal text after bundle code',
189
+ ' --tree-shaking[=false] Tree shaking (default: true; --no-tree-shaking to disable)',
190
+ ' --scope-hoist[=false] Scope hoisting (default: true; --no-scope-hoist to disable)',
191
+ ' --emit-disk-sourcemap[=false] Write .map to disk in watch mode (default: true)',
192
+ ' --fallback:SPEC=TARGET Fallback resolution on failure (=false → empty module)',
193
+ ' --block-list=<pattern> Block module resolution by pattern (repeatable)',
194
+ ' --min-chunk-size=<n> Merge small common chunks below n bytes',
195
+ ' --ignore-annotations Ignore pure/sideEffects annotations',
196
+ ' --jsx-side-effects Preserve unused JSX expressions',
197
+ ' --profile=<csv> Collect profile categories (all, parse, transform, ...)',
198
+ ' --profile-level=<level> Profile level: summary, detailed, per-module, per-pass',
199
+ ' --profile-format=<format> Profile output: table, tree, json, csv',
200
+ ' --runtime-polyfills=<mode> Inject core-js runtime polyfills: auto, usage, entry, off',
201
+ " --runtime-target=<query> Runtime polyfill Browserslist target (repeatable: 'chrome >= 87', 'safari >= 14')",
202
+ ' --core-js=<version> core-js version used for runtime polyfill compatibility',
203
+ ' --stop-after=<phase> Stop transpile after a given phase (debug)',
204
+ ' --tokenize[=false] Print scanner tokens instead of generated code',
205
+ ' --tokenize-format=<format> Token output: text or json',
206
+ ' --outdir <dir> Output directory',
207
+ ' --outfile <file>, -o <file> Output file',
208
+ ' --allow-overwrite Permit output paths to overwrite input files',
209
+ ' --watch, -w Rebuild on changes',
210
+ ' --serve [dir] Serve bundled output',
211
+ ' --config <path> Config file path',
212
+ ' --no-config Skip config file discovery/loading (CLI flags only)',
213
+ ' --color, --no-color Force or disable colored output (honors NO_COLOR)',
214
+ ' --version, -v Print version and exit',
215
+ ' --test262 <dir> Run Zig Test262 runner via zig build test262-run',
216
+ ' --help, -h Show this help message',
217
+ ];
218
+ }
219
+
220
+ function printUsage(command, stream = console.log) {
221
+ stream(usageLines(command).join('\n'));
222
+ }
223
+
224
+ function parseArgs(argv) {
225
+ const args = argv.slice(2);
226
+ const appCommands = new Set(['dev', 'build', 'preview', 'verify']);
227
+ const appCommand = appCommands.has(args[0]) ? args.shift() : undefined;
228
+ const opts = {
229
+ appCommand,
230
+ help: false,
231
+ version: false,
232
+ // config 자동 탐색·로드 우회 (--no-config). --config 명시보다 우선.
233
+ // workspace 모드(--workspace)는 config 가 본질이라 미적용 (경고만).
234
+ noConfig: false,
235
+ // 색상 출력 강제(true)/억제(false). undefined = NO_COLOR/FORCE_COLOR + TTY 자동.
236
+ color: undefined,
237
+ parseError: false,
238
+ appRoot: undefined,
239
+ previewDir: undefined,
240
+ entryPoints: [],
241
+ // SCALAR_KEYS (mergeConfigIntoOpts) 의 다른 키들과 동일하게 `undefined` 기본값 사용.
242
+ // 과거 `null` 이었으나 머지 조건이 `=== undefined` 라 `zntc.config.json` 의 outdir/outfile
243
+ // 만 silent drop 되는 회귀가 있었음. 모든 사용처가 truthy 검사 (`if (opts.outdir)`) 라
244
+ // null → undefined 변경은 동작 영향 없음.
245
+ outfile: undefined,
246
+ outdir: undefined,
247
+ bundle: false,
248
+ watch: false,
249
+ watchJson: false,
250
+ watchDelay: 100,
251
+ serve: false,
252
+ serveDir: '.',
253
+ port: undefined,
254
+ host: undefined,
255
+ strictPort: false,
256
+ open: false,
257
+ proxy: {},
258
+ format: undefined,
259
+ platform: undefined,
260
+ minify: false,
261
+ minifyWhitespace: false,
262
+ minifyIdentifiers: false,
263
+ minifySyntax: false,
264
+ sourcemap: false,
265
+ // undefined: NAPI 측이 missing 시 "linked" fallback. CLI/config 명시 시 override.
266
+ sourcemapMode: undefined,
267
+ // undefined: NAPI 측이 missing 시 "auto" fallback (#2159).
268
+ outputExports: undefined,
269
+ sourcemapDebugIds: false,
270
+ sourcesContent: true,
271
+ splitting: false,
272
+ metafile: undefined,
273
+ analyze: false,
274
+ treeShaking: true,
275
+ // 엔진 기본값 true — `--no-*` / `--*=false` 또는 config false 로만 끈다.
276
+ scopeHoist: true,
277
+ emitDiskSourcemap: true,
278
+ fallback: {},
279
+ blockList: [],
280
+ external: [],
281
+ packagesExternal: false,
282
+ define: {},
283
+ alias: {},
284
+ banner: undefined,
285
+ footer: undefined,
286
+ globalName: undefined,
287
+ publicPath: undefined,
288
+ entryNames: undefined,
289
+ chunkNames: undefined,
290
+ assetNames: undefined,
291
+ jsx: undefined,
292
+ jsxDev: false,
293
+ jsxFactory: undefined,
294
+ jsxFragment: undefined,
295
+ jsxImportSource: undefined,
296
+ // undefined 기본값이어야 RN dev server 의 자체 default(true)를 막지 않는다.
297
+ // `--dev` / config.devMode=true 만 명시 opt-in 으로 BuildOptions devMode에 전달.
298
+ devMode: undefined,
299
+ flow: false,
300
+ experimentalDecorators: false,
301
+ useDefineForClassFields: true,
302
+ keepNames: false,
303
+ shimMissingExports: false,
304
+ preserveSymlinks: false,
305
+ resolveSymlinkSiblings: false,
306
+ // canonical shape — BOOL_KEYS 머지 키. 이전 opts default 누락으로
307
+ // config.disableHierarchicalLookup 가 silent 무시되던 pre-existing 버그
308
+ // (깨진 double-quote 가드가 은폐, C4 fix 로 검출).
309
+ disableHierarchicalLookup: false,
310
+ charsetUtf8: false,
311
+ asciiOnly: false,
312
+ quotes: undefined,
313
+ inject: [],
314
+ pure: [],
315
+ plugins: [],
316
+ pluginPaths: [],
317
+ stdin: false,
318
+ project: undefined,
319
+ tsconfigRaw: undefined,
320
+ logLevel: 'info',
321
+ jobs: undefined,
322
+ logLimit: undefined,
323
+ lineLimit: undefined,
324
+ minChunkSize: undefined,
325
+ clean: false,
326
+ allowOverwrite: false,
327
+ preserveModules: false,
328
+ preserveModulesRoot: undefined,
329
+ inlineDynamicImports: undefined,
330
+ loader: {},
331
+ legalComments: undefined,
332
+ resolveExtensions: [],
333
+ mainFields: [],
334
+ rnPlatform: undefined,
335
+ jsxInJs: false,
336
+ outExtensionJs: undefined,
337
+ sourceRoot: undefined,
338
+ target: undefined,
339
+ emitDecoratorMetadata: false,
340
+ verbatimModuleSyntax: undefined,
341
+ browserslist: undefined,
342
+ outbase: undefined,
343
+ drop: [],
344
+ dropLabels: [],
345
+ certfile: undefined,
346
+ keyfile: undefined,
347
+ configPath: undefined, // --config <path> 명시 시 자동 탐색 우회
348
+ mode: undefined, // --mode <name> 함수형 config / mode 별 config 머지 (#2110) 에서 사용
349
+ envPrefixes: undefined, // --env-prefix=VITE_,ZNTC_ — undefined 면 loadEnv default 사용
350
+ envDir: undefined, // --env-dir <path> — undefined 면 cwd
351
+ workspaceConfig: undefined, // --workspace-config <path> — 명시 시 자동 탐색 우회 (#2111)
352
+ workspace: undefined, // --workspace <name> — 단일 entry 만 빌드 (#2111)
353
+ entryHtml: undefined,
354
+ publicDir: undefined,
355
+ base: undefined,
356
+ spaFallback: undefined,
357
+ intro: undefined,
358
+ outro: undefined,
359
+ globals: {},
360
+ conditions: [],
361
+ nodePaths: [],
362
+ profile: [],
363
+ // canonical opts shape — ARRAY_KEYS 머지 키는 default 에 존재해야
364
+ // (drift-guard #2112). 미존재 시 lazy 초기화돼 머지 조건이 어긋남.
365
+ globalIdentifiers: [],
366
+ polyfills: [],
367
+ runBeforeMain: [],
368
+ watchFolders: [],
369
+ watchInclude: [],
370
+ watchExclude: [],
371
+ profileLevel: undefined,
372
+ profileFormat: undefined,
373
+ runtimePolyfills: undefined,
374
+ coreJs: undefined,
375
+ runtimeTargetQueries: [],
376
+ ignoreAnnotations: false,
377
+ jsxSideEffects: false,
378
+ stopAfter: undefined,
379
+ tokenize: false,
380
+ tokenizeFormat: 'text',
381
+ test262: undefined,
382
+ // verify 모드 (`zntc verify <path-or-url>`) 전용 — FLAG_REGISTRY 가 다른 모드에서
383
+ // 매칭해도 핸들러가 무시. verifyTarget 만 positional 로 채워진다.
384
+ verifyTarget: undefined,
385
+ verifyJson: false,
386
+ verifyReport: undefined,
387
+ verifyTimeout: undefined,
388
+ verifyIgnore: [],
389
+ verifyAllowConsoleError: false,
390
+ };
391
+
392
+ if (appCommand === 'dev') {
393
+ opts.serve = true;
394
+ opts.bundle = true;
395
+ opts.watch = true;
396
+ } else if (appCommand === 'build') {
397
+ opts.bundle = true;
398
+ } else if (appCommand === 'preview') {
399
+ opts.serve = true;
400
+ }
401
+
402
+ for (let i = 0; i < args.length; i++) {
403
+ const arg = args[i];
404
+
405
+ // stdin
406
+ if (arg === '-') {
407
+ opts.stdin = true;
408
+ continue;
409
+ }
410
+
411
+ // positional (파일 경로)
412
+ if (!arg.startsWith('-')) {
413
+ if (opts.appCommand === 'dev' || opts.appCommand === 'build') {
414
+ opts.appRoot = opts.appRoot ?? arg;
415
+ } else if (opts.appCommand === 'preview') {
416
+ opts.previewDir = opts.previewDir ?? arg;
417
+ } else if (opts.appCommand === 'verify') {
418
+ opts.verifyTarget = opts.verifyTarget ?? arg;
419
+ } else {
420
+ opts.entryPoints.push(arg);
421
+ }
422
+ continue;
423
+ }
424
+
425
+ // registry-driven 매칭. 새 flag 는 FLAG_REGISTRY 에 entry 한 줄만 추가.
426
+ const matched = matchFlagFromRegistry(arg, args, i);
427
+ if (matched) {
428
+ applyFlagAction(opts, matched.spec, matched.action);
429
+ i += matched.consumed - 1;
430
+ continue;
431
+ }
432
+
433
+ // ─── 특수 형식 (registry 표현이 어색해 if-chain 잔존) ───
434
+
435
+ // `--serve [DIR]` — 다음 토큰이 flag 아니면 serveDir 로 사용 (next-arg optional, default 유지)
436
+ if (arg === '--serve') {
437
+ opts.serve = true;
438
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
439
+ opts.serveDir = args[++i];
440
+ }
441
+ continue;
442
+ }
443
+
444
+ // `--host [VALUE]` — pair-form 이지만 누락 시 default "0.0.0.0".
445
+ // registry 의 string kind 와 의미 다름 (누락 시 undefined 가 아닌 명시 default).
446
+ if (arg === '--host') {
447
+ opts.host = args[++i] || '0.0.0.0';
448
+ continue;
449
+ }
450
+
451
+ // dev-server proxy — `--proxy /api=http://localhost:8080` 형식 (특수 parser)
452
+ if (arg.startsWith('--proxy')) {
453
+ const [path, target] =
454
+ arg.split('=').length > 1
455
+ ? [arg.split(' ')[0].replace('--proxy', '').replace('=', ''), args[i].split('=')[1]]
456
+ : [args[++i]?.split('=')[0], args[i]?.split('=')[1]];
457
+ if (path && target) opts.proxy[path] = target;
458
+ continue;
459
+ }
460
+
461
+ // unknown — typo 시 가장 가까운 known flag 제안 (Levenshtein, threshold 2).
462
+ if (opts.logLevel !== 'silent') {
463
+ const suggestion = suggestKey(arg, KNOWN_FLAGS);
464
+ console.error(
465
+ suggestion
466
+ ? `warning: unknown option '${arg}' — did you mean '${suggestion}'?`
467
+ : `warning: unknown option '${arg}'`,
468
+ );
469
+ }
470
+ opts.parseError = true;
471
+ }
472
+
473
+ // jsx-dev 단축어
474
+ if (opts.jsxDev) opts.jsx = 'automatic-dev';
475
+
476
+ // esbuild legacy alias normalize: `--jsx=transform` / `--jsx=preserve` → classic.
477
+ // docs/CONFIG.md 가 명시한 CLI vocab (preserve/transform/automatic) 을 strict NAPI vocab
478
+ // (classic/automatic/automatic-dev) 로 변환. JS API 는 이 정규화를 받지 않고 strict union
479
+ // type 만 허용 — CLI argv 의 raw string 만 esbuild 호환을 위해 관대하게 처리.
480
+ if (opts.jsx === 'transform' || opts.jsx === 'preserve') opts.jsx = 'classic';
481
+
482
+ // drop 처리
483
+ for (const d of opts.drop) {
484
+ if (d === 'console') opts.define['console.log'] = 'undefined';
485
+ if (d === 'debugger') opts.define['debugger'] = '';
486
+ }
487
+
488
+ return opts;
489
+ }
490
+
491
+ function formatTokenizeOutput(tokens, format) {
492
+ if (format === 'json') {
493
+ return `${JSON.stringify(tokens, null, 2)}\n`;
494
+ }
495
+ return tokens
496
+ .map((token) => {
497
+ const loc = `${token.line + 1}:${token.column + 1}`;
498
+ const span = `${token.start}-${token.end}`;
499
+ const text = token.text.length > 0 ? ` ${JSON.stringify(token.text)}` : '';
500
+ return `${loc} ${span} ${token.kind}${text}`;
501
+ })
502
+ .join('\n')
503
+ .concat('\n');
504
+ }
505
+
506
+ // ─── 파일 출력 ───
507
+
508
+ // realpathSync 가 throw 하면 (출력 파일은 보통 미존재) lexical resolve 로 fallback.
509
+ // Zig 측 (src/main.zig) 도 같은 전략을 쓴다 — 입력은 심볼릭 링크 해석, 출력은 일반적으로 미존재.
510
+ function safeRealpath(p) {
511
+ try {
512
+ return realpathSync(p);
513
+ } catch {
514
+ return resolve(p);
515
+ }
516
+ }
517
+
518
+ /**
519
+ * BuildResult / NAPI diag 의 errors / warnings 를 stderr 로 출력.
520
+ * `logLevel === 'silent'` 면 출력 안 함, `'error'` 면 errors 만.
521
+ * `err.specifier` 는 NAPI 가 diag suggestion 으로 노출하는 import specifier.
522
+ */
523
+ function printResultDiagnostics(result, logLevel) {
524
+ if (result.errors.length > 0 && logLevel !== 'silent') {
525
+ for (const err of result.errors) {
526
+ const loc = err.location ? `${err.location.file}: ` : '';
527
+ const detail = err.specifier ? ` (${err.specifier})` : '';
528
+ console.error(`error: ${loc}${err.text}${detail}`);
529
+ }
530
+ }
531
+ if (result.warnings.length > 0 && logLevel !== 'silent' && logLevel !== 'error') {
532
+ for (const warn of result.warnings) {
533
+ const detail = warn.specifier ? ` (${warn.specifier})` : '';
534
+ console.error(`warning: ${warn.text}${detail}`);
535
+ }
536
+ }
537
+ }
538
+
539
+ function assertCanWriteOutput(outPath, resolvedEntries) {
540
+ if (!resolvedEntries) return;
541
+ if (resolvedEntries.has(safeRealpath(outPath))) {
542
+ throw new Error(
543
+ `zntc: output file '${outPath}' would overwrite input file (use --allow-overwrite to permit)`,
544
+ );
545
+ }
546
+ }
547
+
548
+ function writeOutputFiles(outputFiles, outfile, outdir, entryPoints, allowOverwrite) {
549
+ const resolvedEntries = allowOverwrite ? null : new Set(entryPoints.map(safeRealpath));
550
+ if (outfile) {
551
+ const outPath = resolve(outfile);
552
+ const outDirAbs = dirname(outPath);
553
+ assertCanWriteOutput(outPath, resolvedEntries);
554
+ mkdirSync(outDirAbs, { recursive: true });
555
+ // 첫 entry 는 main bundle/transpile output → outfile. 나머지는 path 로 분기 —
556
+ // `.map` 으로 끝나면 sourcemap (`outfile.map`), 아니면 asset (CSS bundle / worker
557
+ // chunk 등) 으로 outfile 의 dirname 안에 basename. 옛 코드가 `[1]` slot 을 무조건
558
+ // sourcemap 으로 가정해 asset 이 함께 emit 될 때 asset 이 `.map` 자리에 잘못
559
+ // write 되던 회귀 해소.
560
+ // file.contents 는 Uint8Array — Node fs 가 그대로 syscall 로 전달 (utf-8 encode
561
+ // 비용 없음). transpile path 가 만든 임시 outputFiles 도 `{ path, contents }` 형식
562
+ // (자세히: line 912 참고).
563
+ writeFileSync(outPath, outputFiles[0].contents);
564
+ for (let i = 1; i < outputFiles.length; i++) {
565
+ const file = outputFiles[i];
566
+ if (file.path.endsWith('.map')) {
567
+ writeFileSync(outPath + '.map', file.contents);
568
+ } else {
569
+ const assetPath = join(outDirAbs, basename(file.path));
570
+ assertCanWriteOutput(assetPath, resolvedEntries);
571
+ writeFileSync(assetPath, file.contents);
572
+ }
573
+ }
574
+ } else if (outdir) {
575
+ const outDirAbs = resolve(outdir);
576
+ mkdirSync(outDirAbs, { recursive: true });
577
+ for (const file of outputFiles) {
578
+ const outPath = join(outDirAbs, basename(file.path));
579
+ assertCanWriteOutput(outPath, resolvedEntries);
580
+ writeFileSync(outPath, file.contents);
581
+ }
582
+ }
583
+ }
584
+
585
+ function normalizeBase(base) {
586
+ if (!base) return '/';
587
+ if (base === '.') return '';
588
+ let out = base.startsWith('/') ? base : `/${base}`;
589
+ if (!out.endsWith('/')) out += '/';
590
+ return out;
591
+ }
592
+
593
+ function isBrowserLikePlatform(platform) {
594
+ return platform === undefined || platform === 'browser' || platform === 'react-native';
595
+ }
596
+
597
+ function injectDefaultNodeEnvDefine(opts) {
598
+ if (opts.define['process.env.NODE_ENV'] !== undefined) return;
599
+
600
+ const appBrowserCommand = opts.appCommand === 'dev' || opts.appCommand === 'build';
601
+ const browserBundle = opts.bundle && (isBrowserLikePlatform(opts.platform) || opts.minifySyntax);
602
+ if (!appBrowserCommand && !browserBundle) return;
603
+
604
+ const isDev = opts.appCommand === 'dev' || opts.serve || opts.watch;
605
+ opts.define['process.env.NODE_ENV'] = isDev ? '"development"' : '"production"';
606
+ }
607
+
608
+ function normalizeServerHost(host) {
609
+ if (host === true) return '0.0.0.0';
610
+ if (typeof host === 'string' && host.length > 0) return host;
611
+ return undefined;
612
+ }
613
+
614
+ function mergeServerConfigIntoOpts(opts, config) {
615
+ const server = config?.server;
616
+ if (!server || typeof server !== 'object') return;
617
+
618
+ if (opts.port === undefined && Number.isInteger(server.port)) {
619
+ opts.port = server.port;
620
+ }
621
+ if (opts.host === undefined) {
622
+ const host = normalizeServerHost(server.host);
623
+ if (host !== undefined) opts.host = host;
624
+ }
625
+ if (opts.strictPort === false && server.strictPort === true) {
626
+ opts.strictPort = true;
627
+ }
628
+ if (opts.open === false && server.open === true) {
629
+ opts.open = true;
630
+ }
631
+ }
632
+
633
+ function applyServerDefaults(opts) {
634
+ if (opts.port === undefined) opts.port = 12300;
635
+ if (opts.host === undefined) opts.host = 'localhost';
636
+ }
637
+
638
+ function isPortInUseError(err) {
639
+ const code = err?.code;
640
+ const message = String(err?.message ?? err);
641
+ return code === 'EADDRINUSE' || /address already in use|port .*in use/i.test(message);
642
+ }
643
+
644
+ async function resolveServePort(opts, start) {
645
+ let port = opts.port;
646
+ for (;;) {
647
+ try {
648
+ const server = await start(port);
649
+ opts.port = port;
650
+ return server;
651
+ } catch (err) {
652
+ if (opts.strictPort || !isPortInUseError(err)) throw err;
653
+ port += 1;
654
+ }
655
+ }
656
+ }
657
+
658
+ function getAutoConfigSearchDir(opts) {
659
+ if (opts.appCommand === 'dev' || opts.appCommand === 'build') {
660
+ return resolve(opts.appRoot ?? '.');
661
+ }
662
+ return process.cwd();
663
+ }
664
+
665
+ async function runAppBuild(opts, config, configEnv, _dotenvVars) {
666
+ if (config?.plugins?.length || opts.pluginPaths.length > 0) {
667
+ throw new Error(
668
+ 'zntc build app mode does not support JS plugins yet; use --bundle for plugin builds',
669
+ );
670
+ }
671
+ const web = await loadWebModule();
672
+ const root = resolve(opts.appRoot ?? '.');
673
+ const outdir = resolve(opts.outdir ?? join(root, 'dist'));
674
+ if (opts.clean) rmSync(outdir, { recursive: true, force: true });
675
+ let pipelineRoot = null;
676
+ try {
677
+ const pipeline = await web.prepareAppCssPipelineRoot(
678
+ root,
679
+ outdir,
680
+ configEnv,
681
+ opts.logLevel,
682
+ 'build',
683
+ { fallbackRequire: requireFromCli, cliNodeModules },
684
+ );
685
+ pipelineRoot = pipeline?.tempRoot ?? null;
686
+ const result = buildAppSync({
687
+ root: pipelineRoot ?? root,
688
+ outdir,
689
+ entryHtml: opts.entryHtml ?? 'index.html',
690
+ publicDir: opts.publicDir === undefined ? 'public' : opts.publicDir,
691
+ base: normalizeBase(opts.base ?? opts.publicPath ?? '/'),
692
+ mode: configEnv.mode,
693
+ envDir: opts.envDir ? resolve(opts.envDir) : (pipelineRoot ?? root),
694
+ envPrefixes: opts.envPrefixes,
695
+ define: Object.keys(opts.define).length > 0 ? opts.define : undefined,
696
+ minify: opts.minify || opts.minifyWhitespace || opts.minifyIdentifiers || opts.minifySyntax,
697
+ sourcemap: opts.sourcemap,
698
+ splitting: opts.splitting || undefined,
699
+ jsx: opts.jsx,
700
+ jsxImportSource: opts.jsxImportSource,
701
+ jsxFactory: opts.jsxFactory,
702
+ jsxFragment: opts.jsxFragment,
703
+ compiler: config?.compiler,
704
+ });
705
+ const htmlEnv = loadEnv(
706
+ configEnv.mode,
707
+ opts.envDir ? resolve(opts.envDir) : (pipelineRoot ?? root),
708
+ ['ZNTC_'],
709
+ );
710
+ const { warnings: htmlWarnings } = web.applyHtmlEnvTokens(outdir, htmlEnv);
711
+ if (opts.logLevel !== 'silent') {
712
+ for (const w of htmlWarnings) console.error(`[html-env] ${w}`);
713
+ console.error(`[build] wrote ${result.outputCount ?? 0} files to ${outdir}`);
714
+ }
715
+ return result;
716
+ } finally {
717
+ if (pipelineRoot) web.cleanupPostcssTempRoot(pipelineRoot);
718
+ }
719
+ }
720
+
721
+ // HMR_MSG / APP_DEV_HMR_*_PATH / createHmrChannel 등은 @zntc/server 가 source
722
+ // of truth. dev/preview/build app 모드의 lazy load 한 web 모듈을 통해 접근
723
+ // (web 의 dist 에 server 가 inline). #2539 PR #6a cut over.
724
+
725
+ async function runAppDev(opts, config, configEnv, _dotenvVars) {
726
+ printZntcBanner({
727
+ flavor: 'web',
728
+ version: getCliVersion(),
729
+ silent: opts.logLevel === 'silent',
730
+ });
731
+ const web = await loadWebModule();
732
+ const root = resolve(opts.appRoot ?? '.');
733
+ opts.outdir = opts.outdir || join(root, '.zntc-dev');
734
+ const appDev = web.createAppDevController(opts, root, configEnv, {
735
+ fallbackRequire: requireFromCli,
736
+ cliNodeModules,
737
+ });
738
+ const prepared = await appDev.prepare();
739
+
740
+ opts.entryPoints = [prepared.entryPath];
741
+ opts.serveDir = opts.outdir;
742
+
743
+ return runServe(opts, config, { appDev });
744
+ }
745
+
746
+ // app 모드 (dev/preview/build) 에서만 @zntc/web 을 lazy import. bundle/transpile/watch
747
+ // 모드에서는 web 패키지를 받지 않은 사용자도 동작해야 하기에 정적 import 회피.
748
+ let webModulePromise = null;
749
+ async function loadWebModule() {
750
+ if (webModulePromise) return webModulePromise;
751
+ webModulePromise = (async () => {
752
+ try {
753
+ return await import('@zntc/web');
754
+ } catch (err) {
755
+ const code = err?.code;
756
+ const message = String(err?.message ?? '');
757
+ if (code === 'ERR_MODULE_NOT_FOUND' || /Cannot find package "@zntc\/web"/.test(message)) {
758
+ console.error(
759
+ 'error: @zntc/web 패키지가 필요합니다 (zntc dev / preview / build app 모드).',
760
+ );
761
+ console.error('');
762
+ console.error('help: install with `bun add -D @zntc/web` 또는 `npm i -D @zntc/web`.');
763
+ process.exit(1);
764
+ }
765
+ throw err;
766
+ }
767
+ })();
768
+ return webModulePromise;
769
+ }
770
+
771
+ // RN 모드 (`zntc bundle --platform=react-native`) — @zntc/react-native 을 lazy
772
+ // import. transpile/bundle 일반 사용자는 web 처럼 영향 0 (#2540 PR #7).
773
+ let rnModulePromise = null;
774
+ async function loadRnModule() {
775
+ if (rnModulePromise) return rnModulePromise;
776
+ rnModulePromise = (async () => {
777
+ try {
778
+ return await import('@zntc/react-native');
779
+ } catch (err) {
780
+ const code = err?.code;
781
+ const message = String(err?.message ?? '');
782
+ if (
783
+ code === 'ERR_MODULE_NOT_FOUND' ||
784
+ /Cannot find package "@zntc\/react-native"/.test(message)
785
+ ) {
786
+ console.error(
787
+ 'error: @zntc/react-native 패키지가 필요합니다 (zntc bundle --platform=react-native).',
788
+ );
789
+ console.error('');
790
+ console.error(
791
+ 'help: install with `bun add -D @zntc/react-native` 또는 `npm i -D @zntc/react-native`.',
792
+ );
793
+ process.exit(1);
794
+ }
795
+ throw err;
796
+ }
797
+ })();
798
+ return rnModulePromise;
799
+ }
800
+
801
+ /**
802
+ * RN CLI 호환 미지원 영역 — 사용자가 `--asset-catalog-dest` 등을 지정해도 zntc 가
803
+ * 처리 못 함을 한 줄 stderr 로 알림. silent drop 방지 (#2605 audit P0).
804
+ *
805
+ * graph-bundler 전용 + production asset 영역은 후속 PR (P0#2 — asset 복사) 에서
806
+ * 흡수 예정. 현재는 경고만.
807
+ */
808
+ function warnRnBundleUnsupported(opts) {
809
+ // `--asset-catalog-dest` 는 iOS Images.xcassets — Xcode catalog 별도 작업이라
810
+ // 본 스코프 외. graph-bundler 전용 (`transform-option` / `resolver-option`) 도
811
+ // 미지원.
812
+ const map = {
813
+ assetCatalogDest: '--asset-catalog-dest',
814
+ unstableTransformProfile: '--unstable-transform-profile',
815
+ transformOptions: '--transform-option',
816
+ resolverOptions: '--resolver-option',
817
+ };
818
+ for (const [key, flag] of Object.entries(map)) {
819
+ const v = opts[key];
820
+ if (v === undefined) continue;
821
+ if (typeof v === 'object' && Object.keys(v).length === 0) continue;
822
+ process.stderr.write(`[zntc:rn-bundle] ${flag} (zntc 미지원, ignore)\n`);
823
+ }
824
+ }
825
+
826
+ async function runRnBundle(opts, config) {
827
+ const rn = await loadRnModule();
828
+ const cfg = config ?? {};
829
+ const projectRoot = resolve(opts.rnProjectRoot ?? cfg.projectRoot ?? cfg.root ?? '.');
830
+ const entry = opts.entryPoints?.[0];
831
+ if (!entry) {
832
+ console.error(
833
+ 'error: zntc bundle --platform=react-native 는 entry point 가 필요합니다 (예: `zntc bundle index.ts --platform=react-native`)',
834
+ );
835
+ process.exit(1);
836
+ }
837
+ warnRnBundleUnsupported(opts);
838
+ const rnPlatform = opts.rnPlatform === 'android' ? 'android' : 'ios';
839
+ applySingleFileDynamicImportDefault(opts);
840
+
841
+ // RN CLI 호환 — `--bundle-output X` 가 `--outfile X` 와 동일 의미. 양쪽 다 받되
842
+ // 명시 우선순위: --outfile > --bundle-output. 둘 다 미지정 시 in-memory.
843
+ const outfile = opts.outfile ?? opts.bundleOutput;
844
+
845
+ // `--sourcemap-output` 또는 `--source-map-url` 이 명시되면 sourcemap 자동 활성.
846
+ // bungae build.ts L38-79 와 동일 패턴 — caller-side write 로 path 처리.
847
+ const wantsSourcemap = Boolean(opts.sourcemap || opts.sourcemapOutput || opts.sourceMapUrl);
848
+
849
+ // outfile 명시 + 추가 path 옵션 (sourcemapOutput / sourceMapUrl / bundleEncoding /
850
+ // sourcemapSourcesRoot / sourcemapUseAbsolutePath) 있으면 caller-side 로 직접
851
+ // write — NAPI write:true 회피 후 sourcemap 후처리.
852
+ const callerWrite =
853
+ outfile &&
854
+ (opts.sourcemapOutput ||
855
+ opts.sourceMapUrl ||
856
+ opts.bundleEncoding ||
857
+ typeof opts.sourcemapSourcesRoot === 'string' ||
858
+ opts.sourcemapUseAbsolutePath === true);
859
+
860
+ const extra = buildRnBundleExtra(cfg, opts);
861
+ const result = await rn.bundleRn({
862
+ entry,
863
+ projectRoot,
864
+ rnPlatform,
865
+ dev: Boolean(opts.devMode),
866
+ sourcemap: wantsSourcemap,
867
+ minify:
868
+ opts.minify || opts.minifyWhitespace || opts.minifyIdentifiers || opts.minifySyntax || false,
869
+ dropConsole: opts.drop.includes('console'),
870
+ dropDebugger: opts.drop.includes('debugger'),
871
+ extra,
872
+ override: buildRnBundleOverride({
873
+ config: cfg,
874
+ opts,
875
+ override: outfile && !callerWrite ? { outfile, write: true } : undefined,
876
+ }),
877
+ });
878
+
879
+ printResultDiagnostics(result, opts.logLevel);
880
+
881
+ // caller-side write — bundle / sourcemap path 분리 + URL override 적용.
882
+ if (callerWrite && result.errors.length === 0 && result.outputFiles?.length) {
883
+ const bundlePath = resolve(outfile);
884
+ const mapPath = opts.sourcemapOutput ? resolve(opts.sourcemapOutput) : `${bundlePath}.map`;
885
+ const sourceMappingURL = opts.sourceMapUrl ?? basename(mapPath);
886
+ const encoding = opts.bundleEncoding ?? 'utf-8';
887
+ mkdirSync(dirname(bundlePath), { recursive: true });
888
+ let bundleCode = result.outputFiles[0].text;
889
+ // sourcemap 이 emit 됐으면 `//# sourceMappingURL=` 주석 append.
890
+ if (wantsSourcemap && result.outputFiles[1]) {
891
+ bundleCode = `${bundleCode}\n//# sourceMappingURL=${sourceMappingURL}`;
892
+ }
893
+ writeFileSync(bundlePath, bundleCode, encoding);
894
+ if (wantsSourcemap && result.outputFiles[1]) {
895
+ mkdirSync(dirname(mapPath), { recursive: true });
896
+ // ignoreList (DevTools) + path 옵션 한 패스. production bundle 의
897
+ // DevTools 디버깅 시 node_modules / zntc:runtime frame 자동 hide.
898
+ const mapJson = rn.postProcessSourceMap(result.outputFiles[1].text, {
899
+ sourceRoot: opts.sourcemapSourcesRoot,
900
+ useAbsolutePath: opts.sourcemapUseAbsolutePath === true,
901
+ projectRoot,
902
+ });
903
+ writeFileSync(mapPath, mapJson);
904
+ }
905
+ }
906
+
907
+ // production asset 복사 (`--assets-dest`) — dev=false + 명시 시. Metro 처럼
908
+ // bundle 에 등록된 AssetRegistry asset 만 복사한다. 미지정 시 skip (dev server 가 HTTP 서빙).
909
+ if (result.errors.length === 0 && !opts.devMode && opts.assetsDest) {
910
+ const assetsDestAbs = resolve(opts.assetsDest);
911
+ try {
912
+ const copied = await copyRnAssets({
913
+ assetsDest: assetsDestAbs,
914
+ rnPlatform,
915
+ assets: result.rnAssetMetadata ?? [],
916
+ });
917
+ if (opts.logLevel !== 'silent') {
918
+ console.error(`[bundle] copied ${copied} asset(s) to ${assetsDestAbs}`);
919
+ }
920
+ } catch (err) {
921
+ process.stderr.write(`[zntc:rn-bundle] asset copy 실패: ${err?.message ?? err}\n`);
922
+ throw err;
923
+ }
924
+ }
925
+
926
+ if (result.errors.length === 0 && opts.logLevel !== 'silent') {
927
+ console.error(`[bundle] react-native ${rnPlatform} ${outfile ?? '(in-memory)'}`);
928
+ }
929
+ return result;
930
+ }
931
+
932
+ /**
933
+ * `zntc dev --platform=react-native` (#2605) — @zntc/react-native 의 serveRn lazy
934
+ * import. cli-server-api / dev-middleware / RN runtime peer optional.
935
+ */
936
+ async function runRnDev(opts, config) {
937
+ const rn = await loadRnModule();
938
+ const input = buildRnDevServerInput(opts, config);
939
+ if (!input) {
940
+ console.error(
941
+ 'error: zntc dev --platform=react-native 는 entry point 가 필요합니다 (예: `zntc dev index.js --platform=react-native`)',
942
+ );
943
+ process.exit(1);
944
+ }
945
+ // banner / bundle log 모두 serveRn 내부가 출력 — version 만 주입.
946
+ const handle = await rn.serveRn(rn.buildRnDevServerOptions(input), {
947
+ silent: opts.logLevel === 'silent',
948
+ version: getCliVersion(),
949
+ });
950
+
951
+ // Graceful shutdown — SIGINT / SIGTERM 시 handle.stop().
952
+ const onSignal = async () => {
953
+ await handle.stop();
954
+ process.exit(0);
955
+ };
956
+ process.once('SIGINT', onSignal);
957
+ process.once('SIGTERM', onSignal);
958
+ }
959
+
960
+ async function runAppPreview(opts) {
961
+ opts.serveDir = resolve(opts.previewDir ?? opts.outdir ?? 'dist');
962
+ opts.outdir = undefined;
963
+ opts.bundle = false;
964
+ opts.watch = false;
965
+ return runServe(opts, null);
966
+ }
967
+
968
+ function normalizeSpaFallback(value) {
969
+ if (value === undefined || value === null || value === false || value === 'false') return null;
970
+ const raw = value === true ? 'index.html' : String(value);
971
+ return raw.startsWith('/') ? raw.slice(1) : raw;
972
+ }
973
+
974
+ function requestAcceptsHtml(accept) {
975
+ if (!accept) return true;
976
+ return accept.includes('text/html') || accept.includes('*/*');
977
+ }
978
+
979
+ function looksLikeAssetPath(pathname) {
980
+ return extname(pathname) !== '';
981
+ }
982
+
983
+ // ─── Transpile 모드 ───
984
+
985
+ async function runTranspile(opts) {
986
+ let source;
987
+ if (opts.stdin) {
988
+ // stdin 읽기
989
+ const chunks = [];
990
+ for await (const chunk of process.stdin) chunks.push(chunk);
991
+ source = Buffer.concat(chunks).toString();
992
+ } else {
993
+ source = readFileSync(resolve(opts.entryPoints[0]), 'utf8');
994
+ }
995
+
996
+ if (opts.tokenize) {
997
+ const filename = opts.stdin ? 'stdin.ts' : opts.entryPoints[0];
998
+ const tokens = tokenize(source, { filename });
999
+ process.stdout.write(formatTokenizeOutput(tokens, opts.tokenizeFormat));
1000
+ return;
1001
+ }
1002
+
1003
+ if (opts.profile.length > 0) {
1004
+ configureProfile(opts.profile, opts.profileLevel);
1005
+ }
1006
+
1007
+ const result = transpile(source, {
1008
+ filename: opts.stdin ? 'stdin.ts' : opts.entryPoints[0],
1009
+ sourcemap: opts.sourcemap,
1010
+ minify: opts.minify,
1011
+ minifyWhitespace: opts.minifyWhitespace,
1012
+ minifyIdentifiers: opts.minifyIdentifiers,
1013
+ minifySyntax: opts.minifySyntax,
1014
+ jsx: opts.jsx,
1015
+ jsxFactory: opts.jsxFactory,
1016
+ jsxFragment: opts.jsxFragment,
1017
+ jsxImportSource: opts.jsxImportSource,
1018
+ flow: opts.flow,
1019
+ jsxInJs: opts.jsxInJs,
1020
+ experimentalDecorators: opts.experimentalDecorators,
1021
+ emitDecoratorMetadata: opts.emitDecoratorMetadata,
1022
+ useDefineForClassFields: opts.useDefineForClassFields,
1023
+ verbatimModuleSyntax: opts.verbatimModuleSyntax,
1024
+ tsconfigPath: opts.project,
1025
+ asciiOnly: opts.asciiOnly,
1026
+ charsetUtf8: opts.charsetUtf8,
1027
+ quotes: opts.quotes,
1028
+ format: opts.format,
1029
+ platform: opts.platform,
1030
+ dropConsole: opts.drop.includes('console'),
1031
+ dropDebugger: opts.drop.includes('debugger'),
1032
+ target: opts.target,
1033
+ browserslist: opts.browserslist,
1034
+ tsconfigRaw: opts.tsconfigRaw,
1035
+ stopAfter: opts.stopAfter,
1036
+ });
1037
+
1038
+ if (opts.outfile || opts.outdir) {
1039
+ const name = basename(opts.entryPoints[0]).replace(/\.[^.]+$/, '.js');
1040
+ // transpile result.code / result.map 은 string — writeOutputFiles 는 contents
1041
+ // (Uint8Array) 를 받으므로 `Buffer.from` 으로 한 번 변환. 같은 메모리 backing 의
1042
+ // utf-8 byte view 라 추가 copy 없음.
1043
+ const outputFiles = [{ path: name, contents: Buffer.from(result.code) }];
1044
+ if (opts.outfile && result.map) {
1045
+ outputFiles.push({ path: name + '.map', contents: Buffer.from(result.map) });
1046
+ }
1047
+ writeOutputFiles(outputFiles, opts.outfile, opts.outdir, opts.entryPoints, opts.allowOverwrite);
1048
+ } else {
1049
+ process.stdout.write(result.code);
1050
+ }
1051
+
1052
+ if (opts.profile.length > 0) {
1053
+ process.stderr.write(profileReport(opts.profileFormat ?? 'table'));
1054
+ }
1055
+ }
1056
+
1057
+ // ─── Bundle 모드 ───
1058
+
1059
+ /**
1060
+ * config 로드 — `--config <path>` 명시 시 그 경로, 아니면 cwd 자동 탐색.
1061
+ *
1062
+ * 함수형 config 는 CLI 모드/`--mode` 인자 기반의 `ConfigEnv` 로 호출된다:
1063
+ * - command: serve→"serve", watch→"watch", 그 외→"bundle"
1064
+ * - mode: `--mode <name>` 명시값 또는 command 기본 (serve/watch→"development", 그 외→"production")
1065
+ * - env: dotenv 파일 + process.env 머지. shell env 가 file 보다 우선 (CI 가 .env
1066
+ * 값을 override 가능 — Vite/dotenv 16+ 와 일치).
1067
+ *
1068
+ * 실패 시 `Error("failed to load config — ...")` 를 throw — main 의 try/catch 가 처리.
1069
+ */
1070
+ async function loadAutoConfig(opts) {
1071
+ // --no-config: 명시(--config)·자동 탐색 모두 우회. env(.env/define)는 config 와
1072
+ // 독립이므로 계속 로드해 `{ config: null }` 만 반환.
1073
+ const noConfig = opts.noConfig === true;
1074
+ const explicit = !noConfig && opts.configPath ? resolve(opts.configPath) : null;
1075
+ if (explicit && !existsSync(explicit)) {
1076
+ throw new Error(`failed to load config — file not found: ${explicit}`);
1077
+ }
1078
+ const configSearchDir = getAutoConfigSearchDir(opts);
1079
+ const configPath = noConfig ? null : (explicit ?? findConfigPath(configSearchDir));
1080
+
1081
+ const command = opts.serve ? 'serve' : opts.watch ? 'watch' : 'bundle';
1082
+ const mode = opts.mode ?? (command === 'bundle' ? 'production' : 'development');
1083
+
1084
+ // .env 파일 4단계 우선순위로 로드 (#2106). prefix 미지정 시 default `["VITE_", "ZNTC_"]`.
1085
+ const envDir = opts.envDir ? resolve(opts.envDir) : configSearchDir;
1086
+ const dotenvVars = loadEnv(mode, envDir, opts.envPrefixes);
1087
+
1088
+ // dotenv 키 중 shell env 에도 정의된 건 shell 값으로 override (CI/배포 시 .env
1089
+ // 수정 없이 override — Vite/dotenv 16+ 와 일치). dotenvVars 자체를 final source 로
1090
+ // 갱신하면 envToDefine 에 그대로 전달 가능 (별도 머지 불필요).
1091
+ for (const k of Object.keys(dotenvVars)) {
1092
+ const shellValue = process.env[k];
1093
+ if (shellValue !== undefined) dotenvVars[k] = shellValue;
1094
+ }
1095
+
1096
+ // dotenv 파일 부재 시 process.env spread 회피 (보통 100+ 키 복사 방지).
1097
+ const mergedEnv =
1098
+ Object.keys(dotenvVars).length === 0 ? process.env : { ...process.env, ...dotenvVars };
1099
+ const env = { command, mode, env: mergedEnv };
1100
+
1101
+ // mode-specific config 자동 탐색 + 머지 (#2110). `--config <path>` 명시 시
1102
+ // 그 파일이 단독 source — mode-specific 자동 탐색 안 함 (사용자 의도 존중).
1103
+ const modeConfigPath = noConfig || explicit ? null : findModeConfigPath(configSearchDir, mode);
1104
+
1105
+ if (!configPath && !modeConfigPath) return { config: null, env, dotenvVars };
1106
+
1107
+ try {
1108
+ const baseConfig = configPath ? await loadConfig(configPath, env) : {};
1109
+ const modeConfig = modeConfigPath ? await loadConfig(modeConfigPath, env) : null;
1110
+ const config = modeConfig ? mergeUserConfigs(baseConfig, modeConfig) : baseConfig;
1111
+ return { config, env, dotenvVars };
1112
+ } catch (err) {
1113
+ const reason = err instanceof Error ? err.message : String(err);
1114
+ throw new Error(`failed to load config — ${reason}`);
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * CLI > config 우선순위로 BuildOptions 머지.
1120
+ *
1121
+ * - scalar/string: CLI 가 undefined 면 config 사용
1122
+ * - boolean (default=false): CLI 가 false 면 config=true 만 적용 (`minify`, `sourcemap` 등)
1123
+ * - boolean (default=true): CLI 가 true 면 config=false 만 적용 (`sourcesContent`, `treeShaking` 등)
1124
+ * ※ CLI 가 명시적으로 default 값을 줬는지 (--no-minify 같은) 구분 못 하는 한계 존재.
1125
+ * 함수형 config (#2103) 에서 정밀한 우선순위 적용 예정.
1126
+ * - 배열: CLI 가 비어있으면 config 사용
1127
+ * - 객체 (define/alias/loader): shallow merge (config defaults + CLI override)
1128
+ *
1129
+ * 키 5그룹은 손-유지(머지 분류가 FLAG_REGISTRY kind/TS type 어디에도 기계적
1130
+ * 으로 없는 큐레이션 정책이라 순수 파생 불가 — 2회 실측 회귀로 확인). 대신
1131
+ * `zntc-cli-schema-sync.test.ts` 가 drift-guard: config-mergeable BuildOption
1132
+ * flag 가 여기 누락되면 CI 가 loud fail (이전 silent-무시 footgun 해소).
1133
+ */
1134
+ function mergeConfigIntoOpts(opts, config) {
1135
+ if (!config) return opts;
1136
+
1137
+ const SCALAR_KEYS = [
1138
+ 'format',
1139
+ 'platform',
1140
+ 'target',
1141
+ 'banner',
1142
+ 'footer',
1143
+ 'globalName',
1144
+ 'publicPath',
1145
+ 'entryNames',
1146
+ 'chunkNames',
1147
+ 'assetNames',
1148
+ 'jsx',
1149
+ 'jsxFactory',
1150
+ 'jsxFragment',
1151
+ 'jsxImportSource',
1152
+ 'quotes',
1153
+ 'preserveModulesRoot',
1154
+ 'legalComments',
1155
+ 'sourceRoot',
1156
+ 'sourcemapMode',
1157
+ 'jobs',
1158
+ 'logLevel',
1159
+ 'logLimit',
1160
+ 'lineLimit',
1161
+ 'minChunkSize',
1162
+ 'outputExports',
1163
+ 'outExtensionJs',
1164
+ 'metafile',
1165
+ 'spaFallback',
1166
+ 'outfile',
1167
+ 'outdir',
1168
+ 'outbase',
1169
+ 'browserslist',
1170
+ 'tsconfigRaw',
1171
+ 'intro',
1172
+ 'outro',
1173
+ 'stopAfter',
1174
+ 'profileLevel',
1175
+ 'profileFormat',
1176
+ 'runtimePolyfills',
1177
+ 'coreJs',
1178
+ 'tokenizeFormat',
1179
+ ];
1180
+ for (const key of SCALAR_KEYS) {
1181
+ if (opts[key] === undefined && config[key] !== undefined) {
1182
+ opts[key] = config[key];
1183
+ }
1184
+ }
1185
+
1186
+ // boolean default=false/undefined → config 가 true 면 적용. CLI 명시 false 를
1187
+ // 구분 못 하므로 함수형 config (#2103) 에서 정밀한 우선순위 적용 예정.
1188
+ const BOOL_KEYS = [
1189
+ 'minify',
1190
+ 'minifyWhitespace',
1191
+ 'minifyIdentifiers',
1192
+ 'minifySyntax',
1193
+ 'sourcemap',
1194
+ 'sourcemapDebugIds',
1195
+ 'splitting',
1196
+ 'flow',
1197
+ 'experimentalDecorators',
1198
+ 'emitDecoratorMetadata',
1199
+ 'keepNames',
1200
+ 'shimMissingExports',
1201
+ 'preserveSymlinks',
1202
+ 'resolveSymlinkSiblings',
1203
+ 'disableHierarchicalLookup',
1204
+ 'charsetUtf8',
1205
+ 'asciiOnly',
1206
+ 'jsxInJs',
1207
+ 'jsxDev',
1208
+ 'preserveModules',
1209
+ 'verbatimModuleSyntax',
1210
+ 'packagesExternal',
1211
+ 'allowOverwrite',
1212
+ 'ignoreAnnotations',
1213
+ 'jsxSideEffects',
1214
+ // drift-guard 가 검출한 silent-무시 버그 수정 (#2112 잔여): config 값이
1215
+ // 머지 안 되던 실 BuildOption bool.
1216
+ 'analyze',
1217
+ 'devMode',
1218
+ ];
1219
+ for (const key of BOOL_KEYS) {
1220
+ if ((opts[key] === false || opts[key] === undefined) && config[key] === true) {
1221
+ opts[key] = true;
1222
+ }
1223
+ }
1224
+ // default=true 옵션: CLI 가 default(true) 면 config=false 일 때만 false 로 내린다
1225
+ // (개별 키는 아래 배열 — 추가 시 여기만 갱신).
1226
+ for (const key of [
1227
+ 'sourcesContent',
1228
+ 'treeShaking',
1229
+ 'scopeHoist',
1230
+ 'emitDiskSourcemap',
1231
+ 'useDefineForClassFields',
1232
+ ]) {
1233
+ if (opts[key] === true && config[key] === false) {
1234
+ opts[key] = false;
1235
+ }
1236
+ }
1237
+
1238
+ // tristate bool: false 가 명시적 의미를 가져서 default 를 undefined 로 두는 키.
1239
+ // CLI 가 미지정(undefined)일 때만 config 값(true/false)을 채택한다 (CLI flag 우선).
1240
+ // inlineDynamicImports=false 의 single-file 보정은 applySingleFileDynamicImportDefault.
1241
+ for (const key of ['inlineDynamicImports']) {
1242
+ if (opts[key] === undefined && config[key] !== undefined) {
1243
+ opts[key] = config[key];
1244
+ }
1245
+ }
1246
+
1247
+ const ARRAY_KEYS = [
1248
+ 'entryPoints',
1249
+ 'external',
1250
+ 'inject',
1251
+ 'drop',
1252
+ 'dropLabels',
1253
+ 'pure',
1254
+ 'resolveExtensions',
1255
+ 'mainFields',
1256
+ 'conditions',
1257
+ 'nodePaths',
1258
+ 'profile',
1259
+ 'blockList',
1260
+ // drift-guard 검출 silent-무시 버그 수정 (#2112 잔여): 실 BuildOption array.
1261
+ 'globalIdentifiers',
1262
+ 'polyfills',
1263
+ 'runBeforeMain',
1264
+ 'watchFolders',
1265
+ 'watchInclude',
1266
+ 'watchExclude',
1267
+ ];
1268
+ for (const key of ARRAY_KEYS) {
1269
+ // opts[key] 미초기화([]가 아님) 가능 → 방어 (config 만 있으면 채택).
1270
+ if (
1271
+ Array.isArray(config[key]) &&
1272
+ config[key].length > 0 &&
1273
+ (!Array.isArray(opts[key]) || opts[key].length === 0)
1274
+ ) {
1275
+ opts[key] = [...config[key]];
1276
+ }
1277
+ }
1278
+
1279
+ for (const key of ['define', 'alias', 'loader', 'globals', 'fallback']) {
1280
+ if (config[key] && typeof config[key] === 'object') {
1281
+ opts[key] = { ...config[key], ...opts[key] };
1282
+ }
1283
+ }
1284
+ mergeServerConfigIntoOpts(opts, config);
1285
+
1286
+ return opts;
1287
+ }
1288
+
1289
+ function mergeCliRuntimeTargets(runtimePolyfills, runtimeTargetQueries) {
1290
+ if (!Array.isArray(runtimeTargetQueries) || runtimeTargetQueries.length === 0) {
1291
+ return runtimePolyfills;
1292
+ }
1293
+ if (runtimePolyfills === undefined || runtimePolyfills === 'off') return runtimePolyfills;
1294
+ const targets = runtimeTargetQueries;
1295
+ if (typeof runtimePolyfills === 'string') return { mode: runtimePolyfills, targets };
1296
+ if (runtimePolyfills && typeof runtimePolyfills === 'object') {
1297
+ return { ...runtimePolyfills, targets };
1298
+ }
1299
+ return runtimePolyfills;
1300
+ }
1301
+
1302
+ async function runBundle(opts, config) {
1303
+ // config 자동 탐색 + 머지는 main() 에서 모든 모드에 대해 사전 적용된다.
1304
+ // 여기서는 plugins 만 추가로 합친다 (config 의 plugins → --plugin <path> 의 plugins).
1305
+ const plugins = [];
1306
+ if (config && Array.isArray(config.plugins)) {
1307
+ plugins.push(...config.plugins);
1308
+ }
1309
+ for (const pluginPath of opts.pluginPaths) {
1310
+ const absPath = resolve(pluginPath);
1311
+ // importAndResolveDefault 는 pathToFileURL 으로 Windows 경로를 안전하게 처리하고
1312
+ // ENOENT/객체 검증을 통일한다 (config-loader 와 공유).
1313
+ const cfg = await importAndResolveDefault(absPath);
1314
+ if (Array.isArray(cfg.plugins)) {
1315
+ plugins.push(...cfg.plugins);
1316
+ } else if (typeof cfg.setup === 'function') {
1317
+ plugins.push(cfg);
1318
+ }
1319
+ }
1320
+
1321
+ applySingleFileDynamicImportDefault(opts);
1322
+
1323
+ const buildOpts = {
1324
+ entryPoints: opts.entryPoints.map((e) => resolve(e)),
1325
+ format: opts.format,
1326
+ platform: opts.platform,
1327
+ target: opts.target,
1328
+ browserslist: opts.browserslist,
1329
+ external: opts.external,
1330
+ packagesExternal: opts.packagesExternal,
1331
+ // `--alias:K=V` 플래그 (webpack/rollup 스타일) — JS 옵션이 tsconfig paths 보다 우선 적용됨.
1332
+ alias: Object.keys(opts.alias).length > 0 ? opts.alias : undefined,
1333
+ define: Object.keys(opts.define).length > 0 ? opts.define : undefined,
1334
+ loader: Object.keys(opts.loader).length > 0 ? opts.loader : undefined,
1335
+ minify: opts.minify,
1336
+ minifyWhitespace: opts.minifyWhitespace,
1337
+ minifyIdentifiers: opts.minifyIdentifiers,
1338
+ minifySyntax: opts.minifySyntax,
1339
+ splitting: opts.splitting,
1340
+ sourcemap: opts.sourcemap,
1341
+ sourcemapMode: opts.sourcemapMode,
1342
+ sourcemapDebugIds: opts.sourcemapDebugIds,
1343
+ sourcesContent: opts.sourcesContent,
1344
+ sourceRoot: opts.sourceRoot,
1345
+ treeShaking: opts.treeShaking,
1346
+ scopeHoist: opts.scopeHoist,
1347
+ emitDiskSourcemap: opts.emitDiskSourcemap,
1348
+ fallback: normalizeFallback(opts.fallback),
1349
+ blockList: opts.blockList.length > 0 ? opts.blockList : undefined,
1350
+ minChunkSize: opts.minChunkSize,
1351
+ metafile: !!opts.metafile,
1352
+ keepNames: opts.keepNames,
1353
+ shimMissingExports: opts.shimMissingExports,
1354
+ preserveSymlinks: opts.preserveSymlinks,
1355
+ resolveSymlinkSiblings: opts.resolveSymlinkSiblings,
1356
+ disableHierarchicalLookup: opts.disableHierarchicalLookup,
1357
+ flow: opts.flow,
1358
+ jsxInJs: opts.jsxInJs,
1359
+ charsetUtf8: opts.charsetUtf8,
1360
+ asciiOnly: opts.asciiOnly,
1361
+ quotes: opts.quotes,
1362
+ drop: opts.drop.length > 0 ? opts.drop : undefined,
1363
+ dropLabels: opts.dropLabels.length > 0 ? opts.dropLabels : undefined,
1364
+ pure: opts.pure.length > 0 ? opts.pure : undefined,
1365
+ // bundle 모드도 transpile 과 동일하게 drop console/debugger 적용 (#2155).
1366
+ dropConsole: opts.drop.includes('console'),
1367
+ dropDebugger: opts.drop.includes('debugger'),
1368
+ useDefineForClassFields: opts.useDefineForClassFields,
1369
+ experimentalDecorators: opts.experimentalDecorators,
1370
+ emitDecoratorMetadata: opts.emitDecoratorMetadata,
1371
+ verbatimModuleSyntax: opts.verbatimModuleSyntax,
1372
+ preserveModules: opts.preserveModules,
1373
+ preserveModulesRoot: opts.preserveModulesRoot,
1374
+ inlineDynamicImports: opts.inlineDynamicImports,
1375
+ legalComments: opts.legalComments,
1376
+ logLevel: opts.logLevel,
1377
+ logLimit: opts.logLimit,
1378
+ lineLimit: opts.lineLimit,
1379
+ allowOverwrite: opts.allowOverwrite,
1380
+ outputExports: opts.outputExports,
1381
+ resolveExtensions: opts.resolveExtensions.length > 0 ? opts.resolveExtensions : undefined,
1382
+ mainFields: opts.mainFields.length > 0 ? opts.mainFields : undefined,
1383
+ conditions: opts.conditions.length > 0 ? opts.conditions : undefined,
1384
+ nodePaths: opts.nodePaths.length > 0 ? opts.nodePaths : undefined,
1385
+ profile: opts.profile.length > 0 ? opts.profile : undefined,
1386
+ profileLevel: opts.profileLevel,
1387
+ profileFormat: opts.profileFormat,
1388
+ runtimePolyfills: mergeCliRuntimeTargets(opts.runtimePolyfills, opts.runtimeTargetQueries),
1389
+ coreJs: opts.coreJs,
1390
+ ignoreAnnotations: opts.ignoreAnnotations,
1391
+ jsxSideEffects: opts.jsxSideEffects,
1392
+ // NAPI 가 tsconfig paths / baseUrl 을 alias 로 변환해 resolver 에 주입하도록 전달.
1393
+ tsconfigPath: opts.project,
1394
+ tsconfigRaw: opts.tsconfigRaw,
1395
+ banner: opts.banner,
1396
+ footer: opts.footer,
1397
+ intro: opts.intro,
1398
+ outro: opts.outro,
1399
+ globalName: opts.globalName,
1400
+ globals: Object.keys(opts.globals).length > 0 ? opts.globals : undefined,
1401
+ publicPath: opts.publicPath,
1402
+ entryNames: opts.entryNames,
1403
+ chunkNames: opts.chunkNames,
1404
+ assetNames: opts.assetNames,
1405
+ jsx: opts.jsx,
1406
+ jsxDev: opts.jsxDev,
1407
+ jsxFactory: opts.jsxFactory,
1408
+ jsxFragment: opts.jsxFragment,
1409
+ jsxImportSource: opts.jsxImportSource,
1410
+ inject: opts.inject.map((p) => resolve(p)),
1411
+ devMode: opts.devMode,
1412
+ globalIdentifiers: opts.globalIdentifiers,
1413
+ // --polyfill / --run-before-main / --watch-folder 는 경로 → abs 변환 (--inject 와 동일).
1414
+ // --watch-include / --watch-exclude 는 루트 기준 glob 이므로 변환 안 함.
1415
+ polyfills: opts.polyfills?.length ? opts.polyfills.map((p) => resolve(p)) : undefined,
1416
+ runBeforeMain: opts.runBeforeMain?.length
1417
+ ? opts.runBeforeMain.map((p) => resolve(p))
1418
+ : undefined,
1419
+ watchFolders: opts.watchFolders?.length ? opts.watchFolders.map((p) => resolve(p)) : undefined,
1420
+ watchInclude: opts.watchInclude?.length ? opts.watchInclude : undefined,
1421
+ watchExclude: opts.watchExclude?.length ? opts.watchExclude : undefined,
1422
+ jobs: opts.jobs,
1423
+ outbase: opts.outbase,
1424
+ plugins: plugins.length > 0 ? plugins : undefined,
1425
+ // compiler.styledComponents / compiler.emotion 도 bundle 모드에서 forward.
1426
+ // 누락 시 `zntc.config.json` 의 `compiler` 설정이 silently drop 돼 1st-party transform
1427
+ // (autoLabel 등) 이 활성화 안 됨.
1428
+ compiler: config?.compiler,
1429
+ // PR-plumb (#3318): zntc.config 의 `mf`(Module Federation) 를 NAPI 로
1430
+ // forward. 누락 시 `mf` 가 silently drop → 발행 패키지에서 MF 미동작
1431
+ // (native CLI 만 zntc.config.json mf 를 직접 읽어 동작했던 갭).
1432
+ mf: config?.mf,
1433
+ };
1434
+
1435
+ const result = plugins.length > 0 ? await build(buildOpts) : buildSync(buildOpts);
1436
+
1437
+ printResultDiagnostics(result, opts.logLevel);
1438
+
1439
+ // 출력
1440
+ if (opts.outfile || opts.outdir) {
1441
+ if (opts.clean && opts.outdir) {
1442
+ rmSync(resolve(opts.outdir), { recursive: true, force: true });
1443
+ }
1444
+ writeOutputFiles(
1445
+ result.outputFiles,
1446
+ opts.outfile,
1447
+ opts.outdir,
1448
+ opts.entryPoints,
1449
+ opts.allowOverwrite,
1450
+ );
1451
+ } else {
1452
+ // stdout
1453
+ if (result.outputFiles.length > 0) {
1454
+ process.stdout.write(result.outputFiles[0].text);
1455
+ }
1456
+ }
1457
+
1458
+ // metafile
1459
+ if (opts.metafile && result.metafile) {
1460
+ if (opts.analyze) {
1461
+ console.error(result.metafile);
1462
+ } else {
1463
+ writeFileSync(resolve(opts.metafile), result.metafile);
1464
+ }
1465
+ }
1466
+
1467
+ if (opts.profile.length > 0) {
1468
+ process.stderr.write(profileReport(opts.profileFormat ?? 'table'));
1469
+ }
1470
+
1471
+ return result;
1472
+ }
1473
+
1474
+ function applySingleFileDynamicImportDefault(opts) {
1475
+ if (opts.splitting || opts.preserveModules) return;
1476
+ if (opts.inlineDynamicImports === false) {
1477
+ throw new Error(
1478
+ 'inlineDynamicImports=false requires splitting or preserveModules in bundle mode',
1479
+ );
1480
+ }
1481
+ // Zig CLI 와 동일한 기본값: 단일 파일 번들은 dynamic import target 을 같은 파일에
1482
+ // 인라인해야 Hermes/Node 가 외부 chunk 없는 native import() 를 실행하지 않는다.
1483
+ opts.inlineDynamicImports = true;
1484
+ }
1485
+
1486
+ // ─── Watch 모드 ───
1487
+
1488
+ async function runWatch(opts, config) {
1489
+ const { watch } = await import('node:fs');
1490
+
1491
+ let building = false;
1492
+ let pendingRebuild = false;
1493
+ let debounceTimer = null;
1494
+
1495
+ async function rebuild() {
1496
+ if (building) {
1497
+ pendingRebuild = true;
1498
+ return;
1499
+ }
1500
+ building = true;
1501
+
1502
+ try {
1503
+ const start = performance.now();
1504
+ const result = await runBundle(opts, config);
1505
+ const elapsed = Math.round(performance.now() - start);
1506
+ const files = result.outputFiles?.length ?? 0;
1507
+
1508
+ if (opts.watchJson) {
1509
+ const event =
1510
+ result.errors.length > 0
1511
+ ? { type: 'rebuild', success: false, error: result.errors[0]?.text }
1512
+ : { type: 'rebuild', success: true, files, ms: elapsed };
1513
+ console.log(JSON.stringify(event));
1514
+ } else if (opts.logLevel !== 'silent') {
1515
+ if (result.errors.length === 0) {
1516
+ console.error(`[watch] rebuilt in ${elapsed}ms`);
1517
+ }
1518
+ }
1519
+ } catch (err) {
1520
+ if (opts.watchJson) {
1521
+ console.log(JSON.stringify({ type: 'rebuild', success: false, error: String(err) }));
1522
+ } else if (opts.logLevel !== 'silent') {
1523
+ console.error(`[watch] error: ${err}`);
1524
+ }
1525
+ } finally {
1526
+ building = false;
1527
+ if (pendingRebuild) {
1528
+ pendingRebuild = false;
1529
+ rebuild();
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ // 초기 빌드
1535
+ await rebuild();
1536
+
1537
+ // 파일 감시
1538
+ const watchDirs = new Set();
1539
+ for (const entry of opts.entryPoints) {
1540
+ watchDirs.add(safeRealpath(dirname(resolve(entry))));
1541
+ }
1542
+ // config/.env 파일 변경 감지를 위해 cwd / envDir / config 디렉토리 추가.
1543
+ const restartTriggers = computeRestartTriggers(opts);
1544
+ for (const dir of restartTriggers.dirs) watchDirs.add(safeRealpath(dir));
1545
+
1546
+ for (const dir of watchDirs) {
1547
+ const watcher = watch(dir, { recursive: true }, (_event, filename) => {
1548
+ if (!filename) return;
1549
+ // node_modules, .git, 출력 디렉토리 무시
1550
+ if (filename.includes('node_modules') || filename.includes('.git')) return;
1551
+ if (opts.outdir && filename.startsWith(basename(resolve(opts.outdir)))) return;
1552
+
1553
+ if (restartTriggers.matches(filename)) {
1554
+ emitRestart(opts, 'config 또는 .env 파일 변경 감지');
1555
+ return;
1556
+ }
1557
+
1558
+ clearTimeout(debounceTimer);
1559
+ debounceTimer = setTimeout(rebuild, opts.watchDelay);
1560
+ });
1561
+ attachWatcherErrorHandler(watcher, dir, opts.logLevel);
1562
+ }
1563
+
1564
+ if (opts.watchJson) {
1565
+ console.log(JSON.stringify({ type: 'ready' }));
1566
+ } else if (opts.logLevel !== 'silent') {
1567
+ console.error('[watch] watching for changes...');
1568
+ }
1569
+ }
1570
+
1571
+ /**
1572
+ * watch/serve 모드에서 config 또는 .env 파일이 변경되면 in-process reload 가
1573
+ * 까다롭다 (.ts config 의 dynamic import 캐시, mergeConfigIntoOpts 의 1회성 mutation 등).
1574
+ * Vite 식 spawn-self 패턴으로 깔끔히 재시작 — 동일 argv 로 자식 프로세스 시작 후 종료.
1575
+ */
1576
+ function computeRestartTriggers(opts) {
1577
+ const dirs = new Set();
1578
+ const configSearchDir = getAutoConfigSearchDir(opts);
1579
+ const envDir = opts.envDir ? resolve(opts.envDir) : configSearchDir;
1580
+ dirs.add(envDir);
1581
+
1582
+ // --no-config 면 config 를 안 읽으므로 그 변경도 restart trigger 아님.
1583
+ const noConfig = opts.noConfig === true;
1584
+ const explicitConfig = !noConfig && opts.configPath ? resolve(opts.configPath) : null;
1585
+ const autoConfig = noConfig ? null : (explicitConfig ?? findConfigPath(configSearchDir));
1586
+ if (autoConfig) dirs.add(dirname(autoConfig));
1587
+
1588
+ const mode = opts.mode ?? (opts.serve || opts.watch ? 'development' : 'production');
1589
+ // mode-specific config (`zntc.config.${mode}.{ext}`) 변경도 restart trigger (#2110).
1590
+ const modeConfig = noConfig || explicitConfig ? null : findModeConfigPath(configSearchDir, mode);
1591
+ if (modeConfig) dirs.add(dirname(modeConfig));
1592
+
1593
+ const configBase = autoConfig ? basename(autoConfig) : null;
1594
+ const modeConfigBase = modeConfig ? basename(modeConfig) : null;
1595
+ const envBases = new Set(['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]);
1596
+
1597
+ return {
1598
+ dirs,
1599
+ matches(filename) {
1600
+ const base = basename(filename);
1601
+ if (modeConfigBase && base === modeConfigBase) return true;
1602
+ if (configBase && base === configBase) return true;
1603
+ if (envBases.has(base)) return true;
1604
+ return false;
1605
+ },
1606
+ };
1607
+ }
1608
+
1609
+ /**
1610
+ * fs.watch 의 'error' 이벤트는 unhandled 면 process crash 를 일으킨다. macOS Node v24
1611
+ * 의 `recursive: true` 는 빈 디렉토리에서도 즉시 EMFILE 'error' 를 던질 수 있어
1612
+ * (kqueue 기반 한계), watch 가 죽는 건 허용하되 dev server 자체는 살아있도록 한다
1613
+ * — fail-soft. 첫 error 후 watcher 를 닫으므로 `once` 로 충분.
1614
+ */
1615
+ function attachWatcherErrorHandler(watcher, dir, logLevel) {
1616
+ watcher.once('error', (err) => {
1617
+ if (logLevel !== 'silent') {
1618
+ if (err && (err.code === 'EMFILE' || err.code === 'ENOSPC')) {
1619
+ console.error(
1620
+ `[watch] ${dir} 파일 감시 비활성화 (${err.code}): 변경 시 재빌드가 동작하지 않습니다. ` +
1621
+ `open-file 한도를 늘리거나 큰 하위 트리를 제거하세요.`,
1622
+ );
1623
+ } else {
1624
+ console.error(`[watch] ${dir} 감시 오류: ${err?.message ?? err}`);
1625
+ }
1626
+ }
1627
+ try {
1628
+ watcher.close();
1629
+ } catch {}
1630
+ });
1631
+ }
1632
+
1633
+ function emitRestart(opts, reason) {
1634
+ return emitRestartAfter(opts, reason, null);
1635
+ }
1636
+
1637
+ async function emitRestartAfter(opts, reason, beforeSpawn) {
1638
+ if (opts.watchJson) {
1639
+ console.log(JSON.stringify({ type: 'restart', reason }));
1640
+ } else if (opts.logLevel !== 'silent') {
1641
+ console.error(`[watch] ${reason} — restarting CLI...`);
1642
+ }
1643
+ if (beforeSpawn) await beforeSpawn();
1644
+ // 자식 프로세스 spawn 후 종료 — 새 프로세스가 fresh config/env 로 시작.
1645
+ // stdio inherit 으로 부모의 출력 스트림을 그대로 이어받는다.
1646
+ const { spawn } = await import('node:child_process');
1647
+ const child = spawn(process.argv[0], process.argv.slice(1), {
1648
+ stdio: 'inherit',
1649
+ env: process.env,
1650
+ });
1651
+ child.on('exit', (code) => process.exit(code ?? 0));
1652
+ child.on('error', (err) => {
1653
+ console.error(`[watch] restart failed: ${err}`);
1654
+ process.exit(1);
1655
+ });
1656
+ }
1657
+
1658
+ // ─── Serve 모드 ───
1659
+
1660
+ async function runServe(opts, config, { appDev = null } = {}) {
1661
+ const isBun = typeof globalThis.Bun !== 'undefined';
1662
+ // appDev 모드에서만 web 모듈 (HMR_MSG / APP_DEV_HMR_*_PATH / createHmrChannel /
1663
+ // APP_DEV_HMR_CLIENT) 이 필요. handleRequest / watch drain 의 hot path 마다
1664
+ // `web.X.Y` property chain 을 재계산하지 않도록 진입 시점에 destructure 해
1665
+ // 캐시 (per-request 호출, #2539 PR #6a /simplify finding).
1666
+ const web = appDev ? await loadWebModule() : null;
1667
+ const hmr = web ? web.createHmrChannel() : null;
1668
+ const HMR_MSG = web?.HMR_MSG;
1669
+ const APP_DEV_HMR_CLIENT = web?.APP_DEV_HMR_CLIENT;
1670
+ const APP_DEV_HMR_CLIENT_PATH = web?.APP_DEV_HMR_CLIENT_PATH;
1671
+ const APP_DEV_HMR_WS_PATH = web?.APP_DEV_HMR_WS_PATH;
1672
+ let serverHandle = null;
1673
+ const mimeTypes = {
1674
+ '.html': 'text/html',
1675
+ '.js': 'application/javascript',
1676
+ '.mjs': 'application/javascript',
1677
+ '.css': 'text/css',
1678
+ '.json': 'application/json',
1679
+ '.png': 'image/png',
1680
+ '.jpg': 'image/jpeg',
1681
+ '.gif': 'image/gif',
1682
+ '.svg': 'image/svg+xml',
1683
+ '.ico': 'image/x-icon',
1684
+ '.woff': 'font/woff',
1685
+ '.woff2': 'font/woff2',
1686
+ '.map': 'application/json',
1687
+ };
1688
+
1689
+ // 번들 모드면 먼저 빌드
1690
+ if (opts.bundle && opts.entryPoints.length > 0) {
1691
+ opts.outdir = opts.outdir || join(opts.serveDir, '.zntc-serve');
1692
+ const bundleResult = await runBundle(opts, config);
1693
+ if (appDev) {
1694
+ if (bundleResult.errors.length > 0) {
1695
+ hmr?.reportError(bundleResult.errors);
1696
+ } else {
1697
+ hmr?.clearError();
1698
+ appDev.injectBundleCssLinks(bundleResult);
1699
+ await appDev.afterBundle();
1700
+ }
1701
+ }
1702
+
1703
+ // watch도 같이
1704
+ if (!opts.watch) {
1705
+ opts.watch = true;
1706
+ }
1707
+ }
1708
+
1709
+ const serveDir = resolve(opts.outdir || opts.serveDir);
1710
+ const base = normalizeBase(opts.base ?? '/');
1711
+
1712
+ function handleRequest(reqUrl, accept = '') {
1713
+ let pathname = new URL(reqUrl, 'http://localhost').pathname;
1714
+ if (appDev && pathname === APP_DEV_HMR_CLIENT_PATH) {
1715
+ return {
1716
+ status: 200,
1717
+ body: APP_DEV_HMR_CLIENT,
1718
+ type: 'application/javascript',
1719
+ };
1720
+ }
1721
+ if (base && base !== '/' && pathname.startsWith(base)) {
1722
+ pathname = '/' + pathname.slice(base.length);
1723
+ }
1724
+ if (pathname === '/') pathname = '/index.html';
1725
+
1726
+ let filePath = join(serveDir, pathname);
1727
+ if (!existsSync(filePath)) {
1728
+ const fallback = normalizeSpaFallback(opts.spaFallback);
1729
+ if (!fallback || !requestAcceptsHtml(accept) || looksLikeAssetPath(pathname)) {
1730
+ return { status: 404, body: 'Not Found', type: 'text/plain' };
1731
+ }
1732
+ const fallbackPath = resolve(serveDir, fallback);
1733
+ const insideServeDir =
1734
+ fallbackPath === serveDir || fallbackPath.startsWith(`${serveDir}${sep}`);
1735
+ if (!insideServeDir || !existsSync(fallbackPath)) {
1736
+ return { status: 404, body: 'Not Found', type: 'text/plain' };
1737
+ }
1738
+ filePath = fallbackPath;
1739
+ }
1740
+
1741
+ const ext = extname(filePath);
1742
+ const type = mimeTypes[ext] || 'application/octet-stream';
1743
+ const body = readFileSync(filePath);
1744
+ return { status: 200, body, type };
1745
+ }
1746
+
1747
+ const useTls = opts.certfile && opts.keyfile;
1748
+
1749
+ if (isBun) {
1750
+ // Bun.serve
1751
+ const serveOpts = {
1752
+ port: opts.port,
1753
+ hostname: opts.host,
1754
+ fetch(req, server) {
1755
+ const url = new URL(req.url);
1756
+ // /__hmr WebSocket upgrade — Bun-native API 사용 (Node 분기는 server.on('upgrade')).
1757
+ if (hmr && url.pathname === APP_DEV_HMR_WS_PATH) {
1758
+ if (server.upgrade(req)) return undefined;
1759
+ return new Response('Upgrade required', { status: 426 });
1760
+ }
1761
+ // 프록시 처리
1762
+ for (const [prefix, target] of Object.entries(opts.proxy)) {
1763
+ if (url.pathname.startsWith(prefix)) {
1764
+ return fetch(target + url.pathname.slice(prefix.length) + url.search);
1765
+ }
1766
+ }
1767
+
1768
+ const { status, body, type } = handleRequest(req.url, req.headers.get('accept') ?? '');
1769
+ return new Response(body, {
1770
+ status,
1771
+ headers: {
1772
+ 'Content-Type': type,
1773
+ 'Access-Control-Allow-Origin': '*',
1774
+ },
1775
+ });
1776
+ },
1777
+ };
1778
+ if (hmr) {
1779
+ serveOpts.websocket = {
1780
+ open(ws) {
1781
+ hmr.addBunClient(ws);
1782
+ },
1783
+ close(ws) {
1784
+ hmr.removeBunClient(ws);
1785
+ },
1786
+ message() {},
1787
+ };
1788
+ }
1789
+ if (useTls) {
1790
+ serveOpts.tls = {
1791
+ cert: globalThis.Bun.file(opts.certfile),
1792
+ key: globalThis.Bun.file(opts.keyfile),
1793
+ };
1794
+ }
1795
+ serverHandle = await resolveServePort(opts, (port) => {
1796
+ serveOpts.port = port;
1797
+ return globalThis.Bun.serve(serveOpts);
1798
+ });
1799
+ } else {
1800
+ // Node.js http/https
1801
+ const handler = async (req, res) => {
1802
+ // 프록시 처리
1803
+ const url = new URL(req.url, `${useTls ? 'https' : 'http'}://${req.headers.host}`);
1804
+ for (const [prefix, target] of Object.entries(opts.proxy)) {
1805
+ if (url.pathname.startsWith(prefix)) {
1806
+ try {
1807
+ const proxyRes = await fetch(target + url.pathname.slice(prefix.length) + url.search, {
1808
+ method: req.method,
1809
+ headers: req.headers,
1810
+ });
1811
+ res.writeHead(proxyRes.status, Object.fromEntries(proxyRes.headers));
1812
+ const body = await proxyRes.arrayBuffer();
1813
+ res.end(Buffer.from(body));
1814
+ } catch {
1815
+ res.writeHead(502);
1816
+ res.end('Bad Gateway');
1817
+ }
1818
+ return;
1819
+ }
1820
+ }
1821
+
1822
+ const { status, body, type } = handleRequest(req.url, req.headers.accept ?? '');
1823
+ res.writeHead(status, {
1824
+ 'Content-Type': type,
1825
+ 'Access-Control-Allow-Origin': '*',
1826
+ });
1827
+ res.end(body);
1828
+ };
1829
+ const server = useTls
1830
+ ? createHttpsServer(
1831
+ { cert: readFileSync(opts.certfile), key: readFileSync(opts.keyfile) },
1832
+ handler,
1833
+ )
1834
+ : createServer(handler);
1835
+ if (hmr) {
1836
+ server.on('upgrade', (req, socket) => {
1837
+ const pathname = new URL(req.url, `${useTls ? 'https' : 'http'}://${req.headers.host}`)
1838
+ .pathname;
1839
+ if (pathname !== APP_DEV_HMR_WS_PATH) {
1840
+ socket.destroy();
1841
+ return;
1842
+ }
1843
+ hmr.accept(req, socket);
1844
+ });
1845
+ }
1846
+ serverHandle = await resolveServePort(
1847
+ opts,
1848
+ (port) =>
1849
+ new Promise((resolveListen, rejectListen) => {
1850
+ const onError = (err) => {
1851
+ server.off('listening', onListening);
1852
+ rejectListen(err);
1853
+ };
1854
+ const onListening = () => {
1855
+ server.off('error', onError);
1856
+ resolveListen(server);
1857
+ };
1858
+ server.once('error', onError);
1859
+ server.once('listening', onListening);
1860
+ server.listen(port, opts.host);
1861
+ }),
1862
+ );
1863
+ }
1864
+
1865
+ async function closeServerForRestart() {
1866
+ if (!serverHandle) return;
1867
+ if (typeof serverHandle.stop === 'function') {
1868
+ await serverHandle.stop();
1869
+ return;
1870
+ }
1871
+ if (typeof serverHandle.close === 'function') {
1872
+ await new Promise((resolveClose, rejectClose) => {
1873
+ serverHandle.close((err) => (err ? rejectClose(err) : resolveClose()));
1874
+ });
1875
+ }
1876
+ }
1877
+
1878
+ const protocol = useTls ? 'https' : 'http';
1879
+ if (opts.logLevel !== 'silent') {
1880
+ console.error(`[serve] ${protocol}://${opts.host}:${opts.port}`);
1881
+ }
1882
+
1883
+ // watch 시작 (번들 모드일 때)
1884
+ if (opts.watch && opts.bundle) {
1885
+ const { watch: fsWatch } = await import('node:fs');
1886
+ const outdirAbs = opts.outdir ? resolve(opts.outdir) : null;
1887
+ const outdirPrefix = outdirAbs ? `${outdirAbs}${sep}` : null;
1888
+ let debounceTimer = null;
1889
+ let rebuilding = false;
1890
+ const dirty = new Set();
1891
+
1892
+ async function rebuildAppDevCss(changedPath) {
1893
+ await appDev.afterBundle({ changedPath });
1894
+ hmr?.clearError();
1895
+ hmr?.broadcast({
1896
+ type: HMR_MSG.CssUpdate,
1897
+ href: appDev.hrefFor(changedPath),
1898
+ timestamp: Date.now(),
1899
+ });
1900
+ if (opts.logLevel !== 'silent') console.error('[serve] css updated');
1901
+ }
1902
+
1903
+ async function rebuildAppDevFull(dirtyPaths = null) {
1904
+ const prepared = await appDev.prepare(dirtyPaths);
1905
+ opts.entryPoints = [prepared.entryPath];
1906
+ const bundleResult = await runBundle(opts, config);
1907
+ if (bundleResult.errors.length > 0) {
1908
+ hmr?.reportError(bundleResult.errors);
1909
+ return;
1910
+ }
1911
+ hmr?.clearError();
1912
+ appDev.injectBundleCssLinks(bundleResult);
1913
+ await appDev.afterBundle();
1914
+ hmr?.broadcast({ type: HMR_MSG.FullReload, timestamp: Date.now() });
1915
+ if (opts.logLevel !== 'silent') console.error('[serve] rebuilt');
1916
+ }
1917
+
1918
+ async function drain() {
1919
+ if (rebuilding) return;
1920
+ rebuilding = true;
1921
+ try {
1922
+ while (dirty.size > 0) {
1923
+ const paths = Array.from(dirty);
1924
+ dirty.clear();
1925
+ if (!appDev) {
1926
+ await runBundle(opts, config);
1927
+ if (opts.logLevel !== 'silent') console.error('[serve] rebuilt');
1928
+ continue;
1929
+ }
1930
+ // 변경된 path 들이 모두 CSS-only 면 incremental 처리, 그 외엔 full reload.
1931
+ const allCssOnly = paths.every(
1932
+ (p) => appDev.isCssOnlyChange(p) || appDev.isPostcssConfig(p),
1933
+ );
1934
+ if (allCssOnly) {
1935
+ // postcss config 변경이 섞이면 changedPath 미지정 → 전체 재처리.
1936
+ const cssChanges = paths.filter(
1937
+ (p) => p.endsWith('.css') && !appDev.isPostcssConfig(p),
1938
+ );
1939
+ // 단일 non-module `.scss/.sass` 변경 → 그 파일만 재컴파일하고 outdir mirror
1940
+ // 후 CssUpdate broadcast (BACKLOG #71). full pipeline rebuild + cpSync 회피.
1941
+ if (paths.length === 1 && appDev.isSassOnlyChange(paths[0])) {
1942
+ const href = await appDev.rebuildScssIncremental(paths[0]);
1943
+ if (href) {
1944
+ hmr?.clearError();
1945
+ hmr?.broadcast({ type: HMR_MSG.CssUpdate, href, timestamp: Date.now() });
1946
+ if (opts.logLevel !== 'silent') console.error('[serve] sass updated');
1947
+ } else {
1948
+ await rebuildAppDevFull();
1949
+ }
1950
+ } else if (cssChanges.length === 1 && paths.length === 1) {
1951
+ await rebuildAppDevCss(cssChanges[0]);
1952
+ } else {
1953
+ await appDev.afterBundle();
1954
+ hmr?.clearError();
1955
+ hmr?.broadcast({ type: HMR_MSG.CssUpdate, timestamp: Date.now() });
1956
+ if (opts.logLevel !== 'silent') console.error('[serve] css updated');
1957
+ }
1958
+ } else {
1959
+ await rebuildAppDevFull(paths);
1960
+ }
1961
+ }
1962
+ } catch (err) {
1963
+ console.error('[serve] rebuild error:', err);
1964
+ hmr?.reportThrownError(err);
1965
+ } finally {
1966
+ rebuilding = false;
1967
+ if (dirty.size > 0) drain();
1968
+ }
1969
+ }
1970
+
1971
+ const watchDirs = new Set();
1972
+ if (appDev) {
1973
+ watchDirs.add(appDev.root);
1974
+ } else {
1975
+ for (const entry of opts.entryPoints) {
1976
+ watchDirs.add(dirname(resolve(entry)));
1977
+ }
1978
+ }
1979
+ const restartTriggers = computeRestartTriggers(opts);
1980
+ for (const dir of restartTriggers.dirs) watchDirs.add(dir);
1981
+
1982
+ for (const dir of watchDirs) {
1983
+ const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
1984
+ if (!filename || filename.includes('node_modules') || filename.includes('.git')) return;
1985
+ const absPath = resolve(dir, filename);
1986
+ if (outdirAbs && (absPath === outdirAbs || absPath.startsWith(outdirPrefix))) return;
1987
+ if (restartTriggers.matches(filename)) {
1988
+ void emitRestartAfter(opts, 'config 또는 .env 파일 변경 감지', closeServerForRestart);
1989
+ return;
1990
+ }
1991
+ dirty.add(absPath);
1992
+ clearTimeout(debounceTimer);
1993
+ debounceTimer = setTimeout(drain, opts.watchDelay);
1994
+ });
1995
+ attachWatcherErrorHandler(watcher, dir, opts.logLevel);
1996
+ }
1997
+ }
1998
+
1999
+ // open browser
2000
+ if (opts.open) {
2001
+ const url = `${protocol}://${opts.host === '0.0.0.0' ? 'localhost' : opts.host}:${opts.port}`;
2002
+ const { exec } = await import('node:child_process');
2003
+ const cmd =
2004
+ process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
2005
+ exec(`${cmd} ${url}`);
2006
+ }
2007
+ }
2008
+
2009
+ // ─── Build dispatch ───
2010
+
2011
+ async function runTest262(opts) {
2012
+ const dir = opts.test262;
2013
+ if (!dir) throw new Error('--test262 requires a directory path');
2014
+ const { spawnSync } = await import('node:child_process');
2015
+ const result = spawnSync('zig', ['build', 'test262-run', '--', resolve(dir)], {
2016
+ cwd: resolve(dirname(fileURLToPath(import.meta.url)), '../../..'),
2017
+ stdio: 'inherit',
2018
+ });
2019
+ if (result.error) {
2020
+ throw new Error(`failed to run Test262 runner: ${result.error.message}`);
2021
+ }
2022
+ return { errors: result.status === 0 ? 0 : 1 };
2023
+ }
2024
+
2025
+ /**
2026
+ * 단일/워크스페이스 흐름 공통 dispatch — 모드별 (`runServe`/`runWatch`/`runBundle`/`runTranspile`)
2027
+ * 진입점 호출 + bundle 의 user error 카운트 반환. caller (main / runWorkspace) 가 exit 처리.
2028
+ *
2029
+ * 반환 형태가 다른 두 호출 사이트의 drift 를 차단 — 모드 분기/추가가 1곳에서 끝남.
2030
+ */
2031
+ async function dispatchBuild(opts, config, configEnv, dotenvVars) {
2032
+ if (opts.appCommand === 'build') {
2033
+ const result = await runAppBuild(opts, config, configEnv, dotenvVars);
2034
+ return { errors: result.errors.length };
2035
+ }
2036
+ if (opts.appCommand === 'dev') {
2037
+ if (opts.platform === 'react-native') {
2038
+ // #2605 — RN dev server 는 별도 lazy import. cli-server-api / dev-middleware
2039
+ // / RN runtime peer optional.
2040
+ await runRnDev(opts, config);
2041
+ return { errors: 0 };
2042
+ }
2043
+ await runAppDev(opts, config, configEnv, dotenvVars);
2044
+ return { errors: 0 };
2045
+ }
2046
+ if (opts.appCommand === 'preview') {
2047
+ await runAppPreview(opts);
2048
+ return { errors: 0 };
2049
+ }
2050
+ if (opts.serve) {
2051
+ printZntcBanner({
2052
+ flavor: 'web',
2053
+ version: getCliVersion(),
2054
+ silent: opts.logLevel === 'silent',
2055
+ });
2056
+ await runServe(opts, config);
2057
+ return { errors: 0 };
2058
+ }
2059
+ if (opts.watch) {
2060
+ await runWatch(opts, config);
2061
+ return { errors: 0 };
2062
+ }
2063
+ if (opts.bundle && opts.platform === 'react-native') {
2064
+ // #2540 PR #7 — RN platform 시 @zntc/react-native 의 preset 호출. lazy
2065
+ // import 라 web/transpile/bundle 일반 사용자 영향 0.
2066
+ const result = await runRnBundle(opts, config);
2067
+ return { errors: result.errors.length };
2068
+ }
2069
+ if (opts.bundle) {
2070
+ const result = await runBundle(opts, config);
2071
+ return { errors: result.errors.length };
2072
+ }
2073
+ await runTranspile(opts);
2074
+ return { errors: 0 };
2075
+ }
2076
+
2077
+ // ─── Workspace mode (#2111) ───
2078
+
2079
+ /**
2080
+ * 단일 워크스페이스 entry 를 위한 `subOpts` 생성. `opts` deep clone → entry/root config
2081
+ * 머지 → entry.cwd 기준 path 정규화.
2082
+ *
2083
+ * `structuredClone` 사용 — `JSON.parse(JSON.stringify(opts))` 는 미래에 함수/Date/undefined
2084
+ * 필드가 추가되면 silent drop 위험.
2085
+ *
2086
+ * `outdir`/`outfile` 보강은 historical 잔재 — parseArgs default 가 과거 `null` 이라
2087
+ * `mergeConfigIntoOpts` 의 `=== undefined` 머지 조건을 우회 못 했었음. default 가 `undefined`
2088
+ * 가 된 후로는 mergeConfigIntoOpts 만으로 충분하지만 `== null` 은 둘 다 매치하므로 안전망으로 유지.
2089
+ */
2090
+ function buildSubOpts(opts, w, merged) {
2091
+ const subOpts = structuredClone(opts);
2092
+ mergeConfigIntoOpts(subOpts, merged);
2093
+
2094
+ if (subOpts.outdir == null && merged.outdir) subOpts.outdir = merged.outdir;
2095
+ if (subOpts.outfile == null && merged.outfile) subOpts.outfile = merged.outfile;
2096
+
2097
+ subOpts.entryPoints = subOpts.entryPoints.map((p) => resolve(w.cwd, p));
2098
+ if (subOpts.outdir) subOpts.outdir = resolve(w.cwd, subOpts.outdir);
2099
+ if (subOpts.outfile) subOpts.outfile = resolve(w.cwd, subOpts.outfile);
2100
+
2101
+ return subOpts;
2102
+ }
2103
+
2104
+ /**
2105
+ * `zntc.workspace.{ts,...}` 가 발견되면 단일 build 대신 워크스페이스 fan-out 으로 전환.
2106
+ *
2107
+ * 흐름:
2108
+ * 1. workspace 파일 로드 → `identifyWorkspaceEntries` (config 로드 없는 식별 단계)
2109
+ * 2. `--workspace=<name>` 필터 즉시 적용 — 비싼 TS config 로드를 N-1 회 회피
2110
+ * 3. 필터 후 entries 의 config 를 `Promise.all` 로 병렬 로드
2111
+ * 4. root config (`zntc.config.*`) 가 같은 디렉토리에 있으면 모든 entry 가 상속
2112
+ * 5. 각 entry 마다: opts clone → entry config + root config 머지 → entry.cwd 기준 path 정규화 → build
2113
+ *
2114
+ * `serve`/`watch` 는 워크스페이스에서 의미가 모호 (어느 entry 를 watch?) — 다중 entry 시 reject.
2115
+ * `--workspace=<name>` 필터로 단일 entry 만 남기면 serve/watch 허용.
2116
+ */
2117
+ async function runWorkspace(opts, workspacePath) {
2118
+ // workspace 모드는 root/entry config 가 본질이라 --no-config 미적용. silent
2119
+ // 무시는 혼란을 주므로 1회 경고 (loadAutoConfig 경로와 달리 게이트하지 않음).
2120
+ if (opts.noConfig) {
2121
+ console.warn('zntc: --no-config is ignored in workspace mode (--workspace)');
2122
+ }
2123
+ const command = opts.serve ? 'serve' : opts.watch ? 'watch' : 'bundle';
2124
+ const mode = opts.mode ?? (command === 'bundle' ? 'production' : 'development');
2125
+ const env = { command, mode, env: process.env };
2126
+
2127
+ const rootDir = dirname(resolve(workspacePath));
2128
+ let entries;
2129
+ try {
2130
+ entries = await loadWorkspace(workspacePath, env);
2131
+ } catch (err) {
2132
+ const reason = err instanceof Error ? err.message : String(err);
2133
+ throw new Error(`failed to load workspace — ${reason}`);
2134
+ }
2135
+
2136
+ // 식별 단계 — config 로드 없이 cwd/name/source 만. 필터 후에만 실제 config 로드.
2137
+ const ids = identifyWorkspaceEntries(entries, rootDir);
2138
+ const filtered = filterWorkspaces(ids, opts.workspace);
2139
+
2140
+ // 필터링된 entry 의 config 만 병렬 로드. root config 도 함께 await.
2141
+ const rootConfigPath = findConfigPath(rootDir);
2142
+ const [rootConfig, ...entryConfigs] = await Promise.all([
2143
+ rootConfigPath ? loadConfig(rootConfigPath, env) : Promise.resolve(null),
2144
+ ...filtered.map((w) => loadIdentifiedConfig(w, env)),
2145
+ ]);
2146
+ const resolved = filtered.map((w, i) => ({
2147
+ name: w.name,
2148
+ cwd: w.cwd,
2149
+ source: w.source,
2150
+ config: entryConfigs[i],
2151
+ }));
2152
+
2153
+ if (resolved.length > 1 && (opts.serve || opts.watch)) {
2154
+ throw new Error(
2155
+ `workspace serve/watch requires --workspace=<name> filter (matched ${resolved.length} entries)`,
2156
+ );
2157
+ }
2158
+
2159
+ if (opts.logLevel !== 'silent') {
2160
+ const filterMsg = opts.workspace ? ` (filtered by name='${opts.workspace}')` : '';
2161
+ console.error(
2162
+ `@zntc/core: workspace ${workspacePath} → ${resolved.length} entr${
2163
+ resolved.length === 1 ? 'y' : 'ies'
2164
+ }${filterMsg}`,
2165
+ );
2166
+ }
2167
+
2168
+ let exitCode = 0;
2169
+ for (const w of resolved) {
2170
+ if (opts.logLevel !== 'silent') {
2171
+ console.error(`\n--- workspace: ${w.name} (cwd=${w.cwd}, source=${w.source}) ---`);
2172
+ }
2173
+ const merged = rootConfig ? mergeUserConfigs(rootConfig, w.config) : w.config;
2174
+
2175
+ if (opts.logLevel !== 'silent' && Object.keys(w.config).length > 0) {
2176
+ warnUnknownKeys(w.config, KNOWN_CONFIG_KEYS, { sourceLabel: `workspace[${w.name}]` });
2177
+ }
2178
+
2179
+ const subOpts = buildSubOpts(opts, w, merged);
2180
+
2181
+ if (subOpts.entryPoints.length === 0 && !subOpts.stdin && !subOpts.serve) {
2182
+ if (opts.logLevel !== 'silent') {
2183
+ console.error(`@zntc/core: workspace '${w.name}' has no entryPoints — skipping`);
2184
+ }
2185
+ continue;
2186
+ }
2187
+
2188
+ init();
2189
+ try {
2190
+ const r = await dispatchBuild(subOpts, merged, { mode }, {});
2191
+ if (r.errors > 0) exitCode = 1;
2192
+ } catch (err) {
2193
+ console.error(`error [workspace ${w.name}]: ${err.message}`);
2194
+ exitCode = 1;
2195
+ }
2196
+ }
2197
+ if (exitCode !== 0) process.exit(exitCode);
2198
+ }
2199
+
2200
+ // ─── Main ───
2201
+
2202
+ async function main() {
2203
+ const opts = parseArgs(process.argv);
2204
+
2205
+ // --color/--no-color 를 NO_COLOR/FORCE_COLOR env 로 환원 (명시 flag 가 기존 env override).
2206
+ applyColorPreference(opts.color);
2207
+
2208
+ if (opts.help) {
2209
+ printUsage(opts.appCommand);
2210
+ return;
2211
+ }
2212
+
2213
+ if (opts.version) {
2214
+ console.log(getCliVersion() ?? 'unknown');
2215
+ return;
2216
+ }
2217
+
2218
+ if (opts.parseError) {
2219
+ printUsage(opts.appCommand, console.error);
2220
+ process.exit(1);
2221
+ }
2222
+
2223
+ // --test262 는 zig 서브프로세스로 위임. config / NAPI dlopen 모두 불필요 — early dispatch.
2224
+ if (opts.test262 !== undefined) {
2225
+ if (!opts.test262) {
2226
+ printUsage(opts.appCommand, console.error);
2227
+ process.exit(1);
2228
+ }
2229
+ try {
2230
+ const r = await runTest262(opts);
2231
+ if (r.errors > 0) process.exit(1);
2232
+ } catch (err) {
2233
+ console.error(`error: ${err.message}`);
2234
+ process.exit(1);
2235
+ }
2236
+ return;
2237
+ }
2238
+
2239
+ // verify 는 Playwright 만 호출 — config / NAPI dlopen 불필요. test262 와 동일한 early dispatch.
2240
+ if (opts.appCommand === 'verify') {
2241
+ try {
2242
+ const { runVerify } = await import('./verify.mjs');
2243
+ const r = await runVerify(opts);
2244
+ process.exit(r.exitCode);
2245
+ } catch (err) {
2246
+ console.error(`error: ${err.message}`);
2247
+ process.exit(1);
2248
+ }
2249
+ }
2250
+
2251
+ // RN dev/build 의 positional arg 는 entry point (파일) — web 처럼 appRoot (디렉토리)
2252
+ // 가 아니라서 그대로 두면 envDir 가 파일을 가리켜 `.env` 로딩에서 ENOTDIR 발생.
2253
+ // entry → entryPoints[0] 로 옮기고 그 dirname 을 appRoot 로 사용.
2254
+ if (
2255
+ (opts.appCommand === 'dev' || opts.appCommand === 'build') &&
2256
+ opts.platform === 'react-native' &&
2257
+ opts.appRoot
2258
+ ) {
2259
+ const entryArg = opts.appRoot;
2260
+ if (opts.entryPoints.length === 0) opts.entryPoints.push(entryArg);
2261
+ opts.appRoot = dirname(resolve(entryArg));
2262
+ }
2263
+
2264
+ if ((opts.appCommand === 'dev' || opts.appCommand === 'build') && !opts.envDir) {
2265
+ opts.envDir = resolve(opts.appRoot ?? '.');
2266
+ }
2267
+
2268
+ // workspace 자동 탐색 — `--workspace-config <path>` 명시 또는 cwd 의 zntc.workspace.*
2269
+ // 발견 시 워크스페이스 fan-out 모드로 분기. 나머지 단일 build 흐름은 우회.
2270
+ const workspacePath = opts.workspaceConfig
2271
+ ? resolve(opts.workspaceConfig)
2272
+ : findWorkspacePath(process.cwd());
2273
+ if (workspacePath) {
2274
+ applyServerDefaults(opts);
2275
+ if (opts.workspaceConfig && !existsSync(workspacePath)) {
2276
+ throw new Error(`failed to load workspace — file not found: ${workspacePath}`);
2277
+ }
2278
+ await runWorkspace(opts, workspacePath);
2279
+ return;
2280
+ }
2281
+
2282
+ // config 자동 탐색 + .env 로드 + 머지 (CLI > config > tsconfig). entry 검사 전에
2283
+ // 적용해야 config 의 entryPoints 가 검사 통과에 기여한다.
2284
+ // init() 은 entry 검사 후로 미뤄 no-args 경로의 NAPI dlopen 비용을 절감한다.
2285
+ // (config 가 .ts 면 loadConfig 내부에서 init() 이 idempotent 하게 호출됨)
2286
+ const { config, env: configEnv, dotenvVars } = await loadAutoConfig(opts);
2287
+ if (config) {
2288
+ // unknown 키 검출 + Levenshtein "did you mean?" 제안 (#2109).
2289
+ // 머지 전에 검사 — 사용자 typo 가 silent 무시되지 않도록.
2290
+ if (opts.logLevel !== 'silent') {
2291
+ warnUnknownKeys(config, KNOWN_CONFIG_KEYS, { sourceLabel: 'zntc.config' });
2292
+ }
2293
+ mergeConfigIntoOpts(opts, config);
2294
+ }
2295
+ applyServerDefaults(opts);
2296
+
2297
+ // import.meta.env.* + import.meta.env.MODE/PROD/DEV/SSR 정적 치환을 define 으로
2298
+ // 자동 주입. 사용자 명시 define 이 동일 키를 덮어쓰면 그대로 우선.
2299
+ const envDefine = envToDefine(
2300
+ dotenvVars,
2301
+ configEnv.mode,
2302
+ normalizeBase(opts.base ?? opts.publicPath ?? '/'),
2303
+ );
2304
+ for (const [key, value] of Object.entries(envDefine)) {
2305
+ if (opts.define[key] === undefined) opts.define[key] = value;
2306
+ }
2307
+ injectDefaultNodeEnvDefine(opts);
2308
+
2309
+ if (opts.entryPoints.length === 0 && !opts.stdin && !opts.serve && !opts.appCommand) {
2310
+ printUsage(undefined, console.error);
2311
+ process.exit(1);
2312
+ }
2313
+
2314
+ try {
2315
+ // raw tsconfig 입력 사전 검증 — NAPI 가 silent fallback 이라 invalid 라도 진입은 가능,
2316
+ // 사용자 디버깅 편의를 위해 여기서 명시 에러로 실패시킨다.
2317
+ validateTsConfigRaw(opts.tsconfigRaw);
2318
+ init();
2319
+ const r = await dispatchBuild(opts, config, configEnv, dotenvVars);
2320
+ if (r.errors > 0) process.exit(1);
2321
+ } catch (err) {
2322
+ console.error(`error: ${err.message}`);
2323
+ process.exit(1);
2324
+ }
2325
+ }
2326
+
2327
+ main();