@zntc/core 0.1.0 → 0.1.2

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 CHANGED
@@ -7,7 +7,16 @@
7
7
  * Watch/Serve는 JS 레이어에서 구현.
8
8
  */
9
9
 
10
- import { mkdirSync, existsSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
10
+ import {
11
+ mkdirSync,
12
+ existsSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ realpathSync,
16
+ rmSync,
17
+ unlinkSync,
18
+ writeFileSync,
19
+ } from 'node:fs';
11
20
  import { resolve, dirname, basename, extname, join, sep } from 'node:path';
12
21
  import { createServer } from 'node:http';
13
22
  import { createServer as createHttpsServer } from 'node:https';
@@ -59,6 +68,7 @@ const {
59
68
  build,
60
69
  buildAppSync,
61
70
  buildSync,
71
+ watch,
62
72
  envToDefine,
63
73
  filterWorkspaces,
64
74
  findConfigPath,
@@ -109,6 +119,7 @@ function usageLines(command) {
109
119
  ' --host [host] Host to listen on (default: localhost)',
110
120
  ' --port <port> Port to listen on (default: 12300)',
111
121
  ' --open Open the app URL in the browser',
122
+ ' --lazy Compile dynamic import() chunks on demand',
112
123
  ' --mode <mode> Load mode-specific config and .env files',
113
124
  ' --base <path> Base public path',
114
125
  ' --entry-html <path> HTML entry file',
@@ -247,7 +258,7 @@ function parseArgs(argv) {
247
258
  bundle: false,
248
259
  watch: false,
249
260
  watchJson: false,
250
- watchDelay: 100,
261
+ watchDelay: 16,
251
262
  serve: false,
252
263
  serveDir: '.',
253
264
  port: undefined,
@@ -288,6 +299,7 @@ function parseArgs(argv) {
288
299
  entryNames: undefined,
289
300
  chunkNames: undefined,
290
301
  assetNames: undefined,
302
+ cssNames: undefined,
291
303
  jsx: undefined,
292
304
  jsxDev: false,
293
305
  jsxFactory: undefined,
@@ -393,6 +405,16 @@ function parseArgs(argv) {
393
405
  opts.serve = true;
394
406
  opts.bundle = true;
395
407
  opts.watch = true;
408
+ // #3793 — `zntc dev` 는 incremental HMR 활성 (__zntc_apply_update / __esm register
409
+ // 주입) 이 필수. 명시 안 set 시 initial bundle 이 production 모드로 빌드돼 HMR
410
+ // 런타임 누락 → broadcast 된 Update 가 client 에서 fallback reload. user 가
411
+ // 명시적으로 `--dev=false` 줘서 override 하기 전엔 dev 모드 default 보장.
412
+ opts.devMode = true;
413
+ // RFC_LAZY_DEV_MODULE_HMR PR-5 / web React Fast Refresh: dev 는 reactRefresh 기본 on.
414
+ // 번들러는 React 컴포넌트가 있는 모듈에만 $RefreshReg$/accept 를 emit(비-React 무해).
415
+ // 브라우저 런타임 preamble 주입은 runServe 가 react 존재 시에만 한다(비-React 노이즈 0).
416
+ // 명시 flag(`--react-refresh=false`)는 아래 flag 파싱이 덮어쓴다.
417
+ if (opts.reactRefresh === undefined) opts.reactRefresh = true;
396
418
  } else if (appCommand === 'build') {
397
419
  opts.bundle = true;
398
420
  } else if (appCommand === 'preview') {
@@ -662,16 +684,137 @@ function getAutoConfigSearchDir(opts) {
662
684
  return process.cwd();
663
685
  }
664
686
 
687
+ /**
688
+ * RFC #3833 v3 D1a'' (caller-side pre-warm) helper — runAppBuild/runAppDev 가
689
+ * 공유. 사용자 explicit `plugins: [css({postcss:{...}})]` 의 옵션을 추출해
690
+ * prepare 단계의 `postcssOverride` 로 전달. buildAppSync 의 sync dispatcher ×
691
+ * async cssOnLoad 충돌 회피 — Vite/esbuild 의 main thread pre-process → sync
692
+ * bundle 패턴.
693
+ *
694
+ * 분기:
695
+ * 1. disabled:true → explicit PostCSS 끄기 (override={plugins:[]} 로
696
+ * auto-discover 도 차단, prepare 가 length 0 skip)
697
+ * 2. postcss 명시 → presence check, plugins ?? [] 정규화. options-only
698
+ * override 도 explicit no-op (Vite inline override
699
+ * 시맨틱)
700
+ * 3. 둘 다 없으면 → override=null → prepare 가 auto-discover path
701
+ *
702
+ * **findLast**: 미래 default `css()` prepend 와 user override 가 동시 존재 시
703
+ * 마지막 등록이 winner (Vite plugins 순서 의미). sentinel `__cssOptions` 는
704
+ * runtime 위장 방어 0 — 의도 매치용.
705
+ *
706
+ * @param {Array<{name?:string,__cssOptions?:object}>} plugins
707
+ * @returns {{plugins: unknown[], options: Record<string,unknown> | undefined} | null}
708
+ */
709
+ /**
710
+ * sentinel `__cssOptions` 보유 css plugin 의 raw options 추출. 매치 안 되면 null.
711
+ * extractCssPostcssOverride / extractCssAutoDiscoverRoot 의 공통 helper.
712
+ */
713
+ function extractCssOptions(plugins) {
714
+ const cssPlugin = plugins.findLast(
715
+ // 두 항 모두 optional chain — predicate 순서 변경 시 null/undefined deref 회귀
716
+ // 차단 (/code-review max #1 latent finding).
717
+ (p) => p?.name === '@zntc/web/css' && p?.__cssOptions !== undefined,
718
+ );
719
+ return cssPlugin?.__cssOptions ?? null;
720
+ }
721
+
722
+ function extractCssPostcssOverride(plugins) {
723
+ const opts = extractCssOptions(plugins);
724
+ if (!opts) return null;
725
+ if (opts.disabled === true) return { plugins: [], options: undefined };
726
+ if (opts.postcss) {
727
+ return {
728
+ plugins: opts.postcss.plugins ?? [],
729
+ options: opts.postcss.options,
730
+ // issue #3851 — css({root}) 의 root 가 caller-pre-warm path 에서 silent
731
+ // ignore 였던 회귀 fix. override path 의 postcss require base 로 routing.
732
+ // mode 는 override.plugins 명시 시 loadPostcssConfig 미호출이라 무의미 —
733
+ // routing 안 함 (사용자가 root 와 mode 둘 다 명시한 경우 mode 는 onLoad
734
+ // 의 dispatcher path 에서만 의미 있었음, caller-pre-warm 에선 dead).
735
+ root: opts.root,
736
+ };
737
+ }
738
+ return null;
739
+ }
740
+
741
+ /**
742
+ * issue #3857 — `css({root})` 단독 명시 (postcss override 없이) 시 root 가
743
+ * auto-discover path 의 findPostcssConfig 시작 base 로 사용되게 caller 가 전달.
744
+ * monorepo edge: postcss.config 가 monorepo root 에 있고 app 이 sub-package
745
+ * 인 경우 사용자가 root='/monorepo-root' 명시.
746
+ *
747
+ * - opts.disabled 면 null (자동발견 차단은 disabled true 의 책임)
748
+ * - opts.postcss truthy 면 null (override path 가 root 직접 routing)
749
+ * - opts.root 만 있으면 그것 반환
750
+ */
751
+ function extractCssAutoDiscoverRoot(plugins) {
752
+ const opts = extractCssOptions(plugins);
753
+ if (!opts || opts.disabled === true) return null;
754
+ if (opts.postcss) return null;
755
+ // /code-review max #3/#4: type/empty guard — non-string 또는 빈 string 거부.
756
+ // findPostcssConfig(non-string) → TypeError, findPostcssConfig('') → cwd 기준
757
+ // wrong-base search. 사용자 invalid 입력은 silent null (auto-discover skip).
758
+ if (typeof opts.root !== 'string' || opts.root.length === 0) return null;
759
+ return opts.root;
760
+ }
761
+
762
+ /**
763
+ * RFC #3833 v3 D1a'' — caller-pre-warm sentinel (`__cssOptions !== undefined`)
764
+ * 가진 css plugin 을 native dispatcher plugin chain 에서 제거.
765
+ *
766
+ * Caller paths:
767
+ * - **runAppBuild**: buildAppSync 의 sync dispatcher 가 async onLoad 받으면
768
+ * syncPluginPromiseFailure → BundleFailed. 본 helper 로 dispatch 차단.
769
+ * - **buildBundleOptions** (runBundle/watch/runServe): native async dispatcher
770
+ * 가 onLoad 호출 → prepare 와 같은 PostCSS 두 번 실행 (double-pass). 본
771
+ * helper 로 dispatch 차단.
772
+ *
773
+ * **runAppDev 는 본 helper 미경유** — controller (createAppDevController) 에
774
+ * postcssOverride 만 전달, plugin chain 자체는 runServe → buildBundleOptions
775
+ * 경로에서 처리. 따라서 dev path 의 filter 는 buildBundleOptions 가 cover.
776
+ *
777
+ * extractCssPostcssOverride 와 동일 sentinel match 조건 (predicate 일관) —
778
+ * drift 위험 차단.
779
+ *
780
+ * @param {Array<{name?:string,__cssOptions?:object}>} plugins
781
+ * @returns {Array<unknown>} caller-pre-warm 활성 css plugin 제거된 새 array
782
+ */
783
+ function dropCallerPreWarmedCssPlugin(plugins) {
784
+ return plugins.filter((p) => !(p?.name === '@zntc/web/css' && p?.__cssOptions !== undefined));
785
+ }
786
+
665
787
  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
- );
788
+ // JS plugin 로드 bundle pipeline 의 buildBundleOptions 와 동일 패턴. app
789
+ // pipeline 도 plugin dispatcher 통과 (#2538 4-4 PR-1).
790
+ const appPlugins = [];
791
+ if (config && Array.isArray(config.plugins)) {
792
+ appPlugins.push(...config.plugins);
793
+ }
794
+ for (const pluginPath of opts.pluginPaths) {
795
+ const absPath = resolve(pluginPath);
796
+ const cfg = await importAndResolveDefault(absPath);
797
+ if (Array.isArray(cfg.plugins)) {
798
+ appPlugins.push(...cfg.plugins);
799
+ } else if (typeof cfg.setup === 'function') {
800
+ appPlugins.push(cfg);
801
+ }
670
802
  }
671
803
  const web = await loadWebModule();
672
804
  const root = resolve(opts.appRoot ?? '.');
673
805
  const outdir = resolve(opts.outdir ?? join(root, 'dist'));
674
806
  if (opts.clean) rmSync(outdir, { recursive: true, force: true });
807
+ // RFC #3833 v3 D1a'' caller-side pre-warm — extractCssPostcssOverride helper 참조.
808
+ const postcssOverride = extractCssPostcssOverride(appPlugins);
809
+ // issue #3857 — css({root}) 단독 명시 시 findPostcssConfig search base
810
+ // (monorepo edge: app 이 sub-package, postcss.config 가 monorepo root).
811
+ const cssAutoDiscoverRoot = extractCssAutoDiscoverRoot(appPlugins);
812
+ // dropCallerPreWarmedCssPlugin: sentinel 가진 css plugin 을 항상 dispatcher 에서
813
+ // 제거 (buildBundleOptions 와 동일 무조건 적용). /code-review max #5: 조건부
814
+ // (postcssOverride truthy 시만) 분기는 비대칭 — 사용자가 sentinel 만 가진
815
+ // plugin (예: `__cssOptions:{someFutureKey}` — extract null) 등록 시
816
+ // BundleFailed 회귀 가능. 무조건 drop 으로 future-key 안전 + 양쪽 path 일관.
817
+ const dispatchPlugins = dropCallerPreWarmedCssPlugin(appPlugins);
675
818
  let pipelineRoot = null;
676
819
  try {
677
820
  const pipeline = await web.prepareAppCssPipelineRoot(
@@ -681,6 +824,7 @@ async function runAppBuild(opts, config, configEnv, _dotenvVars) {
681
824
  opts.logLevel,
682
825
  'build',
683
826
  { fallbackRequire: requireFromCli, cliNodeModules },
827
+ { postcssOverride, cssAutoDiscoverRoot },
684
828
  );
685
829
  pipelineRoot = pipeline?.tempRoot ?? null;
686
830
  const result = buildAppSync({
@@ -701,6 +845,7 @@ async function runAppBuild(opts, config, configEnv, _dotenvVars) {
701
845
  jsxFactory: opts.jsxFactory,
702
846
  jsxFragment: opts.jsxFragment,
703
847
  compiler: config?.compiler,
848
+ plugins: dispatchPlugins.length > 0 ? dispatchPlugins : undefined,
704
849
  });
705
850
  const htmlEnv = loadEnv(
706
851
  configEnv.mode,
@@ -731,14 +876,40 @@ async function runAppDev(opts, config, configEnv, _dotenvVars) {
731
876
  const web = await loadWebModule();
732
877
  const root = resolve(opts.appRoot ?? '.');
733
878
  opts.outdir = opts.outdir || join(root, '.zntc-dev');
734
- const appDev = web.createAppDevController(opts, root, configEnv, {
735
- fallbackRequire: requireFromCli,
736
- cliNodeModules,
737
- });
879
+ // RFC #3833 v3 D1a'' Phase 2: build path 와 동일 plugin walk + helper 추출.
880
+ const devUserPlugins = [];
881
+ if (config && Array.isArray(config.plugins)) devUserPlugins.push(...config.plugins);
882
+ for (const pluginPath of opts.pluginPaths) {
883
+ const absPath = resolve(pluginPath);
884
+ const cfg = await importAndResolveDefault(absPath);
885
+ if (Array.isArray(cfg.plugins)) devUserPlugins.push(...cfg.plugins);
886
+ else if (typeof cfg.setup === 'function') devUserPlugins.push(cfg);
887
+ }
888
+ const appDev = web.createAppDevController(
889
+ {
890
+ ...opts,
891
+ postcssOverride: extractCssPostcssOverride(devUserPlugins),
892
+ // issue #3857 — css({root}) 단독 명시 (postcss override 없이) 시 root 를
893
+ // findPostcssConfig 의 search base 로 전달. monorepo 의 sub-package app
894
+ // 이 monorepo root 의 postcss.config 참조하는 시나리오.
895
+ cssAutoDiscoverRoot: extractCssAutoDiscoverRoot(devUserPlugins),
896
+ },
897
+ root,
898
+ configEnv,
899
+ { fallbackRequire: requireFromCli, cliNodeModules },
900
+ );
738
901
  const prepared = await appDev.prepare();
739
902
 
740
903
  opts.entryPoints = [prepared.entryPath];
741
904
  opts.serveDir = opts.outdir;
905
+ // issue #3858 — runServe 의 watchFolders 자동 set 위해 app root 를 stash.
906
+ // opts.serveDir 은 outdir(`.zntc-dev`) 이라 사용자 source code root 가 아님 —
907
+ // 별도 channel 필요.
908
+ opts._appWatchRoot = root;
909
+ // issue #3852 — runAppDev 가 collect 한 plugin 을 stash → runServe 의
910
+ // buildBundleOptions 가 재import 안 함. ESM cache hit 라 시맨틱 회귀는 0 였지만
911
+ // perf + cache invalidate edge 안전.
912
+ opts._resolvedPlugins = devUserPlugins;
742
913
 
743
914
  return runServe(opts, config, { appDev });
744
915
  }
@@ -762,6 +933,17 @@ async function loadWebModule() {
762
933
  console.error('help: install with `bun add -D @zntc/web` 또는 `npm i -D @zntc/web`.');
763
934
  process.exit(1);
764
935
  }
936
+ // @zntc/web 의 module 평가가 dev-overlay-client.raw.js 를 readFileSync 한다
937
+ // (#2538 4-3). dist 가 incomplete 한 경우 (build:bundle 의 copy 누락 / 일부만
938
+ // 추출된 tarball) 가 ENOENT 로 throw — 친절 핸들 분기.
939
+ if (code === 'ENOENT' && /dev-overlay-client\.raw\.js/.test(message)) {
940
+ console.error('error: @zntc/web 의 dist/dev-overlay-client.raw.js 가 누락됐습니다.');
941
+ console.error('');
942
+ console.error(
943
+ 'help: `bun --cwd <repo>/packages/web run build` 로 재빌드하거나 @zntc/web 을 재설치하세요.',
944
+ );
945
+ process.exit(1);
946
+ }
765
947
  throw err;
766
948
  }
767
949
  })();
@@ -1145,6 +1327,7 @@ function mergeConfigIntoOpts(opts, config) {
1145
1327
  'entryNames',
1146
1328
  'chunkNames',
1147
1329
  'assetNames',
1330
+ 'cssNames',
1148
1331
  'jsx',
1149
1332
  'jsxFactory',
1150
1333
  'jsxFragment',
@@ -1299,29 +1482,54 @@ function mergeCliRuntimeTargets(runtimePolyfills, runtimeTargetQueries) {
1299
1482
  return runtimePolyfills;
1300
1483
  }
1301
1484
 
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);
1485
+ /**
1486
+ * `runBundle` / `startBundleWatch` 공유하는 NAPI BuildOptions 생성 helper.
1487
+ * plugins 머지 + applySingleFileDynamicImportDefault + 옵션 매핑을 곳에 모아
1488
+ * runBundle (single-shot) 와 watch (incremental HMR, #3779) 의 옵션 drift 차단.
1489
+ */
1490
+ async function buildBundleOptions(opts, config, { filterCallerPreWarmCss = false } = {}) {
1491
+ // issue #3852 — caller (runAppDev) 가 이미 plugin walk 했으면 `_resolvedPlugins`
1492
+ // stash 재import skip. ESM cache hit 이라 시맨틱 회귀 없지만 perf +
1493
+ // cache invalidate edge 안전.
1494
+ let plugins;
1495
+ if (Array.isArray(opts._resolvedPlugins)) {
1496
+ plugins = [...opts._resolvedPlugins];
1497
+ } else {
1498
+ plugins = [];
1499
+ if (config && Array.isArray(config.plugins)) {
1500
+ plugins.push(...config.plugins);
1501
+ }
1502
+ for (const pluginPath of opts.pluginPaths) {
1503
+ const absPath = resolve(pluginPath);
1504
+ // importAndResolveDefault 는 pathToFileURL 으로 Windows 경로를 안전하게 처리하고
1505
+ // ENOENT/객체 검증을 통일한다 (config-loader 와 공유).
1506
+ const cfg = await importAndResolveDefault(absPath);
1507
+ if (Array.isArray(cfg.plugins)) {
1508
+ plugins.push(...cfg.plugins);
1509
+ } else if (typeof cfg.setup === 'function') {
1510
+ plugins.push(cfg);
1511
+ }
1318
1512
  }
1319
1513
  }
1514
+ // RFC #3833 v3 D1a'' caller-side pre-warm — dropCallerPreWarmedCssPlugin helper
1515
+ // 로 sentinel 가진 css plugin 을 dispatcher chain 에서 제거. **app caller
1516
+ // (runServe with appDev) 만 적용** — bundle/transpile 모드 사용자가 css()
1517
+ // 명시한 경우 무조건 drop 하면 PostCSS 효과 0 silent regression (review #2).
1518
+ // app 모드만 caller-pre-warm 로 prepare 가 처리하므로 dispatcher dispatch 차단
1519
+ // 필요, bundle 모드는 native async dispatcher 가 onLoad 호출 (단 caller-pre-warm
1520
+ // 없어 PostCSS 적용 안 되지만 사용자 의도 그대로 dispatcher 에 전달).
1521
+ const dispatchPlugins = filterCallerPreWarmCss ? dropCallerPreWarmedCssPlugin(plugins) : plugins;
1320
1522
 
1321
1523
  applySingleFileDynamicImportDefault(opts);
1322
1524
 
1323
- const buildOpts = {
1525
+ return {
1324
1526
  entryPoints: opts.entryPoints.map((e) => resolve(e)),
1527
+ // #3795/#3796 — watch handle 이 outdir 알아야 worker thread 의 createFile 가 정확한 위치에
1528
+ // 출력. `prepareNapiOptions` 가 build/buildSync 케이스에서 outdir 를 delete 하지만 watch()
1529
+ // wrapper (index.ts:3358) 가 명시 outdir 받아 NAPI 로 restore. 그러므로 BuildOptions 의
1530
+ // outdir/outfile 필드 자체는 enrich 해서 보내야 함.
1531
+ outdir: opts.outdir,
1532
+ outfile: opts.outfile,
1325
1533
  format: opts.format,
1326
1534
  platform: opts.platform,
1327
1535
  target: opts.target,
@@ -1402,6 +1610,7 @@ async function runBundle(opts, config) {
1402
1610
  entryNames: opts.entryNames,
1403
1611
  chunkNames: opts.chunkNames,
1404
1612
  assetNames: opts.assetNames,
1613
+ cssNames: opts.cssNames,
1405
1614
  jsx: opts.jsx,
1406
1615
  jsxDev: opts.jsxDev,
1407
1616
  jsxFactory: opts.jsxFactory,
@@ -1409,6 +1618,9 @@ async function runBundle(opts, config) {
1409
1618
  jsxImportSource: opts.jsxImportSource,
1410
1619
  inject: opts.inject.map((p) => resolve(p)),
1411
1620
  devMode: opts.devMode,
1621
+ // web React Fast Refresh: native 빌드(options.zig:reactRefresh)에 전파 → 컴포넌트에
1622
+ // $RefreshReg$/accept emit. dev 블록이 기본 on(비-React 무해).
1623
+ reactRefresh: opts.reactRefresh,
1412
1624
  globalIdentifiers: opts.globalIdentifiers,
1413
1625
  // --polyfill / --run-before-main / --watch-folder 는 경로 → abs 변환 (--inject 와 동일).
1414
1626
  // --watch-include / --watch-exclude 는 루트 기준 glob 이므로 변환 안 함.
@@ -1421,7 +1633,7 @@ async function runBundle(opts, config) {
1421
1633
  watchExclude: opts.watchExclude?.length ? opts.watchExclude : undefined,
1422
1634
  jobs: opts.jobs,
1423
1635
  outbase: opts.outbase,
1424
- plugins: plugins.length > 0 ? plugins : undefined,
1636
+ plugins: dispatchPlugins.length > 0 ? dispatchPlugins : undefined,
1425
1637
  // compiler.styledComponents / compiler.emotion 도 bundle 모드에서 forward.
1426
1638
  // 누락 시 `zntc.config.json` 의 `compiler` 설정이 silently drop 돼 1st-party transform
1427
1639
  // (autoLabel 등) 이 활성화 안 됨.
@@ -1431,8 +1643,12 @@ async function runBundle(opts, config) {
1431
1643
  // (native CLI 만 zntc.config.json mf 를 직접 읽어 동작했던 갭).
1432
1644
  mf: config?.mf,
1433
1645
  };
1646
+ }
1434
1647
 
1435
- const result = plugins.length > 0 ? await build(buildOpts) : buildSync(buildOpts);
1648
+ async function runBundle(opts, config) {
1649
+ const buildOpts = await buildBundleOptions(opts, config);
1650
+ const hasPlugins = Array.isArray(buildOpts.plugins) && buildOpts.plugins.length > 0;
1651
+ const result = hasPlugins ? await build(buildOpts) : buildSync(buildOpts);
1436
1652
 
1437
1653
  printResultDiagnostics(result, opts.logLevel);
1438
1654
 
@@ -1657,6 +1873,65 @@ async function emitRestartAfter(opts, reason, beforeSpawn) {
1657
1873
 
1658
1874
  // ─── Serve 모드 ───
1659
1875
 
1876
+ /**
1877
+ * #3858 — raw root .css 의 diff 기반 reconcile. 이전 scan 결과와 비교해
1878
+ * **사라진 path** 만 outdir 에서 unlink. 이전 design (raw vs outdir set diff)
1879
+ * 은 outdir 의 bundler/sass/css-modules 가 emit 한 transient file (chunk.css,
1880
+ * Button.module.zntc.css 등) 을 stale 오판 → unlink 회귀 (/code-review max #4/#5).
1881
+ *
1882
+ * caller 가 closure 로 prevRawSet 보관 — 매 cycle current scan + diff:
1883
+ * - removed = prev ∖ current → outdir 의 동일 rel path unlink
1884
+ * - prev := current
1885
+ *
1886
+ * .scss/.sass 는 sass pipeline 이 별도 outdir 에 emit, raw root 의 .scss 자체
1887
+ * 삭제 시 sass 산출물도 dev_controller 가 cleanup. reconcile 은 plain raw .css
1888
+ * 만 cover (사용자가 직접 만든 .css 파일).
1889
+ *
1890
+ * cost: O(raw .css count) walk per rebuild — 일반적 small (dozens).
1891
+ */
1892
+ function createReconcileOutdirCss(rawRoot, outdir) {
1893
+ let prevRawSet = new Set();
1894
+
1895
+ function scanRawCss() {
1896
+ const set = new Set();
1897
+ function walk(dir, relBase) {
1898
+ let entries;
1899
+ try {
1900
+ entries = readdirSync(dir, { withFileTypes: true });
1901
+ } catch {
1902
+ return;
1903
+ }
1904
+ for (const e of entries) {
1905
+ if (e.name === 'node_modules' || e.name === '.git') continue;
1906
+ if (e.name.startsWith('.zntc-')) continue;
1907
+ const rel = relBase ? `${relBase}/${e.name}` : e.name;
1908
+ if (e.isDirectory()) walk(join(dir, e.name), rel);
1909
+ else if (e.isFile() && e.name.endsWith('.css')) set.add(rel);
1910
+ }
1911
+ }
1912
+ walk(rawRoot, '');
1913
+ return set;
1914
+ }
1915
+
1916
+ // 첫 호출 시 prev 를 현재 raw scan 으로 초기화 — 첫 reconcile cycle 에서
1917
+ // 모든 outdir .css 가 stale 로 오판되는 것 회피.
1918
+ prevRawSet = scanRawCss();
1919
+
1920
+ return function reconcile() {
1921
+ const current = scanRawCss();
1922
+ for (const rel of prevRawSet) {
1923
+ if (current.has(rel)) continue;
1924
+ const target = join(outdir, rel);
1925
+ try {
1926
+ unlinkSync(target);
1927
+ } catch {
1928
+ // best-effort — file 이 없거나 race
1929
+ }
1930
+ }
1931
+ prevRawSet = current;
1932
+ };
1933
+ }
1934
+
1660
1935
  async function runServe(opts, config, { appDev = null } = {}) {
1661
1936
  const isBun = typeof globalThis.Bun !== 'undefined';
1662
1937
  // appDev 모드에서만 web 모듈 (HMR_MSG / APP_DEV_HMR_*_PATH / createHmrChannel /
@@ -1669,7 +1944,47 @@ async function runServe(opts, config, { appDev = null } = {}) {
1669
1944
  const APP_DEV_HMR_CLIENT = web?.APP_DEV_HMR_CLIENT;
1670
1945
  const APP_DEV_HMR_CLIENT_PATH = web?.APP_DEV_HMR_CLIENT_PATH;
1671
1946
  const APP_DEV_HMR_WS_PATH = web?.APP_DEV_HMR_WS_PATH;
1947
+ const APP_DEV_REACT_REFRESH_PATH = web?.APP_DEV_REACT_REFRESH_PATH;
1948
+ // React Fast Refresh preamble (react-refresh 런타임 글로벌 노출 + injectIntoGlobalHook).
1949
+ // reactRefresh on + react 설치 시에만 non-null(=비-React 앱은 주입/서빙/경고 0). lazy 1회 캐시.
1950
+ const reactRefreshAppRoot = opts.appRoot ? resolve(opts.appRoot) : process.cwd();
1951
+ let reactRefreshPreamble; // undefined=미계산, null=스킵, string=서빙
1952
+ const getReactRefreshPreamble = () => {
1953
+ if (reactRefreshPreamble === undefined) {
1954
+ reactRefreshPreamble =
1955
+ web && opts.reactRefresh ? web.buildReactRefreshPreamble(reactRefreshAppRoot) : null;
1956
+ }
1957
+ return reactRefreshPreamble;
1958
+ };
1672
1959
  let serverHandle = null;
1960
+ // #3779 follow-up — restart 시 stop 호출용. opts.bundle+watch+appDev+hmr 분기 안에서만 할당.
1961
+ let nativeWatchHandle = null;
1962
+ // #4062 — JS dev 서버 lazy on-demand 라우트. 게이트 = `--lazy` CLI 플래그(PR-C-3) 또는
1963
+ // env `ZNTC_LAZY=1`(동치 fallback). D105 접근1: native lazy 프리미티브(#4069/#4070, watch
1964
+ // lazySeeds + build lazyForceParse) 위에 JS 서버가 얇게 on-demand 라우팅을 얹는다. lazy 동적
1965
+ // 청크는 emit-skip 되므로(디스크에 없음) 브라우저가 `/<stem>-<8hex>.js` 를 요청하면 그 seed 만
1966
+ // force-parse 한 단발 build() 로 즉석 생성·캐시해서 서빙한다. 게이트 OFF 면 아래 전부 무시 → 0 영향.
1967
+ const lazyMode = opts.lazy === true || process.env.ZNTC_LAZY === '1';
1968
+ // pathHash(8 hex) → seed 절대경로. watch onReady/onRebuild 의 event.lazySeeds 로 갱신.
1969
+ const lazySeedMap = new Map();
1970
+ // pathHash → { body, type }. on-demand 빌드 결과 캐시. rebuild 마다 무효화(seed 본문 변경 가능).
1971
+ const lazyChunkCache = new Map();
1972
+ // pathHash → in-flight build Promise. 같은 청크 동시 요청을 coalesce(중복 build 회피).
1973
+ const lazyInflight = new Map();
1974
+ // on-demand build() 직렬화 tail. 페이지 로드 시 여러 lazy 청크가 동시 요청돼도 native build()
1975
+ // 를 한 번에 하나씩만 돌려 watch worker 와의 동시 재진입 위험을 없앤다(/code-review Q2).
1976
+ let lazyBuildTail = Promise.resolve();
1977
+ // #4062 PR-C-2 — 캐시 세대(epoch). rebuild(captureLazyState)가 캐시를 무효화할 때마다 +1.
1978
+ // on-demand build 가 시작 시 epoch 를 캡처하고 완료 후 비교해, 빌드 도중 rebuild 가 끼면
1979
+ // (epoch 변동) 그 결과를 캐시에 넣지 않는다(옛 소스로 만든 stale 바이트가 비워진 캐시를
1980
+ // 재오염하는 것을 막음 — native LazyState.epoch PR-4-iii 패턴 이식).
1981
+ let lazyEpoch = 0;
1982
+ // lazy entry 청크의 디스크 경로(`<entrystem>.js`). served index.html 은 prepareDev 가 non-split
1983
+ // 으로 `/bundle.js` 를 참조하게 rewrite 했지만, watch lazy 빌드의 entry 청크는 stem 이름이라
1984
+ // mismatch → `/bundle.js` 를 이 파일로 alias.
1985
+ let lazyEntryFile = null;
1986
+ // on-demand 단발 build() 옵션 템플릿(watchBuildOpts 와 동일 lazy 설정, callback/outdir 제거).
1987
+ let lazyOnDemandOpts = null;
1673
1988
  const mimeTypes = {
1674
1989
  '.html': 'text/html',
1675
1990
  '.js': 'application/javascript',
@@ -1686,29 +2001,40 @@ async function runServe(opts, config, { appDev = null } = {}) {
1686
2001
  '.map': 'application/json',
1687
2002
  };
1688
2003
 
1689
- // 번들 모드면 먼저 빌드
2004
+ // #3796/#3798 root-cause cold-start 시 runBundle 호출 제거. native watch 가 initial
2005
+ // 빌드 + outdir 출력 + appDev hooks 전부 담당. plugin.setup() 1회만 invoke (cold-start
2006
+ // 시점). race 회피는 HTTP server listen 을 watch.onReady 까지 wait — `napi_tsfn_blocking`
2007
+ // 모드라 worker 가 outdir 출력 완료 후 ready_event firing, JS callback 안에서 markWatchReady.
1690
2008
  if (opts.bundle && opts.entryPoints.length > 0) {
1691
2009
  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
2010
  if (!opts.watch) {
1705
2011
  opts.watch = true;
1706
2012
  }
1707
2013
  }
1708
2014
 
2015
+ // #3796 — watch.onReady 까지 HTTP listen 이 wait 할 수 있게 promise. appDev + hmr 모드만
2016
+ // 의미 — 그 외 모드는 markWatchReady 즉시 호출 → promise 가 처음부터 resolved.
2017
+ let watchReadyResolve;
2018
+ const watchReadyPromise = new Promise((r) => {
2019
+ watchReadyResolve = r;
2020
+ });
2021
+ let watchReadyResolved = false;
2022
+ const markWatchReady = () => {
2023
+ if (!watchReadyResolved) {
2024
+ watchReadyResolved = true;
2025
+ watchReadyResolve();
2026
+ }
2027
+ };
2028
+ if (!(opts.bundle && opts.watch && appDev && hmr)) markWatchReady();
2029
+
1709
2030
  const serveDir = resolve(opts.outdir || opts.serveDir);
1710
2031
  const base = normalizeBase(opts.base ?? '/');
1711
2032
 
2033
+ // #3799 — HMR Update modules 의 sourceMappingURL 이 가리키는 lazy sourcemap endpoint.
2034
+ // dev_overlay_client.js 의 __zntc_apply_update 가 eval 한 module code 끝의 주석을 DevTools
2035
+ // 가 fetch 함. RN bridge 의 `/__zntc_hmr_map/<id>` 와 동일 path.
2036
+ const HMR_MAP_PATH = '/__zntc_hmr_map/';
2037
+
1712
2038
  function handleRequest(reqUrl, accept = '') {
1713
2039
  let pathname = new URL(reqUrl, 'http://localhost').pathname;
1714
2040
  if (appDev && pathname === APP_DEV_HMR_CLIENT_PATH) {
@@ -1718,6 +2044,27 @@ async function runServe(opts, config, { appDev = null } = {}) {
1718
2044
  type: 'application/javascript',
1719
2045
  };
1720
2046
  }
2047
+ // React Fast Refresh preamble — 앱 번들보다 먼저 실행되는 classic <script> 본문.
2048
+ if (appDev && opts.reactRefresh && pathname === APP_DEV_REACT_REFRESH_PATH) {
2049
+ const body = getReactRefreshPreamble();
2050
+ if (body != null) return { status: 200, body, type: 'application/javascript' };
2051
+ }
2052
+ // #3799 — HMR module 별 sourcemap. nativeWatchHandle 이 lazy cache 한 V3 JSON 을
2053
+ // moduleId 로 조회. handle 가 stop 됐거나 module 미수집 시 null → 404. 사용자
2054
+ // app 의 routing 우선순위 (base prefix 처리) 보다 앞에 위치 — `__zntc_hmr_map/`
2055
+ // 는 internal prefix 라 base 영향 무관.
2056
+ if (appDev && pathname.startsWith(HMR_MAP_PATH) && nativeWatchHandle) {
2057
+ const moduleId = decodeURIComponent(pathname.slice(HMR_MAP_PATH.length));
2058
+ try {
2059
+ const sm = nativeWatchHandle.getHmrSourceMap(moduleId);
2060
+ if (sm) {
2061
+ return { status: 200, body: sm, type: 'application/json' };
2062
+ }
2063
+ } catch {
2064
+ // handle stop 또는 unwrap 실패 — 404
2065
+ }
2066
+ return { status: 404, body: 'Not Found', type: 'text/plain' };
2067
+ }
1721
2068
  if (base && base !== '/' && pathname.startsWith(base)) {
1722
2069
  pathname = '/' + pathname.slice(base.length);
1723
2070
  }
@@ -1744,16 +2091,323 @@ async function runServe(opts, config, { appDev = null } = {}) {
1744
2091
  return { status: 200, body, type };
1745
2092
  }
1746
2093
 
2094
+ // #4062 PR-B-2 — lazy 상태 갱신. watch onReady/onRebuild event 의 lazySeeds 로 pathHash→seed
2095
+ // 맵을 다시 채우고, entry 청크 파일을 식별하고, 캐시를 무효화한다(seed 본문 변경 가능).
2096
+ // event.outputs 는 디스크 경로 목록. entry 청크 = entry stem(`<name>.js`)과 basename 이 일치하는
2097
+ // 출력(splitting 의 entry 청크 네이밍). dev 모드는 content-hash off 라 stem 그대로.
2098
+ function captureLazyState(event) {
2099
+ // 실패한 rebuild(event.success===false)는 무시 — 직전 성공 빌드의 seed/cache 를 유지한다
2100
+ // (clear 하면 다음 요청이 또 실패할 빌드를 돌려 last-good 청크를 잃는다). onReady event 는
2101
+ // success 필드가 없어(undefined) 통과한다.
2102
+ if (!lazyMode || !event || event.success === false) return;
2103
+ // 정밀 캐시 무효화(#4062 후속) — 매 rebuild 전체 clear 하면 무관한 편집에도 멀쩡한 lazy
2104
+ // 청크가 버려져 다음 요청이 cold build()(store 없는 단발이라 ~수초)를 돈다. 대신 변경 파일
2105
+ // (event.changed, 절대경로)이 *닿은* 청크만 버린다 — 캐시 항목의 moduleIds(청크 내 모듈
2106
+ // 절대경로)와 교집합이 있을 때만. graph 모양이 바뀐 rebuild(graphChanged=모듈 추가/제거)는
2107
+ // moduleIds 가 stale 일 수 있어(신규 파일이 옛 moduleIds 에 없음) 전체 clear 로 fallback.
2108
+ // changed 가 없으면(onReady 등) 보수적으로 전체 clear.
2109
+ const changedAbs =
2110
+ Array.isArray(event.changed) && event.changed.length > 0
2111
+ ? new Set(event.changed.map((p) => resolve(p)))
2112
+ : null;
2113
+ if (event.graphChanged || !changedAbs) {
2114
+ lazyChunkCache.clear();
2115
+ } else {
2116
+ for (const [hash, entry] of lazyChunkCache) {
2117
+ const ids = entry.moduleIds;
2118
+ // moduleIds 미보유(방어) 또는 변경 파일과 교집합 → 버림.
2119
+ if (!Array.isArray(ids) || ids.some((id) => changedAbs.has(resolve(id)))) {
2120
+ lazyChunkCache.delete(hash);
2121
+ }
2122
+ }
2123
+ }
2124
+ // epoch 증가는 항상 — 진행 중 on-demand build 가 이 무효화를 넘겨 stale 바이트를 재캐시하지
2125
+ // 못하게(in-flight race backstop). 정밀 delete 와 직교(어떤 항목을 버리든 race 가드는 유지).
2126
+ lazyEpoch++;
2127
+ // seed 맵은 event.lazySeeds 가 *실제로 올 때만* 교체한다. 일반 편집(동적 import 를 가진
2128
+ // 모듈이 cache-hit)은 native 가 graph.lazy_seeds 를 다시 안 쌓아 lazySeeds 가 undefined 로
2129
+ // 온다 — 무조건 clear 하면 직전 유효 맵을 날려(PR-B-2 보다 나쁨) on-demand 라우트가 죽는다.
2130
+ // 새 seed 집합(동적 import 추가/제거 = importer 재파싱)일 때만 clear+repopulate.
2131
+ // (lazyEntryFile 의 `if (entry)` 보존과 같은 원리.)
2132
+ if (Array.isArray(event.lazySeeds)) {
2133
+ lazySeedMap.clear();
2134
+ for (const s of event.lazySeeds) {
2135
+ if (s && typeof s.pathHash === 'string' && typeof s.path === 'string') {
2136
+ lazySeedMap.set(s.pathHash, s.path);
2137
+ }
2138
+ }
2139
+ }
2140
+ const entryStem = basename(opts.entryPoints[0] ?? '', extname(opts.entryPoints[0] ?? ''));
2141
+ // event.outputs 는 outdir 기준 bare 파일명("main.js") — serveDir 로 절대화한다(이미 절대면 그대로).
2142
+ const outputs = (Array.isArray(event.outputs) ? event.outputs : [])
2143
+ .filter((p) => typeof p === 'string')
2144
+ .map((p) => resolve(serveDir, p));
2145
+ let entry = outputs.find((p) => basename(p, '.js') === entryStem) ?? null;
2146
+ // fallback: entry stem 매칭 실패 시 `__zntc_load_chunk` 를 포함한 .js 출력(동적 import 보유 청크).
2147
+ if (!entry) {
2148
+ for (const p of outputs) {
2149
+ if (!p.endsWith('.js')) continue;
2150
+ try {
2151
+ if (readFileSync(p, 'utf8').includes('__zntc_load_chunk(')) {
2152
+ entry = p;
2153
+ break;
2154
+ }
2155
+ } catch {
2156
+ // 읽기 실패 — skip
2157
+ }
2158
+ }
2159
+ }
2160
+ // dev rebuild 는 skip_bundle_output 라 event.outputs 가 비어있을 수 있다(#3796). entry 를
2161
+ // 못 찾았으면 직전 값을 유지(entry 청크 파일명은 stem 고정이라 안정 — 매 요청 readFileSync 라
2162
+ // 내용은 자동 fresh). 찾았을 때만 갱신.
2163
+ if (entry) lazyEntryFile = entry;
2164
+ }
2165
+
2166
+ // #4062 PR-B-2 — lazy on-demand 라우트. 반환 null = lazy 라우트가 처리 안 함(기존 handleRequest 로).
2167
+ // ① `/bundle.js` (served index.html 이 참조) → lazy entry 청크 alias.
2168
+ // ② `/<stem>-<8hex>.js` → seed 역참조 후 그 seed 만 force-parse 한 단발 build() 로 동적 청크 생성.
2169
+ async function tryServeLazy(reqUrl) {
2170
+ if (!lazyMode) return null;
2171
+ let pathname = new URL(reqUrl, 'http://localhost').pathname;
2172
+ if (base && base !== '/' && pathname.startsWith(base)) {
2173
+ pathname = '/' + pathname.slice(base.length);
2174
+ }
2175
+ // ① entry alias — served index.html 의 `/bundle.js` 를 lazy entry 청크로.
2176
+ if ((pathname === '/bundle.js' || pathname === '/index.js') && lazyEntryFile) {
2177
+ try {
2178
+ return {
2179
+ status: 200,
2180
+ body: readFileSync(lazyEntryFile),
2181
+ type: 'application/javascript',
2182
+ };
2183
+ } catch {
2184
+ return null; // 파일 사라짐 — 정적 fallback
2185
+ }
2186
+ }
2187
+ // ② on-demand 동적 청크.
2188
+ const m = pathname.match(/-([0-9a-f]{8})\.js$/);
2189
+ if (!m) return null;
2190
+ const pathHash = m[1];
2191
+ const seedPath = lazySeedMap.get(pathHash);
2192
+ if (!seedPath) return null; // 알 수 없는 hash — 정적 자산일 수 있어 fallback
2193
+ // #4079 PR-3 (dev materialize) — 이 seed 가 방문됐으니 watch worker 가 그것을 force-parse 해
2194
+ // 정식 청크로 emit(이후 정적 서빙) + transitive deps 를 감시(깊은 편집 HMR + warm 재빌드)하게
2195
+ // 한다. native 가 dedup 하므로 매 요청 호출 무해. PR-1 의 path-hash 안정 이름 덕에 on-demand
2196
+ // 청크 URL ↔ watch emit 청크 URL 이 동일해 전환이 매끄럽다. 아래 on-demand build 는 첫 요청
2197
+ // 즉시성용(watch rebuild 는 ≤200ms 비동기). 구 native 바이너리면 메서드 부재 → 옵셔널 no-op.
2198
+ nativeWatchHandle?.requestLazySeed?.(seedPath);
2199
+ const cached = lazyChunkCache.get(pathHash);
2200
+ if (cached) return cached;
2201
+ if (!lazyOnDemandOpts) return null;
2202
+ // 같은 청크 동시 요청은 진행 중 build 를 공유(coalesce).
2203
+ const existing = lazyInflight.get(pathHash);
2204
+ if (existing) return existing;
2205
+ // tail 에 체인 → on-demand build 들을 직렬화(동시 native build()+watch worker 재진입 회피).
2206
+ const job = lazyBuildTail.then(async () => {
2207
+ // 큐 대기 중 cache 가 채워졌으면(다른 동일 요청이 먼저 완료) 그대로 재사용.
2208
+ const c = lazyChunkCache.get(pathHash);
2209
+ if (c) return c;
2210
+ // build 시작 직전 세대 캡처 — 완료 후 변동(=빌드 도중 rebuild) 시 캐시 오염 방지.
2211
+ const epoch = lazyEpoch;
2212
+ try {
2213
+ const r = await build({ ...lazyOnDemandOpts, lazyForceParse: [seedPath] });
2214
+ if (r.errors && r.errors.length > 0) return null;
2215
+ // seed 모듈을 포함한 청크를 moduleIds 로 찾는다(force-parse 라 그 seed 가 어느 청크에 인라인됨).
2216
+ const chunk = (r.outputFiles ?? []).find(
2217
+ (f) => Array.isArray(f.moduleIds) && f.moduleIds.includes(seedPath),
2218
+ );
2219
+ if (!chunk) return null;
2220
+ const result = {
2221
+ status: 200,
2222
+ body: chunk.contents,
2223
+ type: 'application/javascript',
2224
+ // 정밀 무효화용 — 이 청크에 들어간 모듈 절대경로 집합. rebuild 시 event.changed 와
2225
+ // 교집합이 있을 때만 이 항목을 버린다(무관한 편집엔 보존 → cold 재빌드 회피).
2226
+ moduleIds: Array.isArray(chunk.moduleIds) ? chunk.moduleIds : [],
2227
+ };
2228
+ // 빌드 도중 rebuild 가 끼지 않았을 때만 캐시(epoch 불변). 끼었으면 이 결과는 옛 소스
2229
+ // 기반이라 캐시하지 않고(다음 요청이 fresh 재빌드) 이번 응답으로만 반환.
2230
+ if (lazyEpoch === epoch) lazyChunkCache.set(pathHash, result);
2231
+ return result;
2232
+ } catch (err) {
2233
+ console.error('[serve] lazy chunk build failed:', err);
2234
+ return null;
2235
+ }
2236
+ });
2237
+ lazyBuildTail = job.catch(() => {}); // tail 은 reject 흡수(다음 build 가 멈추지 않게).
2238
+ lazyInflight.set(pathHash, job);
2239
+ try {
2240
+ return await job;
2241
+ } finally {
2242
+ lazyInflight.delete(pathHash);
2243
+ }
2244
+ }
2245
+
1747
2246
  const useTls = opts.certfile && opts.keyfile;
1748
2247
 
2248
+ // #3796/#3798 root-cause — watch handle 을 HTTP listen 전에 띄움. cold-start runBundle 제거
2249
+ // 후 watch worker 가 outdir 출력 + appDev hooks 담당. HTTP listen 은 watchReadyPromise 까지
2250
+ // wait → server listen 시점에 outdir 채워진 상태 (race-free).
2251
+ if (opts.bundle && opts.watch && appDev && hmr) {
2252
+ // app dev path (appDev 활성) — caller-pre-warm 으로 prepare 가 처리한
2253
+ // css plugin 을 dispatcher 에서 제거 필요. bundle 모드 (appDev=null) 는
2254
+ // filter false (사용자 명시 css() 가 dispatcher 에 정상 전달).
2255
+ const watchBuildOpts = await buildBundleOptions(opts, config, {
2256
+ filterCallerPreWarmCss: true,
2257
+ });
2258
+ watchBuildOpts.devMode = true;
2259
+ // #4062 PR-B-2 — lazy on-demand 활성화. lazy 동적 청크는 (a) code splitting 으로 분리되고
2260
+ // (b) IIFE registry(`__zntc_require`/`__zntc_load_chunk`) 로 로드되어야 한다. dev 단일파일
2261
+ // 기본(applySingleFileDynamicImportDefault 가 splitting 미설정 시 inlineDynamicImports=true)
2262
+ // 을 명시적으로 끄고 splitting+iife 를 강제한다. on-demand 단발 build() 템플릿도 동일 설정으로
2263
+ // 준비(watch callback/outdir 제거 — build() 는 outdir 없으면 in-memory outputFiles 반환).
2264
+ if (lazyMode) {
2265
+ watchBuildOpts.splitting = true;
2266
+ watchBuildOpts.inlineDynamicImports = false;
2267
+ watchBuildOpts.lazyCompilation = true;
2268
+ watchBuildOpts.format = 'iife';
2269
+ lazyOnDemandOpts = {
2270
+ ...watchBuildOpts,
2271
+ splitting: true,
2272
+ inlineDynamicImports: false,
2273
+ lazyCompilation: true,
2274
+ format: 'iife',
2275
+ };
2276
+ delete lazyOnDemandOpts.onReady;
2277
+ delete lazyOnDemandOpts.onRebuild;
2278
+ // outdir/outfile 둘 다 제거 → build() 가 write skip(in-memory outputFiles)해 watch 의
2279
+ // 디스크 출력을 매 요청마다 clobber 하지 않게 한다(/code-review: outfile 누락 footgun).
2280
+ delete lazyOnDemandOpts.outdir;
2281
+ delete lazyOnDemandOpts.outfile;
2282
+ delete lazyOnDemandOpts.watch;
2283
+ }
2284
+ // issue #3858 — appDev path 에서 watchFolders 자동 = app root. 사용자가 직접
2285
+ // .css 파일을 import 없이 만들었을 때 (graph 외) 신규 file 의 add 감지를
2286
+ // native watcher (TrackedFileSet 의 dir-watch) 가 처리하도록 root_dir 등록.
2287
+ // opts._appWatchRoot 는 runAppDev 가 stash 한 사용자 source code root (outdir
2288
+ // 아닌 진짜 src/ 컨테이너). 사용자 명시 watchFolders 가 있으면 union — 우선순위 유지.
2289
+ let reconcileOutdir = null;
2290
+ if (opts._appWatchRoot) {
2291
+ const autoWatchRoot = resolve(opts._appWatchRoot);
2292
+ const existing = Array.isArray(watchBuildOpts.watchFolders)
2293
+ ? watchBuildOpts.watchFolders
2294
+ : [];
2295
+ if (!existing.includes(autoWatchRoot)) {
2296
+ watchBuildOpts.watchFolders = [...existing, autoWatchRoot];
2297
+ }
2298
+ // issue #3858 — reconcileOutdir factory 생성 (closure 가 prev/current raw
2299
+ // .css set 추적). onRebuild 마다 호출 — sass/.module/.chunk emit 영향 0.
2300
+ if (opts.outdir) {
2301
+ reconcileOutdir = createReconcileOutdirCss(autoWatchRoot, resolve(opts.outdir));
2302
+ }
2303
+ }
2304
+ watchBuildOpts.onReady = async (event) => {
2305
+ try {
2306
+ // #3799 root-cause — initial build 의 diagnostics 의 error 도 reportError.
2307
+ if (event && event.errors && event.errors.length > 0) {
2308
+ hmr.reportError(
2309
+ event.errors.map((e) => ({ text: e.message, location: { file: e.file } })),
2310
+ );
2311
+ } else {
2312
+ hmr.clearError();
2313
+ }
2314
+ captureLazyState(event); // #4062 — lazySeed 맵 + entry 청크 식별 (lazyMode 아니면 no-op)
2315
+ // #3796 — event.outputs (path 목록) 를 BundleResult shape 으로 변환해 injectBundleCssLinks.
2316
+ const mockResult = {
2317
+ outputFiles: (event && event.outputs ? event.outputs : []).map((p) => ({ path: p })),
2318
+ };
2319
+ appDev.injectBundleCssLinks(mockResult);
2320
+ await appDev.afterBundle();
2321
+ } catch (err) {
2322
+ console.error('[serve] initial appDev hooks failed:', err);
2323
+ } finally {
2324
+ markWatchReady();
2325
+ }
2326
+ };
2327
+ watchBuildOpts.onRebuild = (event) => {
2328
+ // dev_mode + collect_module_codes 인 incremental rebuild 는 skip_bundle_output 자동
2329
+ // 활성이라 outdir 갱신 안 함. graphChanged 시 추가 runBundle 호출 (plugin.setup 2회
2330
+ // 시점 — cold-start 는 watch 1회만).
2331
+ void (async () => {
2332
+ try {
2333
+ // #4062 PR-C-1 — rebuild 마다 lazy 상태 갱신: seed 맵을 event.lazySeeds 로 다시 채우고
2334
+ // (신규 동적 import 추가/제거 반영) 청크 캐시를 무효화한다. captureLazyState 가 실패한
2335
+ // rebuild 는 skip, outputs 가 비면(skip_bundle_output) entry 는 유지한다. (PR-B-2 는
2336
+ // onRebuild 가 lazySeeds 미노출이라 cache.clear 만 했음 — PR-C-1 에서 native 가 노출.)
2337
+ captureLazyState(event);
2338
+ // issue #3858 — native onRebuild 의 cssChanges 분기는 PR #3859 머지로
2339
+ // 도입됐으나, drain (fs.watch) 가 이미 같은 fs event 받아 rebuildAppDevCss
2340
+ // (syncDirty + afterBundle, PostCSS incremental) 로 처리. dual watch race
2341
+ // 의 source 였음 + prepare 호출이 PostCSS 를 tempRoot 전체 reprocess →
2342
+ // "processed N" 회귀 (dev-hmr/postcss test fail). drain 단일 처리로 통합.
2343
+ // graph 외 .css ADD case (#3858 핵심) 는 drain 의 fs.watch (recursive)
2344
+ // 가 신규 add event 받아 처리 — native 의 cssChanges 분기 redundant.
2345
+ // 단 graphChanged 시 outdir 갱신은 아래 runBundle 분기가 cover.
2346
+ // issue #3858 — 매 rebuild 마다 outdir reconcile (raw root .css diff
2347
+ // 후 사라진 path 만 outdir unlink). closure factory 의 prev/current
2348
+ // diff 로 outdir 의 sass/.module/.chunk emit 영향 0.
2349
+ if (event && event.success && reconcileOutdir) {
2350
+ reconcileOutdir();
2351
+ }
2352
+ if (event && event.success && event.graphChanged) {
2353
+ try {
2354
+ const r = await runBundle(opts, config);
2355
+ if (r.errors.length === 0) appDev.injectBundleCssLinks(r);
2356
+ } catch (cssErr) {
2357
+ console.error('[serve] graph-change outdir rebuild failed:', cssErr);
2358
+ }
2359
+ }
2360
+ const annotated =
2361
+ event && event.success && event.updates && event.updates.length > 0
2362
+ ? {
2363
+ ...event,
2364
+ updates: event.updates.map((u) => ({
2365
+ ...u,
2366
+ code: `${u.code}\n//# sourceMappingURL=${HMR_MAP_PATH}${encodeURIComponent(u.id)}\n`,
2367
+ })),
2368
+ }
2369
+ : event;
2370
+ const outcome = web.broadcastRebuildEvent(hmr, annotated);
2371
+ // #4079: lazy(splitting) dev 는 분할 청크가 dev HMR 모듈 레지스트리에 없어 module-level
2372
+ // HMR 이 불가하다(dev init lowering 이 production init 로 fallback). 그래서 lazy 청크
2373
+ // 안의 모듈을 편집하면 rebuild 는 변경을 감지(event.changed)하지만 module update 를
2374
+ // 못 만들어 broadcastRebuildEvent 가 'noop' 으로 끝나 화면이 안 갱신된다 → full reload 로 갈음.
2375
+ // 단 (a) 'update'(메인 번들 모듈 변경 — 진짜 HMR 적용 가능)와 'full-reload'(graphChanged)
2376
+ // /'error' 는 제외, (b) CSS 변경은 drain 의 CSS-only HMR(live <link> swap)이 처리하므로
2377
+ // *비-CSS* 변경이 하나라도 있을 때만 full reload(전부 CSS 면 CSS HMR 보존).
2378
+ // errors 가 있으면(partial build) overlay 가 latch 된 상태 — full reload 는 overlay 를
2379
+ // 숨기고 깨진 상태로 리로드하므로 skip(에러는 그대로 보여줌, 다음 성공 빌드가 갱신).
2380
+ if (lazyMode && event.success && outcome === 'noop' && !event.errors?.length) {
2381
+ const hasNonCss = (event.changed ?? []).some(
2382
+ (p) => !/\.(css|scss|sass|less|styl|pcss|postcss)$/i.test(p),
2383
+ );
2384
+ if (hasNonCss) hmr.broadcast({ type: web.HMR_MSG.FullReload, timestamp: Date.now() });
2385
+ }
2386
+ } catch (err) {
2387
+ console.error('[serve] hmr broadcast error:', err);
2388
+ }
2389
+ })();
2390
+ };
2391
+ try {
2392
+ nativeWatchHandle = watch(watchBuildOpts);
2393
+ } catch (err) {
2394
+ console.error('[serve] native watch failed to start (incremental HMR disabled):', err);
2395
+ markWatchReady();
2396
+ }
2397
+ }
2398
+
2399
+ // #3796 — HTTP listen 을 watch.onReady 까지 wait (5초 timeout 으로 deadlock 방어).
2400
+ await Promise.race([watchReadyPromise, new Promise((r) => setTimeout(r, 5000))]);
2401
+
1749
2402
  if (isBun) {
1750
2403
  // Bun.serve
1751
2404
  const serveOpts = {
1752
2405
  port: opts.port,
1753
2406
  hostname: opts.host,
1754
- fetch(req, server) {
2407
+ async fetch(req, server) {
1755
2408
  const url = new URL(req.url);
1756
2409
  // /__hmr WebSocket upgrade — Bun-native API 사용 (Node 분기는 server.on('upgrade')).
2410
+ // upgrade 는 첫 await 전에 동기 실행돼 async fetch 여도 안전.
1757
2411
  if (hmr && url.pathname === APP_DEV_HMR_WS_PATH) {
1758
2412
  if (server.upgrade(req)) return undefined;
1759
2413
  return new Response('Upgrade required', { status: 426 });
@@ -1765,6 +2419,18 @@ async function runServe(opts, config, { appDev = null } = {}) {
1765
2419
  }
1766
2420
  }
1767
2421
 
2422
+ // #4062 PR-B-2 — lazy 라우트(entry alias + on-demand 동적 청크). null = 정적 처리로.
2423
+ const lazy = await tryServeLazy(req.url);
2424
+ if (lazy) {
2425
+ return new Response(lazy.body, {
2426
+ status: lazy.status,
2427
+ headers: {
2428
+ 'Content-Type': lazy.type,
2429
+ 'Access-Control-Allow-Origin': '*',
2430
+ },
2431
+ });
2432
+ }
2433
+
1768
2434
  const { status, body, type } = handleRequest(req.url, req.headers.get('accept') ?? '');
1769
2435
  return new Response(body, {
1770
2436
  status,
@@ -1819,6 +2485,17 @@ async function runServe(opts, config, { appDev = null } = {}) {
1819
2485
  }
1820
2486
  }
1821
2487
 
2488
+ // #4062 PR-B-2 — lazy 라우트(entry alias + on-demand 동적 청크). null = 정적 처리로.
2489
+ const lazy = await tryServeLazy(req.url);
2490
+ if (lazy) {
2491
+ res.writeHead(lazy.status, {
2492
+ 'Content-Type': lazy.type,
2493
+ 'Access-Control-Allow-Origin': '*',
2494
+ });
2495
+ res.end(Buffer.isBuffer(lazy.body) ? lazy.body : Buffer.from(lazy.body));
2496
+ return;
2497
+ }
2498
+
1822
2499
  const { status, body, type } = handleRequest(req.url, req.headers.accept ?? '');
1823
2500
  res.writeHead(status, {
1824
2501
  'Content-Type': type,
@@ -1863,6 +2540,20 @@ async function runServe(opts, config, { appDev = null } = {}) {
1863
2540
  }
1864
2541
 
1865
2542
  async function closeServerForRestart() {
2543
+ // #3779 follow-up — native watch worker thread 가 child process spawn 후에도 살아남으면
2544
+ // outdir 출력이 부모/자식 두 곳에서 일어나 race. emitRestartAfter 의 child spawn 전에 stop.
2545
+ // stop 자체가 throw 해도 server.close 는 시도 (HTTP 포트 해제 우선).
2546
+ // #3803 — stop() throw 시 nativeWatchHandle 을 null 하지 않고 유지 → 다음 호출에서 retry
2547
+ // 가능. 정상 path 에서만 null 로 갱신. idempotent stop (#3794 의 napi_remove_wrap) 라
2548
+ // 다음 시도도 안전.
2549
+ if (nativeWatchHandle) {
2550
+ try {
2551
+ nativeWatchHandle.stop();
2552
+ nativeWatchHandle = null;
2553
+ } catch (err) {
2554
+ console.error('[serve] native watch stop failed (will retry on next invocation):', err);
2555
+ }
2556
+ }
1866
2557
  if (!serverHandle) return;
1867
2558
  if (typeof serverHandle.stop === 'function') {
1868
2559
  await serverHandle.stop();
@@ -1889,8 +2580,26 @@ async function runServe(opts, config, { appDev = null } = {}) {
1889
2580
  let rebuilding = false;
1890
2581
  const dirty = new Set();
1891
2582
 
2583
+ // #3796 — native watch handle 은 HTTP listen 전에 띄움 (위쪽 코드). 이 블록은
2584
+ // fsWatch + drain (CSS / sass / postcss / restart) 만 — JS 변경은 native watch 가 단독 처리.
2585
+
1892
2586
  async function rebuildAppDevCss(changedPath) {
1893
- await appDev.afterBundle({ changedPath });
2587
+ // issue #3861 — drain (fs.watch) prepare skip 시 tempRoot 가 raw root
2588
+ // 와 동기 안 되어 afterBundle 의 mirror 가 stale .css 를 outdir 에 다시
2589
+ // write → reconcileOutdirCss 의 unlink 무효화 (dual watch race).
2590
+ // syncDirty 는 prepare 의 syncDirtyFilesIntoTempRoot 만 호출 — PostCSS
2591
+ // 재실행 skip (prepare 가 full PostCSS reprocess 라 단일 CSS modify 시
2592
+ // 전체 .css 처리 → "processed N" 회귀, dev-hmr/postcss test fail). PostCSS
2593
+ // incremental 처리는 아래 afterBundle 의 changedPath 분기가 cover.
2594
+ appDev.syncDirty([changedPath]);
2595
+ try {
2596
+ await appDev.afterBundle({ changedPath });
2597
+ } catch (cssErr) {
2598
+ if (opts.logLevel !== 'silent') {
2599
+ console.error('[serve] css afterBundle failed:', cssErr);
2600
+ }
2601
+ throw cssErr; // drain outer catch (line 2460) 가 hmr.reportThrownError.
2602
+ }
1894
2603
  hmr?.clearError();
1895
2604
  hmr?.broadcast({
1896
2605
  type: HMR_MSG.CssUpdate,
@@ -1900,6 +2609,9 @@ async function runServe(opts, config, { appDev = null } = {}) {
1900
2609
  if (opts.logLevel !== 'silent') console.error('[serve] css updated');
1901
2610
  }
1902
2611
 
2612
+ // #3779 — sass partial / 다중 sass 변경 (drain 의 sass 분기) 전용. SCSS dependents
2613
+ // 까지 재컴파일해야 하므로 full pipeline + FullReload. JS module 변경은 native watch
2614
+ // handle 이 단독 처리 — 이 함수는 더 이상 JS 분기에서 호출되지 않는다.
1903
2615
  async function rebuildAppDevFull(dirtyPaths = null) {
1904
2616
  const prepared = await appDev.prepare(dirtyPaths);
1905
2617
  opts.entryPoints = [prepared.entryPath];
@@ -1947,6 +2659,12 @@ async function runServe(opts, config, { appDev = null } = {}) {
1947
2659
  } else {
1948
2660
  await rebuildAppDevFull();
1949
2661
  }
2662
+ } else if (paths.some((p) => p.endsWith('.scss') || p.endsWith('.sass'))) {
2663
+ // #71: sass 변경인데 fast-path 자격 없음(다른 root scss 가 @import 하는 partial,
2664
+ // 또는 다중 sass 변경) → full pipeline rebuild 로 dirty 의 transitive dependents 까지
2665
+ // 재컴파일. CSS-only 분기(afterBundle = postcss only)는 sass 를 재컴파일하지 않아
2666
+ // partial 변경 시 root scss 가 stale 로 남는다(code-review max 적발).
2667
+ await rebuildAppDevFull(paths);
1950
2668
  } else if (cssChanges.length === 1 && paths.length === 1) {
1951
2669
  await rebuildAppDevCss(cssChanges[0]);
1952
2670
  } else {
@@ -1956,7 +2674,33 @@ async function runServe(opts, config, { appDev = null } = {}) {
1956
2674
  if (opts.logLevel !== 'silent') console.error('[serve] css updated');
1957
2675
  }
1958
2676
  } else {
1959
- await rebuildAppDevFull(paths);
2677
+ // #3797 — paths 의 종류에 따라 3분기:
2678
+ // - 전부 CSS-derived (CSS Module / Sass Module / postcss): native watch 의 module
2679
+ // graph 밖이라 incremental update 트리거 안 됨 → full pipeline + FullReload.
2680
+ // - JS module (.ts/.tsx/.js 등) 섞임: native watch 가 단독 처리. drain noop
2681
+ // (중복 컴파일/output race 회피).
2682
+ // - 그 외 (HTML / JSON / static asset 등 native watch graph 밖 + non-CSS): fallback
2683
+ // FullReload broadcast. pre-#3779 의 unconditional rebuildAppDevFull 회귀 가드.
2684
+ // #3801 — appDev 의 isCssLikeChange 가 단일 진실 소스. inline literal endsWith
2685
+ // 가 `.less` (미지원) 포함하거나 `.styl/.pcss` 누락하던 drift 회귀 방지.
2686
+ const isJsModule = (p) =>
2687
+ p.endsWith('.ts') ||
2688
+ p.endsWith('.tsx') ||
2689
+ p.endsWith('.js') ||
2690
+ p.endsWith('.jsx') ||
2691
+ p.endsWith('.mjs') ||
2692
+ p.endsWith('.cjs');
2693
+ const allCssDerived = paths.every((p) => appDev.isCssLikeChange(p));
2694
+ const hasJs = paths.some(isJsModule);
2695
+ if (allCssDerived) {
2696
+ await rebuildAppDevFull(paths);
2697
+ } else if (!hasJs) {
2698
+ // HTML/JSON/asset 변경 — native watch 가 안 보는 변경에 대한 reload 신호.
2699
+ hmr?.clearError();
2700
+ hmr?.broadcast({ type: HMR_MSG.FullReload, timestamp: Date.now() });
2701
+ if (opts.logLevel !== 'silent') console.error('[serve] file changed, full reload');
2702
+ }
2703
+ // hasJs=true: native watch handle 의 onRebuild 가 단독 broadcast. drain noop.
1960
2704
  }
1961
2705
  }
1962
2706
  } catch (err) {