@zntc/core 0.1.1 → 0.1.3

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,11 @@ function parseArgs(argv) {
247
258
  bundle: false,
248
259
  watch: false,
249
260
  watchJson: false,
250
- watchDelay: 100,
261
+ // SCALAR_KEYS 머지 키 — config.watchDelay 가 적용되려면 default 가 `undefined`
262
+ // 여야 한다(머지 조건 `opts[key] === undefined`). 16ms 디바운스 default 는
263
+ // 소비처(setTimeout)에서 `?? 16` 으로 보정(#4223 — 이전 `16` default 라 config
264
+ // watchDelay 가 silent 무시되던 schema-drift 가드 발동).
265
+ watchDelay: undefined,
251
266
  serve: false,
252
267
  serveDir: '.',
253
268
  port: undefined,
@@ -288,6 +303,9 @@ function parseArgs(argv) {
288
303
  entryNames: undefined,
289
304
  chunkNames: undefined,
290
305
  assetNames: undefined,
306
+ // #4466 — undefined 여야 native default(4096)를 덮지 않는다. 명시 0 = 인라인 끔.
307
+ assetInlineLimit: undefined,
308
+ cssNames: undefined,
291
309
  jsx: undefined,
292
310
  jsxDev: false,
293
311
  jsxFactory: undefined,
@@ -332,6 +350,9 @@ function parseArgs(argv) {
332
350
  resolveExtensions: [],
333
351
  mainFields: [],
334
352
  rnPlatform: undefined,
353
+ rnVersion: undefined,
354
+ diskCache: false, // #4438 디스크 캐시 활성 (--disk-cache)
355
+ cacheDir: undefined, // #4438 디스크 캐시 경로 (--cache-dir)
335
356
  jsxInJs: false,
336
357
  outExtensionJs: undefined,
337
358
  sourceRoot: undefined,
@@ -393,6 +414,16 @@ function parseArgs(argv) {
393
414
  opts.serve = true;
394
415
  opts.bundle = true;
395
416
  opts.watch = true;
417
+ // #3793 — `zntc dev` 는 incremental HMR 활성 (__zntc_apply_update / __esm register
418
+ // 주입) 이 필수. 명시 안 set 시 initial bundle 이 production 모드로 빌드돼 HMR
419
+ // 런타임 누락 → broadcast 된 Update 가 client 에서 fallback reload. user 가
420
+ // 명시적으로 `--dev=false` 줘서 override 하기 전엔 dev 모드 default 보장.
421
+ opts.devMode = true;
422
+ // RFC_LAZY_DEV_MODULE_HMR PR-5 / web React Fast Refresh: dev 는 reactRefresh 기본 on.
423
+ // 번들러는 React 컴포넌트가 있는 모듈에만 $RefreshReg$/accept 를 emit(비-React 무해).
424
+ // 브라우저 런타임 preamble 주입은 runServe 가 react 존재 시에만 한다(비-React 노이즈 0).
425
+ // 명시 flag(`--react-refresh=false`)는 아래 flag 파싱이 덮어쓴다.
426
+ if (opts.reactRefresh === undefined) opts.reactRefresh = true;
396
427
  } else if (appCommand === 'build') {
397
428
  opts.bundle = true;
398
429
  } else if (appCommand === 'preview') {
@@ -662,16 +693,137 @@ function getAutoConfigSearchDir(opts) {
662
693
  return process.cwd();
663
694
  }
664
695
 
696
+ /**
697
+ * RFC #3833 v3 D1a'' (caller-side pre-warm) helper — runAppBuild/runAppDev 가
698
+ * 공유. 사용자 explicit `plugins: [css({postcss:{...}})]` 의 옵션을 추출해
699
+ * prepare 단계의 `postcssOverride` 로 전달. buildAppSync 의 sync dispatcher ×
700
+ * async cssOnLoad 충돌 회피 — Vite/esbuild 의 main thread pre-process → sync
701
+ * bundle 패턴.
702
+ *
703
+ * 분기:
704
+ * 1. disabled:true → explicit PostCSS 끄기 (override={plugins:[]} 로
705
+ * auto-discover 도 차단, prepare 가 length 0 skip)
706
+ * 2. postcss 명시 → presence check, plugins ?? [] 정규화. options-only
707
+ * override 도 explicit no-op (Vite inline override
708
+ * 시맨틱)
709
+ * 3. 둘 다 없으면 → override=null → prepare 가 auto-discover path
710
+ *
711
+ * **findLast**: 미래 default `css()` prepend 와 user override 가 동시 존재 시
712
+ * 마지막 등록이 winner (Vite plugins 순서 의미). sentinel `__cssOptions` 는
713
+ * runtime 위장 방어 0 — 의도 매치용.
714
+ *
715
+ * @param {Array<{name?:string,__cssOptions?:object}>} plugins
716
+ * @returns {{plugins: unknown[], options: Record<string,unknown> | undefined} | null}
717
+ */
718
+ /**
719
+ * sentinel `__cssOptions` 보유 css plugin 의 raw options 추출. 매치 안 되면 null.
720
+ * extractCssPostcssOverride / extractCssAutoDiscoverRoot 의 공통 helper.
721
+ */
722
+ function extractCssOptions(plugins) {
723
+ const cssPlugin = plugins.findLast(
724
+ // 두 항 모두 optional chain — predicate 순서 변경 시 null/undefined deref 회귀
725
+ // 차단 (/code-review max #1 latent finding).
726
+ (p) => p?.name === '@zntc/web/css' && p?.__cssOptions !== undefined,
727
+ );
728
+ return cssPlugin?.__cssOptions ?? null;
729
+ }
730
+
731
+ function extractCssPostcssOverride(plugins) {
732
+ const opts = extractCssOptions(plugins);
733
+ if (!opts) return null;
734
+ if (opts.disabled === true) return { plugins: [], options: undefined };
735
+ if (opts.postcss) {
736
+ return {
737
+ plugins: opts.postcss.plugins ?? [],
738
+ options: opts.postcss.options,
739
+ // issue #3851 — css({root}) 의 root 가 caller-pre-warm path 에서 silent
740
+ // ignore 였던 회귀 fix. override path 의 postcss require base 로 routing.
741
+ // mode 는 override.plugins 명시 시 loadPostcssConfig 미호출이라 무의미 —
742
+ // routing 안 함 (사용자가 root 와 mode 둘 다 명시한 경우 mode 는 onLoad
743
+ // 의 dispatcher path 에서만 의미 있었음, caller-pre-warm 에선 dead).
744
+ root: opts.root,
745
+ };
746
+ }
747
+ return null;
748
+ }
749
+
750
+ /**
751
+ * issue #3857 — `css({root})` 단독 명시 (postcss override 없이) 시 root 가
752
+ * auto-discover path 의 findPostcssConfig 시작 base 로 사용되게 caller 가 전달.
753
+ * monorepo edge: postcss.config 가 monorepo root 에 있고 app 이 sub-package
754
+ * 인 경우 사용자가 root='/monorepo-root' 명시.
755
+ *
756
+ * - opts.disabled 면 null (자동발견 차단은 disabled true 의 책임)
757
+ * - opts.postcss truthy 면 null (override path 가 root 직접 routing)
758
+ * - opts.root 만 있으면 그것 반환
759
+ */
760
+ function extractCssAutoDiscoverRoot(plugins) {
761
+ const opts = extractCssOptions(plugins);
762
+ if (!opts || opts.disabled === true) return null;
763
+ if (opts.postcss) return null;
764
+ // /code-review max #3/#4: type/empty guard — non-string 또는 빈 string 거부.
765
+ // findPostcssConfig(non-string) → TypeError, findPostcssConfig('') → cwd 기준
766
+ // wrong-base search. 사용자 invalid 입력은 silent null (auto-discover skip).
767
+ if (typeof opts.root !== 'string' || opts.root.length === 0) return null;
768
+ return opts.root;
769
+ }
770
+
771
+ /**
772
+ * RFC #3833 v3 D1a'' — caller-pre-warm sentinel (`__cssOptions !== undefined`)
773
+ * 가진 css plugin 을 native dispatcher plugin chain 에서 제거.
774
+ *
775
+ * Caller paths:
776
+ * - **runAppBuild**: buildAppSync 의 sync dispatcher 가 async onLoad 받으면
777
+ * syncPluginPromiseFailure → BundleFailed. 본 helper 로 dispatch 차단.
778
+ * - **buildBundleOptions** (runBundle/watch/runServe): native async dispatcher
779
+ * 가 onLoad 호출 → prepare 와 같은 PostCSS 두 번 실행 (double-pass). 본
780
+ * helper 로 dispatch 차단.
781
+ *
782
+ * **runAppDev 는 본 helper 미경유** — controller (createAppDevController) 에
783
+ * postcssOverride 만 전달, plugin chain 자체는 runServe → buildBundleOptions
784
+ * 경로에서 처리. 따라서 dev path 의 filter 는 buildBundleOptions 가 cover.
785
+ *
786
+ * extractCssPostcssOverride 와 동일 sentinel match 조건 (predicate 일관) —
787
+ * drift 위험 차단.
788
+ *
789
+ * @param {Array<{name?:string,__cssOptions?:object}>} plugins
790
+ * @returns {Array<unknown>} caller-pre-warm 활성 css plugin 제거된 새 array
791
+ */
792
+ function dropCallerPreWarmedCssPlugin(plugins) {
793
+ return plugins.filter((p) => !(p?.name === '@zntc/web/css' && p?.__cssOptions !== undefined));
794
+ }
795
+
665
796
  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
- );
797
+ // JS plugin 로드 bundle pipeline 의 buildBundleOptions 와 동일 패턴. app
798
+ // pipeline 도 plugin dispatcher 통과 (#2538 4-4 PR-1).
799
+ const appPlugins = [];
800
+ if (config && Array.isArray(config.plugins)) {
801
+ appPlugins.push(...config.plugins);
802
+ }
803
+ for (const pluginPath of opts.pluginPaths) {
804
+ const absPath = resolve(pluginPath);
805
+ const cfg = await importAndResolveDefault(absPath);
806
+ if (Array.isArray(cfg.plugins)) {
807
+ appPlugins.push(...cfg.plugins);
808
+ } else if (typeof cfg.setup === 'function') {
809
+ appPlugins.push(cfg);
810
+ }
670
811
  }
671
812
  const web = await loadWebModule();
672
813
  const root = resolve(opts.appRoot ?? '.');
673
814
  const outdir = resolve(opts.outdir ?? join(root, 'dist'));
674
815
  if (opts.clean) rmSync(outdir, { recursive: true, force: true });
816
+ // RFC #3833 v3 D1a'' caller-side pre-warm — extractCssPostcssOverride helper 참조.
817
+ const postcssOverride = extractCssPostcssOverride(appPlugins);
818
+ // issue #3857 — css({root}) 단독 명시 시 findPostcssConfig search base
819
+ // (monorepo edge: app 이 sub-package, postcss.config 가 monorepo root).
820
+ const cssAutoDiscoverRoot = extractCssAutoDiscoverRoot(appPlugins);
821
+ // dropCallerPreWarmedCssPlugin: sentinel 가진 css plugin 을 항상 dispatcher 에서
822
+ // 제거 (buildBundleOptions 와 동일 무조건 적용). /code-review max #5: 조건부
823
+ // (postcssOverride truthy 시만) 분기는 비대칭 — 사용자가 sentinel 만 가진
824
+ // plugin (예: `__cssOptions:{someFutureKey}` — extract null) 등록 시
825
+ // BundleFailed 회귀 가능. 무조건 drop 으로 future-key 안전 + 양쪽 path 일관.
826
+ const dispatchPlugins = dropCallerPreWarmedCssPlugin(appPlugins);
675
827
  let pipelineRoot = null;
676
828
  try {
677
829
  const pipeline = await web.prepareAppCssPipelineRoot(
@@ -681,6 +833,7 @@ async function runAppBuild(opts, config, configEnv, _dotenvVars) {
681
833
  opts.logLevel,
682
834
  'build',
683
835
  { fallbackRequire: requireFromCli, cliNodeModules },
836
+ { postcssOverride, cssAutoDiscoverRoot },
684
837
  );
685
838
  pipelineRoot = pipeline?.tempRoot ?? null;
686
839
  const result = buildAppSync({
@@ -701,6 +854,7 @@ async function runAppBuild(opts, config, configEnv, _dotenvVars) {
701
854
  jsxFactory: opts.jsxFactory,
702
855
  jsxFragment: opts.jsxFragment,
703
856
  compiler: config?.compiler,
857
+ plugins: dispatchPlugins.length > 0 ? dispatchPlugins : undefined,
704
858
  });
705
859
  const htmlEnv = loadEnv(
706
860
  configEnv.mode,
@@ -731,14 +885,40 @@ async function runAppDev(opts, config, configEnv, _dotenvVars) {
731
885
  const web = await loadWebModule();
732
886
  const root = resolve(opts.appRoot ?? '.');
733
887
  opts.outdir = opts.outdir || join(root, '.zntc-dev');
734
- const appDev = web.createAppDevController(opts, root, configEnv, {
735
- fallbackRequire: requireFromCli,
736
- cliNodeModules,
737
- });
888
+ // RFC #3833 v3 D1a'' Phase 2: build path 와 동일 plugin walk + helper 추출.
889
+ const devUserPlugins = [];
890
+ if (config && Array.isArray(config.plugins)) devUserPlugins.push(...config.plugins);
891
+ for (const pluginPath of opts.pluginPaths) {
892
+ const absPath = resolve(pluginPath);
893
+ const cfg = await importAndResolveDefault(absPath);
894
+ if (Array.isArray(cfg.plugins)) devUserPlugins.push(...cfg.plugins);
895
+ else if (typeof cfg.setup === 'function') devUserPlugins.push(cfg);
896
+ }
897
+ const appDev = web.createAppDevController(
898
+ {
899
+ ...opts,
900
+ postcssOverride: extractCssPostcssOverride(devUserPlugins),
901
+ // issue #3857 — css({root}) 단독 명시 (postcss override 없이) 시 root 를
902
+ // findPostcssConfig 의 search base 로 전달. monorepo 의 sub-package app
903
+ // 이 monorepo root 의 postcss.config 참조하는 시나리오.
904
+ cssAutoDiscoverRoot: extractCssAutoDiscoverRoot(devUserPlugins),
905
+ },
906
+ root,
907
+ configEnv,
908
+ { fallbackRequire: requireFromCli, cliNodeModules },
909
+ );
738
910
  const prepared = await appDev.prepare();
739
911
 
740
912
  opts.entryPoints = [prepared.entryPath];
741
913
  opts.serveDir = opts.outdir;
914
+ // issue #3858 — runServe 의 watchFolders 자동 set 위해 app root 를 stash.
915
+ // opts.serveDir 은 outdir(`.zntc-dev`) 이라 사용자 source code root 가 아님 —
916
+ // 별도 channel 필요.
917
+ opts._appWatchRoot = root;
918
+ // issue #3852 — runAppDev 가 collect 한 plugin 을 stash → runServe 의
919
+ // buildBundleOptions 가 재import 안 함. ESM cache hit 라 시맨틱 회귀는 0 였지만
920
+ // perf + cache invalidate edge 안전.
921
+ opts._resolvedPlugins = devUserPlugins;
742
922
 
743
923
  return runServe(opts, config, { appDev });
744
924
  }
@@ -762,6 +942,17 @@ async function loadWebModule() {
762
942
  console.error('help: install with `bun add -D @zntc/web` 또는 `npm i -D @zntc/web`.');
763
943
  process.exit(1);
764
944
  }
945
+ // @zntc/web 의 module 평가가 dev-overlay-client.raw.js 를 readFileSync 한다
946
+ // (#2538 4-3). dist 가 incomplete 한 경우 (build:bundle 의 copy 누락 / 일부만
947
+ // 추출된 tarball) 가 ENOENT 로 throw — 친절 핸들 분기.
948
+ if (code === 'ENOENT' && /dev-overlay-client\.raw\.js/.test(message)) {
949
+ console.error('error: @zntc/web 의 dist/dev-overlay-client.raw.js 가 누락됐습니다.');
950
+ console.error('');
951
+ console.error(
952
+ 'help: `bun --cwd <repo>/packages/web run build` 로 재빌드하거나 @zntc/web 을 재설치하세요.',
953
+ );
954
+ process.exit(1);
955
+ }
765
956
  throw err;
766
957
  }
767
958
  })();
@@ -1138,6 +1329,8 @@ function mergeConfigIntoOpts(opts, config) {
1138
1329
  'format',
1139
1330
  'platform',
1140
1331
  'target',
1332
+ 'rnVersion',
1333
+ 'cacheDir', // #4438 디스크 캐시 경로 (NAPI 가 disk_cache_dir 로 해석)
1141
1334
  'banner',
1142
1335
  'footer',
1143
1336
  'globalName',
@@ -1145,6 +1338,8 @@ function mergeConfigIntoOpts(opts, config) {
1145
1338
  'entryNames',
1146
1339
  'chunkNames',
1147
1340
  'assetNames',
1341
+ 'assetInlineLimit', // #4466 asset data URL 인라인 임계값 (숫자 scalar)
1342
+ 'cssNames',
1148
1343
  'jsx',
1149
1344
  'jsxFactory',
1150
1345
  'jsxFragment',
@@ -1176,6 +1371,8 @@ function mergeConfigIntoOpts(opts, config) {
1176
1371
  'runtimePolyfills',
1177
1372
  'coreJs',
1178
1373
  'tokenizeFormat',
1374
+ // #4223 — config.watchDelay 가 머지되도록(opts default=undefined, 소비처 `?? 16`).
1375
+ 'watchDelay',
1179
1376
  ];
1180
1377
  for (const key of SCALAR_KEYS) {
1181
1378
  if (opts[key] === undefined && config[key] !== undefined) {
@@ -1215,6 +1412,7 @@ function mergeConfigIntoOpts(opts, config) {
1215
1412
  // 머지 안 되던 실 BuildOption bool.
1216
1413
  'analyze',
1217
1414
  'devMode',
1415
+ 'diskCache', // #4438 디스크 캐시 활성 (NAPI 가 disk_cache_dir 로 해석)
1218
1416
  ];
1219
1417
  for (const key of BOOL_KEYS) {
1220
1418
  if ((opts[key] === false || opts[key] === undefined) && config[key] === true) {
@@ -1238,7 +1436,10 @@ function mergeConfigIntoOpts(opts, config) {
1238
1436
  // tristate bool: false 가 명시적 의미를 가져서 default 를 undefined 로 두는 키.
1239
1437
  // CLI 가 미지정(undefined)일 때만 config 값(true/false)을 채택한다 (CLI flag 우선).
1240
1438
  // inlineDynamicImports=false 의 single-file 보정은 applySingleFileDynamicImportDefault.
1241
- for (const key of ['inlineDynamicImports']) {
1439
+ // reactRefresh(#4223): `zntc dev` 는 default on(parseArgs 가 opts.reactRefresh=true)
1440
+ // 그 경우 머지 skip(opts defined). build/transpile 은 opts undefined 라 config(true/
1441
+ // false) 채택 — 이전 머지 리스트 누락으로 silent 무시되던 schema-drift 해소.
1442
+ for (const key of ['inlineDynamicImports', 'reactRefresh']) {
1242
1443
  if (opts[key] === undefined && config[key] !== undefined) {
1243
1444
  opts[key] = config[key];
1244
1445
  }
@@ -1299,32 +1500,62 @@ function mergeCliRuntimeTargets(runtimePolyfills, runtimeTargetQueries) {
1299
1500
  return runtimePolyfills;
1300
1501
  }
1301
1502
 
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);
1503
+ /**
1504
+ * `runBundle` / `startBundleWatch` 공유하는 NAPI BuildOptions 생성 helper.
1505
+ * plugins 머지 + applySingleFileDynamicImportDefault + 옵션 매핑을 곳에 모아
1506
+ * runBundle (single-shot) 와 watch (incremental HMR, #3779) 의 옵션 drift 차단.
1507
+ */
1508
+ async function buildBundleOptions(opts, config, { filterCallerPreWarmCss = false } = {}) {
1509
+ // issue #3852 — caller (runAppDev) 가 이미 plugin walk 했으면 `_resolvedPlugins`
1510
+ // stash 재import skip. ESM cache hit 이라 시맨틱 회귀 없지만 perf +
1511
+ // cache invalidate edge 안전.
1512
+ let plugins;
1513
+ if (Array.isArray(opts._resolvedPlugins)) {
1514
+ plugins = [...opts._resolvedPlugins];
1515
+ } else {
1516
+ plugins = [];
1517
+ if (config && Array.isArray(config.plugins)) {
1518
+ plugins.push(...config.plugins);
1519
+ }
1520
+ for (const pluginPath of opts.pluginPaths) {
1521
+ const absPath = resolve(pluginPath);
1522
+ // importAndResolveDefault 는 pathToFileURL 으로 Windows 경로를 안전하게 처리하고
1523
+ // ENOENT/객체 검증을 통일한다 (config-loader 와 공유).
1524
+ const cfg = await importAndResolveDefault(absPath);
1525
+ if (Array.isArray(cfg.plugins)) {
1526
+ plugins.push(...cfg.plugins);
1527
+ } else if (typeof cfg.setup === 'function') {
1528
+ plugins.push(cfg);
1529
+ }
1318
1530
  }
1319
1531
  }
1532
+ // RFC #3833 v3 D1a'' caller-side pre-warm — dropCallerPreWarmedCssPlugin helper
1533
+ // 로 sentinel 가진 css plugin 을 dispatcher chain 에서 제거. **app caller
1534
+ // (runServe with appDev) 만 적용** — bundle/transpile 모드 사용자가 css()
1535
+ // 명시한 경우 무조건 drop 하면 PostCSS 효과 0 silent regression (review #2).
1536
+ // app 모드만 caller-pre-warm 로 prepare 가 처리하므로 dispatcher dispatch 차단
1537
+ // 필요, bundle 모드는 native async dispatcher 가 onLoad 호출 (단 caller-pre-warm
1538
+ // 없어 PostCSS 적용 안 되지만 사용자 의도 그대로 dispatcher 에 전달).
1539
+ const dispatchPlugins = filterCallerPreWarmCss ? dropCallerPreWarmedCssPlugin(plugins) : plugins;
1320
1540
 
1321
1541
  applySingleFileDynamicImportDefault(opts);
1322
1542
 
1323
- const buildOpts = {
1543
+ return {
1324
1544
  entryPoints: opts.entryPoints.map((e) => resolve(e)),
1545
+ // #3795/#3796 — watch handle 이 outdir 알아야 worker thread 의 createFile 가 정확한 위치에
1546
+ // 출력. `prepareNapiOptions` 가 build/buildSync 케이스에서 outdir 를 delete 하지만 watch()
1547
+ // wrapper (index.ts:3358) 가 명시 outdir 받아 NAPI 로 restore. 그러므로 BuildOptions 의
1548
+ // outdir/outfile 필드 자체는 enrich 해서 보내야 함.
1549
+ outdir: opts.outdir,
1550
+ outfile: opts.outfile,
1325
1551
  format: opts.format,
1326
1552
  platform: opts.platform,
1327
1553
  target: opts.target,
1554
+ // --rn-version: platform=react-native 함의 + RN 문서 기준 버전별 다운레벨 (NAPI 가 해석).
1555
+ rnVersion: opts.rnVersion,
1556
+ // #4438 디스크 캐시 opt-in: --disk-cache(활성) / --cache-dir <path>. NAPI 가 disk_cache_dir 로 해석.
1557
+ diskCache: opts.diskCache,
1558
+ cacheDir: opts.cacheDir,
1328
1559
  browserslist: opts.browserslist,
1329
1560
  external: opts.external,
1330
1561
  packagesExternal: opts.packagesExternal,
@@ -1402,6 +1633,8 @@ async function runBundle(opts, config) {
1402
1633
  entryNames: opts.entryNames,
1403
1634
  chunkNames: opts.chunkNames,
1404
1635
  assetNames: opts.assetNames,
1636
+ assetInlineLimit: opts.assetInlineLimit,
1637
+ cssNames: opts.cssNames,
1405
1638
  jsx: opts.jsx,
1406
1639
  jsxDev: opts.jsxDev,
1407
1640
  jsxFactory: opts.jsxFactory,
@@ -1409,6 +1642,9 @@ async function runBundle(opts, config) {
1409
1642
  jsxImportSource: opts.jsxImportSource,
1410
1643
  inject: opts.inject.map((p) => resolve(p)),
1411
1644
  devMode: opts.devMode,
1645
+ // web React Fast Refresh: native 빌드(options.zig:reactRefresh)에 전파 → 컴포넌트에
1646
+ // $RefreshReg$/accept emit. dev 블록이 기본 on(비-React 무해).
1647
+ reactRefresh: opts.reactRefresh,
1412
1648
  globalIdentifiers: opts.globalIdentifiers,
1413
1649
  // --polyfill / --run-before-main / --watch-folder 는 경로 → abs 변환 (--inject 와 동일).
1414
1650
  // --watch-include / --watch-exclude 는 루트 기준 glob 이므로 변환 안 함.
@@ -1421,7 +1657,7 @@ async function runBundle(opts, config) {
1421
1657
  watchExclude: opts.watchExclude?.length ? opts.watchExclude : undefined,
1422
1658
  jobs: opts.jobs,
1423
1659
  outbase: opts.outbase,
1424
- plugins: plugins.length > 0 ? plugins : undefined,
1660
+ plugins: dispatchPlugins.length > 0 ? dispatchPlugins : undefined,
1425
1661
  // compiler.styledComponents / compiler.emotion 도 bundle 모드에서 forward.
1426
1662
  // 누락 시 `zntc.config.json` 의 `compiler` 설정이 silently drop 돼 1st-party transform
1427
1663
  // (autoLabel 등) 이 활성화 안 됨.
@@ -1431,8 +1667,12 @@ async function runBundle(opts, config) {
1431
1667
  // (native CLI 만 zntc.config.json mf 를 직접 읽어 동작했던 갭).
1432
1668
  mf: config?.mf,
1433
1669
  };
1670
+ }
1434
1671
 
1435
- const result = plugins.length > 0 ? await build(buildOpts) : buildSync(buildOpts);
1672
+ async function runBundle(opts, config) {
1673
+ const buildOpts = await buildBundleOptions(opts, config);
1674
+ const hasPlugins = Array.isArray(buildOpts.plugins) && buildOpts.plugins.length > 0;
1675
+ const result = hasPlugins ? await build(buildOpts) : buildSync(buildOpts);
1436
1676
 
1437
1677
  printResultDiagnostics(result, opts.logLevel);
1438
1678
 
@@ -1556,7 +1796,7 @@ async function runWatch(opts, config) {
1556
1796
  }
1557
1797
 
1558
1798
  clearTimeout(debounceTimer);
1559
- debounceTimer = setTimeout(rebuild, opts.watchDelay);
1799
+ debounceTimer = setTimeout(rebuild, opts.watchDelay ?? 16);
1560
1800
  });
1561
1801
  attachWatcherErrorHandler(watcher, dir, opts.logLevel);
1562
1802
  }
@@ -1657,6 +1897,65 @@ async function emitRestartAfter(opts, reason, beforeSpawn) {
1657
1897
 
1658
1898
  // ─── Serve 모드 ───
1659
1899
 
1900
+ /**
1901
+ * #3858 — raw root .css 의 diff 기반 reconcile. 이전 scan 결과와 비교해
1902
+ * **사라진 path** 만 outdir 에서 unlink. 이전 design (raw vs outdir set diff)
1903
+ * 은 outdir 의 bundler/sass/css-modules 가 emit 한 transient file (chunk.css,
1904
+ * Button.module.zntc.css 등) 을 stale 오판 → unlink 회귀 (/code-review max #4/#5).
1905
+ *
1906
+ * caller 가 closure 로 prevRawSet 보관 — 매 cycle current scan + diff:
1907
+ * - removed = prev ∖ current → outdir 의 동일 rel path unlink
1908
+ * - prev := current
1909
+ *
1910
+ * .scss/.sass 는 sass pipeline 이 별도 outdir 에 emit, raw root 의 .scss 자체
1911
+ * 삭제 시 sass 산출물도 dev_controller 가 cleanup. reconcile 은 plain raw .css
1912
+ * 만 cover (사용자가 직접 만든 .css 파일).
1913
+ *
1914
+ * cost: O(raw .css count) walk per rebuild — 일반적 small (dozens).
1915
+ */
1916
+ function createReconcileOutdirCss(rawRoot, outdir) {
1917
+ let prevRawSet = new Set();
1918
+
1919
+ function scanRawCss() {
1920
+ const set = new Set();
1921
+ function walk(dir, relBase) {
1922
+ let entries;
1923
+ try {
1924
+ entries = readdirSync(dir, { withFileTypes: true });
1925
+ } catch {
1926
+ return;
1927
+ }
1928
+ for (const e of entries) {
1929
+ if (e.name === 'node_modules' || e.name === '.git') continue;
1930
+ if (e.name.startsWith('.zntc-')) continue;
1931
+ const rel = relBase ? `${relBase}/${e.name}` : e.name;
1932
+ if (e.isDirectory()) walk(join(dir, e.name), rel);
1933
+ else if (e.isFile() && e.name.endsWith('.css')) set.add(rel);
1934
+ }
1935
+ }
1936
+ walk(rawRoot, '');
1937
+ return set;
1938
+ }
1939
+
1940
+ // 첫 호출 시 prev 를 현재 raw scan 으로 초기화 — 첫 reconcile cycle 에서
1941
+ // 모든 outdir .css 가 stale 로 오판되는 것 회피.
1942
+ prevRawSet = scanRawCss();
1943
+
1944
+ return function reconcile() {
1945
+ const current = scanRawCss();
1946
+ for (const rel of prevRawSet) {
1947
+ if (current.has(rel)) continue;
1948
+ const target = join(outdir, rel);
1949
+ try {
1950
+ unlinkSync(target);
1951
+ } catch {
1952
+ // best-effort — file 이 없거나 race
1953
+ }
1954
+ }
1955
+ prevRawSet = current;
1956
+ };
1957
+ }
1958
+
1660
1959
  async function runServe(opts, config, { appDev = null } = {}) {
1661
1960
  const isBun = typeof globalThis.Bun !== 'undefined';
1662
1961
  // appDev 모드에서만 web 모듈 (HMR_MSG / APP_DEV_HMR_*_PATH / createHmrChannel /
@@ -1669,7 +1968,47 @@ async function runServe(opts, config, { appDev = null } = {}) {
1669
1968
  const APP_DEV_HMR_CLIENT = web?.APP_DEV_HMR_CLIENT;
1670
1969
  const APP_DEV_HMR_CLIENT_PATH = web?.APP_DEV_HMR_CLIENT_PATH;
1671
1970
  const APP_DEV_HMR_WS_PATH = web?.APP_DEV_HMR_WS_PATH;
1971
+ const APP_DEV_REACT_REFRESH_PATH = web?.APP_DEV_REACT_REFRESH_PATH;
1972
+ // React Fast Refresh preamble (react-refresh 런타임 글로벌 노출 + injectIntoGlobalHook).
1973
+ // reactRefresh on + react 설치 시에만 non-null(=비-React 앱은 주입/서빙/경고 0). lazy 1회 캐시.
1974
+ const reactRefreshAppRoot = opts.appRoot ? resolve(opts.appRoot) : process.cwd();
1975
+ let reactRefreshPreamble; // undefined=미계산, null=스킵, string=서빙
1976
+ const getReactRefreshPreamble = () => {
1977
+ if (reactRefreshPreamble === undefined) {
1978
+ reactRefreshPreamble =
1979
+ web && opts.reactRefresh ? web.buildReactRefreshPreamble(reactRefreshAppRoot) : null;
1980
+ }
1981
+ return reactRefreshPreamble;
1982
+ };
1672
1983
  let serverHandle = null;
1984
+ // #3779 follow-up — restart 시 stop 호출용. opts.bundle+watch+appDev+hmr 분기 안에서만 할당.
1985
+ let nativeWatchHandle = null;
1986
+ // #4062 — JS dev 서버 lazy on-demand 라우트. 게이트 = `--lazy` CLI 플래그(PR-C-3) 또는
1987
+ // env `ZNTC_LAZY=1`(동치 fallback). D105 접근1: native lazy 프리미티브(#4069/#4070, watch
1988
+ // lazySeeds + build lazyForceParse) 위에 JS 서버가 얇게 on-demand 라우팅을 얹는다. lazy 동적
1989
+ // 청크는 emit-skip 되므로(디스크에 없음) 브라우저가 `/<stem>-<8hex>.js` 를 요청하면 그 seed 만
1990
+ // force-parse 한 단발 build() 로 즉석 생성·캐시해서 서빙한다. 게이트 OFF 면 아래 전부 무시 → 0 영향.
1991
+ const lazyMode = opts.lazy === true || process.env.ZNTC_LAZY === '1';
1992
+ // pathHash(8 hex) → seed 절대경로. watch onReady/onRebuild 의 event.lazySeeds 로 갱신.
1993
+ const lazySeedMap = new Map();
1994
+ // pathHash → { body, type }. on-demand 빌드 결과 캐시. rebuild 마다 무효화(seed 본문 변경 가능).
1995
+ const lazyChunkCache = new Map();
1996
+ // pathHash → in-flight build Promise. 같은 청크 동시 요청을 coalesce(중복 build 회피).
1997
+ const lazyInflight = new Map();
1998
+ // on-demand build() 직렬화 tail. 페이지 로드 시 여러 lazy 청크가 동시 요청돼도 native build()
1999
+ // 를 한 번에 하나씩만 돌려 watch worker 와의 동시 재진입 위험을 없앤다(/code-review Q2).
2000
+ let lazyBuildTail = Promise.resolve();
2001
+ // #4062 PR-C-2 — 캐시 세대(epoch). rebuild(captureLazyState)가 캐시를 무효화할 때마다 +1.
2002
+ // on-demand build 가 시작 시 epoch 를 캡처하고 완료 후 비교해, 빌드 도중 rebuild 가 끼면
2003
+ // (epoch 변동) 그 결과를 캐시에 넣지 않는다(옛 소스로 만든 stale 바이트가 비워진 캐시를
2004
+ // 재오염하는 것을 막음 — native LazyState.epoch PR-4-iii 패턴 이식).
2005
+ let lazyEpoch = 0;
2006
+ // lazy entry 청크의 디스크 경로(`<entrystem>.js`). served index.html 은 prepareDev 가 non-split
2007
+ // 으로 `/bundle.js` 를 참조하게 rewrite 했지만, watch lazy 빌드의 entry 청크는 stem 이름이라
2008
+ // mismatch → `/bundle.js` 를 이 파일로 alias.
2009
+ let lazyEntryFile = null;
2010
+ // on-demand 단발 build() 옵션 템플릿(watchBuildOpts 와 동일 lazy 설정, callback/outdir 제거).
2011
+ let lazyOnDemandOpts = null;
1673
2012
  const mimeTypes = {
1674
2013
  '.html': 'text/html',
1675
2014
  '.js': 'application/javascript',
@@ -1686,29 +2025,40 @@ async function runServe(opts, config, { appDev = null } = {}) {
1686
2025
  '.map': 'application/json',
1687
2026
  };
1688
2027
 
1689
- // 번들 모드면 먼저 빌드
2028
+ // #3796/#3798 root-cause cold-start 시 runBundle 호출 제거. native watch 가 initial
2029
+ // 빌드 + outdir 출력 + appDev hooks 전부 담당. plugin.setup() 1회만 invoke (cold-start
2030
+ // 시점). race 회피는 HTTP server listen 을 watch.onReady 까지 wait — `napi_tsfn_blocking`
2031
+ // 모드라 worker 가 outdir 출력 완료 후 ready_event firing, JS callback 안에서 markWatchReady.
1690
2032
  if (opts.bundle && opts.entryPoints.length > 0) {
1691
2033
  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
2034
  if (!opts.watch) {
1705
2035
  opts.watch = true;
1706
2036
  }
1707
2037
  }
1708
2038
 
2039
+ // #3796 — watch.onReady 까지 HTTP listen 이 wait 할 수 있게 promise. appDev + hmr 모드만
2040
+ // 의미 — 그 외 모드는 markWatchReady 즉시 호출 → promise 가 처음부터 resolved.
2041
+ let watchReadyResolve;
2042
+ const watchReadyPromise = new Promise((r) => {
2043
+ watchReadyResolve = r;
2044
+ });
2045
+ let watchReadyResolved = false;
2046
+ const markWatchReady = () => {
2047
+ if (!watchReadyResolved) {
2048
+ watchReadyResolved = true;
2049
+ watchReadyResolve();
2050
+ }
2051
+ };
2052
+ if (!(opts.bundle && opts.watch && appDev && hmr)) markWatchReady();
2053
+
1709
2054
  const serveDir = resolve(opts.outdir || opts.serveDir);
1710
2055
  const base = normalizeBase(opts.base ?? '/');
1711
2056
 
2057
+ // #3799 — HMR Update modules 의 sourceMappingURL 이 가리키는 lazy sourcemap endpoint.
2058
+ // dev_overlay_client.js 의 __zntc_apply_update 가 eval 한 module code 끝의 주석을 DevTools
2059
+ // 가 fetch 함. RN bridge 의 `/__zntc_hmr_map/<id>` 와 동일 path.
2060
+ const HMR_MAP_PATH = '/__zntc_hmr_map/';
2061
+
1712
2062
  function handleRequest(reqUrl, accept = '') {
1713
2063
  let pathname = new URL(reqUrl, 'http://localhost').pathname;
1714
2064
  if (appDev && pathname === APP_DEV_HMR_CLIENT_PATH) {
@@ -1718,6 +2068,27 @@ async function runServe(opts, config, { appDev = null } = {}) {
1718
2068
  type: 'application/javascript',
1719
2069
  };
1720
2070
  }
2071
+ // React Fast Refresh preamble — 앱 번들보다 먼저 실행되는 classic <script> 본문.
2072
+ if (appDev && opts.reactRefresh && pathname === APP_DEV_REACT_REFRESH_PATH) {
2073
+ const body = getReactRefreshPreamble();
2074
+ if (body != null) return { status: 200, body, type: 'application/javascript' };
2075
+ }
2076
+ // #3799 — HMR module 별 sourcemap. nativeWatchHandle 이 lazy cache 한 V3 JSON 을
2077
+ // moduleId 로 조회. handle 가 stop 됐거나 module 미수집 시 null → 404. 사용자
2078
+ // app 의 routing 우선순위 (base prefix 처리) 보다 앞에 위치 — `__zntc_hmr_map/`
2079
+ // 는 internal prefix 라 base 영향 무관.
2080
+ if (appDev && pathname.startsWith(HMR_MAP_PATH) && nativeWatchHandle) {
2081
+ const moduleId = decodeURIComponent(pathname.slice(HMR_MAP_PATH.length));
2082
+ try {
2083
+ const sm = nativeWatchHandle.getHmrSourceMap(moduleId);
2084
+ if (sm) {
2085
+ return { status: 200, body: sm, type: 'application/json' };
2086
+ }
2087
+ } catch {
2088
+ // handle stop 또는 unwrap 실패 — 404
2089
+ }
2090
+ return { status: 404, body: 'Not Found', type: 'text/plain' };
2091
+ }
1721
2092
  if (base && base !== '/' && pathname.startsWith(base)) {
1722
2093
  pathname = '/' + pathname.slice(base.length);
1723
2094
  }
@@ -1744,16 +2115,323 @@ async function runServe(opts, config, { appDev = null } = {}) {
1744
2115
  return { status: 200, body, type };
1745
2116
  }
1746
2117
 
2118
+ // #4062 PR-B-2 — lazy 상태 갱신. watch onReady/onRebuild event 의 lazySeeds 로 pathHash→seed
2119
+ // 맵을 다시 채우고, entry 청크 파일을 식별하고, 캐시를 무효화한다(seed 본문 변경 가능).
2120
+ // event.outputs 는 디스크 경로 목록. entry 청크 = entry stem(`<name>.js`)과 basename 이 일치하는
2121
+ // 출력(splitting 의 entry 청크 네이밍). dev 모드는 content-hash off 라 stem 그대로.
2122
+ function captureLazyState(event) {
2123
+ // 실패한 rebuild(event.success===false)는 무시 — 직전 성공 빌드의 seed/cache 를 유지한다
2124
+ // (clear 하면 다음 요청이 또 실패할 빌드를 돌려 last-good 청크를 잃는다). onReady event 는
2125
+ // success 필드가 없어(undefined) 통과한다.
2126
+ if (!lazyMode || !event || event.success === false) return;
2127
+ // 정밀 캐시 무효화(#4062 후속) — 매 rebuild 전체 clear 하면 무관한 편집에도 멀쩡한 lazy
2128
+ // 청크가 버려져 다음 요청이 cold build()(store 없는 단발이라 ~수초)를 돈다. 대신 변경 파일
2129
+ // (event.changed, 절대경로)이 *닿은* 청크만 버린다 — 캐시 항목의 moduleIds(청크 내 모듈
2130
+ // 절대경로)와 교집합이 있을 때만. graph 모양이 바뀐 rebuild(graphChanged=모듈 추가/제거)는
2131
+ // moduleIds 가 stale 일 수 있어(신규 파일이 옛 moduleIds 에 없음) 전체 clear 로 fallback.
2132
+ // changed 가 없으면(onReady 등) 보수적으로 전체 clear.
2133
+ const changedAbs =
2134
+ Array.isArray(event.changed) && event.changed.length > 0
2135
+ ? new Set(event.changed.map((p) => resolve(p)))
2136
+ : null;
2137
+ if (event.graphChanged || !changedAbs) {
2138
+ lazyChunkCache.clear();
2139
+ } else {
2140
+ for (const [hash, entry] of lazyChunkCache) {
2141
+ const ids = entry.moduleIds;
2142
+ // moduleIds 미보유(방어) 또는 변경 파일과 교집합 → 버림.
2143
+ if (!Array.isArray(ids) || ids.some((id) => changedAbs.has(resolve(id)))) {
2144
+ lazyChunkCache.delete(hash);
2145
+ }
2146
+ }
2147
+ }
2148
+ // epoch 증가는 항상 — 진행 중 on-demand build 가 이 무효화를 넘겨 stale 바이트를 재캐시하지
2149
+ // 못하게(in-flight race backstop). 정밀 delete 와 직교(어떤 항목을 버리든 race 가드는 유지).
2150
+ lazyEpoch++;
2151
+ // seed 맵은 event.lazySeeds 가 *실제로 올 때만* 교체한다. 일반 편집(동적 import 를 가진
2152
+ // 모듈이 cache-hit)은 native 가 graph.lazy_seeds 를 다시 안 쌓아 lazySeeds 가 undefined 로
2153
+ // 온다 — 무조건 clear 하면 직전 유효 맵을 날려(PR-B-2 보다 나쁨) on-demand 라우트가 죽는다.
2154
+ // 새 seed 집합(동적 import 추가/제거 = importer 재파싱)일 때만 clear+repopulate.
2155
+ // (lazyEntryFile 의 `if (entry)` 보존과 같은 원리.)
2156
+ if (Array.isArray(event.lazySeeds)) {
2157
+ lazySeedMap.clear();
2158
+ for (const s of event.lazySeeds) {
2159
+ if (s && typeof s.pathHash === 'string' && typeof s.path === 'string') {
2160
+ lazySeedMap.set(s.pathHash, s.path);
2161
+ }
2162
+ }
2163
+ }
2164
+ const entryStem = basename(opts.entryPoints[0] ?? '', extname(opts.entryPoints[0] ?? ''));
2165
+ // event.outputs 는 outdir 기준 bare 파일명("main.js") — serveDir 로 절대화한다(이미 절대면 그대로).
2166
+ const outputs = (Array.isArray(event.outputs) ? event.outputs : [])
2167
+ .filter((p) => typeof p === 'string')
2168
+ .map((p) => resolve(serveDir, p));
2169
+ let entry = outputs.find((p) => basename(p, '.js') === entryStem) ?? null;
2170
+ // fallback: entry stem 매칭 실패 시 `__zntc_load_chunk` 를 포함한 .js 출력(동적 import 보유 청크).
2171
+ if (!entry) {
2172
+ for (const p of outputs) {
2173
+ if (!p.endsWith('.js')) continue;
2174
+ try {
2175
+ if (readFileSync(p, 'utf8').includes('__zntc_load_chunk(')) {
2176
+ entry = p;
2177
+ break;
2178
+ }
2179
+ } catch {
2180
+ // 읽기 실패 — skip
2181
+ }
2182
+ }
2183
+ }
2184
+ // dev rebuild 는 skip_bundle_output 라 event.outputs 가 비어있을 수 있다(#3796). entry 를
2185
+ // 못 찾았으면 직전 값을 유지(entry 청크 파일명은 stem 고정이라 안정 — 매 요청 readFileSync 라
2186
+ // 내용은 자동 fresh). 찾았을 때만 갱신.
2187
+ if (entry) lazyEntryFile = entry;
2188
+ }
2189
+
2190
+ // #4062 PR-B-2 — lazy on-demand 라우트. 반환 null = lazy 라우트가 처리 안 함(기존 handleRequest 로).
2191
+ // ① `/bundle.js` (served index.html 이 참조) → lazy entry 청크 alias.
2192
+ // ② `/<stem>-<8hex>.js` → seed 역참조 후 그 seed 만 force-parse 한 단발 build() 로 동적 청크 생성.
2193
+ async function tryServeLazy(reqUrl) {
2194
+ if (!lazyMode) return null;
2195
+ let pathname = new URL(reqUrl, 'http://localhost').pathname;
2196
+ if (base && base !== '/' && pathname.startsWith(base)) {
2197
+ pathname = '/' + pathname.slice(base.length);
2198
+ }
2199
+ // ① entry alias — served index.html 의 `/bundle.js` 를 lazy entry 청크로.
2200
+ if ((pathname === '/bundle.js' || pathname === '/index.js') && lazyEntryFile) {
2201
+ try {
2202
+ return {
2203
+ status: 200,
2204
+ body: readFileSync(lazyEntryFile),
2205
+ type: 'application/javascript',
2206
+ };
2207
+ } catch {
2208
+ return null; // 파일 사라짐 — 정적 fallback
2209
+ }
2210
+ }
2211
+ // ② on-demand 동적 청크.
2212
+ const m = pathname.match(/-([0-9a-f]{8})\.js$/);
2213
+ if (!m) return null;
2214
+ const pathHash = m[1];
2215
+ const seedPath = lazySeedMap.get(pathHash);
2216
+ if (!seedPath) return null; // 알 수 없는 hash — 정적 자산일 수 있어 fallback
2217
+ // #4079 PR-3 (dev materialize) — 이 seed 가 방문됐으니 watch worker 가 그것을 force-parse 해
2218
+ // 정식 청크로 emit(이후 정적 서빙) + transitive deps 를 감시(깊은 편집 HMR + warm 재빌드)하게
2219
+ // 한다. native 가 dedup 하므로 매 요청 호출 무해. PR-1 의 path-hash 안정 이름 덕에 on-demand
2220
+ // 청크 URL ↔ watch emit 청크 URL 이 동일해 전환이 매끄럽다. 아래 on-demand build 는 첫 요청
2221
+ // 즉시성용(watch rebuild 는 ≤200ms 비동기). 구 native 바이너리면 메서드 부재 → 옵셔널 no-op.
2222
+ nativeWatchHandle?.requestLazySeed?.(seedPath);
2223
+ const cached = lazyChunkCache.get(pathHash);
2224
+ if (cached) return cached;
2225
+ if (!lazyOnDemandOpts) return null;
2226
+ // 같은 청크 동시 요청은 진행 중 build 를 공유(coalesce).
2227
+ const existing = lazyInflight.get(pathHash);
2228
+ if (existing) return existing;
2229
+ // tail 에 체인 → on-demand build 들을 직렬화(동시 native build()+watch worker 재진입 회피).
2230
+ const job = lazyBuildTail.then(async () => {
2231
+ // 큐 대기 중 cache 가 채워졌으면(다른 동일 요청이 먼저 완료) 그대로 재사용.
2232
+ const c = lazyChunkCache.get(pathHash);
2233
+ if (c) return c;
2234
+ // build 시작 직전 세대 캡처 — 완료 후 변동(=빌드 도중 rebuild) 시 캐시 오염 방지.
2235
+ const epoch = lazyEpoch;
2236
+ try {
2237
+ const r = await build({ ...lazyOnDemandOpts, lazyForceParse: [seedPath] });
2238
+ if (r.errors && r.errors.length > 0) return null;
2239
+ // seed 모듈을 포함한 청크를 moduleIds 로 찾는다(force-parse 라 그 seed 가 어느 청크에 인라인됨).
2240
+ const chunk = (r.outputFiles ?? []).find(
2241
+ (f) => Array.isArray(f.moduleIds) && f.moduleIds.includes(seedPath),
2242
+ );
2243
+ if (!chunk) return null;
2244
+ const result = {
2245
+ status: 200,
2246
+ body: chunk.contents,
2247
+ type: 'application/javascript',
2248
+ // 정밀 무효화용 — 이 청크에 들어간 모듈 절대경로 집합. rebuild 시 event.changed 와
2249
+ // 교집합이 있을 때만 이 항목을 버린다(무관한 편집엔 보존 → cold 재빌드 회피).
2250
+ moduleIds: Array.isArray(chunk.moduleIds) ? chunk.moduleIds : [],
2251
+ };
2252
+ // 빌드 도중 rebuild 가 끼지 않았을 때만 캐시(epoch 불변). 끼었으면 이 결과는 옛 소스
2253
+ // 기반이라 캐시하지 않고(다음 요청이 fresh 재빌드) 이번 응답으로만 반환.
2254
+ if (lazyEpoch === epoch) lazyChunkCache.set(pathHash, result);
2255
+ return result;
2256
+ } catch (err) {
2257
+ console.error('[serve] lazy chunk build failed:', err);
2258
+ return null;
2259
+ }
2260
+ });
2261
+ lazyBuildTail = job.catch(() => {}); // tail 은 reject 흡수(다음 build 가 멈추지 않게).
2262
+ lazyInflight.set(pathHash, job);
2263
+ try {
2264
+ return await job;
2265
+ } finally {
2266
+ lazyInflight.delete(pathHash);
2267
+ }
2268
+ }
2269
+
1747
2270
  const useTls = opts.certfile && opts.keyfile;
1748
2271
 
2272
+ // #3796/#3798 root-cause — watch handle 을 HTTP listen 전에 띄움. cold-start runBundle 제거
2273
+ // 후 watch worker 가 outdir 출력 + appDev hooks 담당. HTTP listen 은 watchReadyPromise 까지
2274
+ // wait → server listen 시점에 outdir 채워진 상태 (race-free).
2275
+ if (opts.bundle && opts.watch && appDev && hmr) {
2276
+ // app dev path (appDev 활성) — caller-pre-warm 으로 prepare 가 처리한
2277
+ // css plugin 을 dispatcher 에서 제거 필요. bundle 모드 (appDev=null) 는
2278
+ // filter false (사용자 명시 css() 가 dispatcher 에 정상 전달).
2279
+ const watchBuildOpts = await buildBundleOptions(opts, config, {
2280
+ filterCallerPreWarmCss: true,
2281
+ });
2282
+ watchBuildOpts.devMode = true;
2283
+ // #4062 PR-B-2 — lazy on-demand 활성화. lazy 동적 청크는 (a) code splitting 으로 분리되고
2284
+ // (b) IIFE registry(`__zntc_require`/`__zntc_load_chunk`) 로 로드되어야 한다. dev 단일파일
2285
+ // 기본(applySingleFileDynamicImportDefault 가 splitting 미설정 시 inlineDynamicImports=true)
2286
+ // 을 명시적으로 끄고 splitting+iife 를 강제한다. on-demand 단발 build() 템플릿도 동일 설정으로
2287
+ // 준비(watch callback/outdir 제거 — build() 는 outdir 없으면 in-memory outputFiles 반환).
2288
+ if (lazyMode) {
2289
+ watchBuildOpts.splitting = true;
2290
+ watchBuildOpts.inlineDynamicImports = false;
2291
+ watchBuildOpts.lazyCompilation = true;
2292
+ watchBuildOpts.format = 'iife';
2293
+ lazyOnDemandOpts = {
2294
+ ...watchBuildOpts,
2295
+ splitting: true,
2296
+ inlineDynamicImports: false,
2297
+ lazyCompilation: true,
2298
+ format: 'iife',
2299
+ };
2300
+ delete lazyOnDemandOpts.onReady;
2301
+ delete lazyOnDemandOpts.onRebuild;
2302
+ // outdir/outfile 둘 다 제거 → build() 가 write skip(in-memory outputFiles)해 watch 의
2303
+ // 디스크 출력을 매 요청마다 clobber 하지 않게 한다(/code-review: outfile 누락 footgun).
2304
+ delete lazyOnDemandOpts.outdir;
2305
+ delete lazyOnDemandOpts.outfile;
2306
+ delete lazyOnDemandOpts.watch;
2307
+ }
2308
+ // issue #3858 — appDev path 에서 watchFolders 자동 = app root. 사용자가 직접
2309
+ // .css 파일을 import 없이 만들었을 때 (graph 외) 신규 file 의 add 감지를
2310
+ // native watcher (TrackedFileSet 의 dir-watch) 가 처리하도록 root_dir 등록.
2311
+ // opts._appWatchRoot 는 runAppDev 가 stash 한 사용자 source code root (outdir
2312
+ // 아닌 진짜 src/ 컨테이너). 사용자 명시 watchFolders 가 있으면 union — 우선순위 유지.
2313
+ let reconcileOutdir = null;
2314
+ if (opts._appWatchRoot) {
2315
+ const autoWatchRoot = resolve(opts._appWatchRoot);
2316
+ const existing = Array.isArray(watchBuildOpts.watchFolders)
2317
+ ? watchBuildOpts.watchFolders
2318
+ : [];
2319
+ if (!existing.includes(autoWatchRoot)) {
2320
+ watchBuildOpts.watchFolders = [...existing, autoWatchRoot];
2321
+ }
2322
+ // issue #3858 — reconcileOutdir factory 생성 (closure 가 prev/current raw
2323
+ // .css set 추적). onRebuild 마다 호출 — sass/.module/.chunk emit 영향 0.
2324
+ if (opts.outdir) {
2325
+ reconcileOutdir = createReconcileOutdirCss(autoWatchRoot, resolve(opts.outdir));
2326
+ }
2327
+ }
2328
+ watchBuildOpts.onReady = async (event) => {
2329
+ try {
2330
+ // #3799 root-cause — initial build 의 diagnostics 의 error 도 reportError.
2331
+ if (event && event.errors && event.errors.length > 0) {
2332
+ hmr.reportError(
2333
+ event.errors.map((e) => ({ text: e.message, location: { file: e.file } })),
2334
+ );
2335
+ } else {
2336
+ hmr.clearError();
2337
+ }
2338
+ captureLazyState(event); // #4062 — lazySeed 맵 + entry 청크 식별 (lazyMode 아니면 no-op)
2339
+ // #3796 — event.outputs (path 목록) 를 BundleResult shape 으로 변환해 injectBundleCssLinks.
2340
+ const mockResult = {
2341
+ outputFiles: (event && event.outputs ? event.outputs : []).map((p) => ({ path: p })),
2342
+ };
2343
+ appDev.injectBundleCssLinks(mockResult);
2344
+ await appDev.afterBundle();
2345
+ } catch (err) {
2346
+ console.error('[serve] initial appDev hooks failed:', err);
2347
+ } finally {
2348
+ markWatchReady();
2349
+ }
2350
+ };
2351
+ watchBuildOpts.onRebuild = (event) => {
2352
+ // dev_mode + collect_module_codes 인 incremental rebuild 는 skip_bundle_output 자동
2353
+ // 활성이라 outdir 갱신 안 함. graphChanged 시 추가 runBundle 호출 (plugin.setup 2회
2354
+ // 시점 — cold-start 는 watch 1회만).
2355
+ void (async () => {
2356
+ try {
2357
+ // #4062 PR-C-1 — rebuild 마다 lazy 상태 갱신: seed 맵을 event.lazySeeds 로 다시 채우고
2358
+ // (신규 동적 import 추가/제거 반영) 청크 캐시를 무효화한다. captureLazyState 가 실패한
2359
+ // rebuild 는 skip, outputs 가 비면(skip_bundle_output) entry 는 유지한다. (PR-B-2 는
2360
+ // onRebuild 가 lazySeeds 미노출이라 cache.clear 만 했음 — PR-C-1 에서 native 가 노출.)
2361
+ captureLazyState(event);
2362
+ // issue #3858 — native onRebuild 의 cssChanges 분기는 PR #3859 머지로
2363
+ // 도입됐으나, drain (fs.watch) 가 이미 같은 fs event 받아 rebuildAppDevCss
2364
+ // (syncDirty + afterBundle, PostCSS incremental) 로 처리. dual watch race
2365
+ // 의 source 였음 + prepare 호출이 PostCSS 를 tempRoot 전체 reprocess →
2366
+ // "processed N" 회귀 (dev-hmr/postcss test fail). drain 단일 처리로 통합.
2367
+ // graph 외 .css ADD case (#3858 핵심) 는 drain 의 fs.watch (recursive)
2368
+ // 가 신규 add event 받아 처리 — native 의 cssChanges 분기 redundant.
2369
+ // 단 graphChanged 시 outdir 갱신은 아래 runBundle 분기가 cover.
2370
+ // issue #3858 — 매 rebuild 마다 outdir reconcile (raw root .css diff
2371
+ // 후 사라진 path 만 outdir unlink). closure factory 의 prev/current
2372
+ // diff 로 outdir 의 sass/.module/.chunk emit 영향 0.
2373
+ if (event && event.success && reconcileOutdir) {
2374
+ reconcileOutdir();
2375
+ }
2376
+ if (event && event.success && event.graphChanged) {
2377
+ try {
2378
+ const r = await runBundle(opts, config);
2379
+ if (r.errors.length === 0) appDev.injectBundleCssLinks(r);
2380
+ } catch (cssErr) {
2381
+ console.error('[serve] graph-change outdir rebuild failed:', cssErr);
2382
+ }
2383
+ }
2384
+ const annotated =
2385
+ event && event.success && event.updates && event.updates.length > 0
2386
+ ? {
2387
+ ...event,
2388
+ updates: event.updates.map((u) => ({
2389
+ ...u,
2390
+ code: `${u.code}\n//# sourceMappingURL=${HMR_MAP_PATH}${encodeURIComponent(u.id)}\n`,
2391
+ })),
2392
+ }
2393
+ : event;
2394
+ const outcome = web.broadcastRebuildEvent(hmr, annotated);
2395
+ // #4079: lazy(splitting) dev 는 분할 청크가 dev HMR 모듈 레지스트리에 없어 module-level
2396
+ // HMR 이 불가하다(dev init lowering 이 production init 로 fallback). 그래서 lazy 청크
2397
+ // 안의 모듈을 편집하면 rebuild 는 변경을 감지(event.changed)하지만 module update 를
2398
+ // 못 만들어 broadcastRebuildEvent 가 'noop' 으로 끝나 화면이 안 갱신된다 → full reload 로 갈음.
2399
+ // 단 (a) 'update'(메인 번들 모듈 변경 — 진짜 HMR 적용 가능)와 'full-reload'(graphChanged)
2400
+ // /'error' 는 제외, (b) CSS 변경은 drain 의 CSS-only HMR(live <link> swap)이 처리하므로
2401
+ // *비-CSS* 변경이 하나라도 있을 때만 full reload(전부 CSS 면 CSS HMR 보존).
2402
+ // errors 가 있으면(partial build) overlay 가 latch 된 상태 — full reload 는 overlay 를
2403
+ // 숨기고 깨진 상태로 리로드하므로 skip(에러는 그대로 보여줌, 다음 성공 빌드가 갱신).
2404
+ if (lazyMode && event.success && outcome === 'noop' && !event.errors?.length) {
2405
+ const hasNonCss = (event.changed ?? []).some(
2406
+ (p) => !/\.(css|scss|sass|less|styl|pcss|postcss)$/i.test(p),
2407
+ );
2408
+ if (hasNonCss) hmr.broadcast({ type: web.HMR_MSG.FullReload, timestamp: Date.now() });
2409
+ }
2410
+ } catch (err) {
2411
+ console.error('[serve] hmr broadcast error:', err);
2412
+ }
2413
+ })();
2414
+ };
2415
+ try {
2416
+ nativeWatchHandle = watch(watchBuildOpts);
2417
+ } catch (err) {
2418
+ console.error('[serve] native watch failed to start (incremental HMR disabled):', err);
2419
+ markWatchReady();
2420
+ }
2421
+ }
2422
+
2423
+ // #3796 — HTTP listen 을 watch.onReady 까지 wait (5초 timeout 으로 deadlock 방어).
2424
+ await Promise.race([watchReadyPromise, new Promise((r) => setTimeout(r, 5000))]);
2425
+
1749
2426
  if (isBun) {
1750
2427
  // Bun.serve
1751
2428
  const serveOpts = {
1752
2429
  port: opts.port,
1753
2430
  hostname: opts.host,
1754
- fetch(req, server) {
2431
+ async fetch(req, server) {
1755
2432
  const url = new URL(req.url);
1756
2433
  // /__hmr WebSocket upgrade — Bun-native API 사용 (Node 분기는 server.on('upgrade')).
2434
+ // upgrade 는 첫 await 전에 동기 실행돼 async fetch 여도 안전.
1757
2435
  if (hmr && url.pathname === APP_DEV_HMR_WS_PATH) {
1758
2436
  if (server.upgrade(req)) return undefined;
1759
2437
  return new Response('Upgrade required', { status: 426 });
@@ -1765,6 +2443,18 @@ async function runServe(opts, config, { appDev = null } = {}) {
1765
2443
  }
1766
2444
  }
1767
2445
 
2446
+ // #4062 PR-B-2 — lazy 라우트(entry alias + on-demand 동적 청크). null = 정적 처리로.
2447
+ const lazy = await tryServeLazy(req.url);
2448
+ if (lazy) {
2449
+ return new Response(lazy.body, {
2450
+ status: lazy.status,
2451
+ headers: {
2452
+ 'Content-Type': lazy.type,
2453
+ 'Access-Control-Allow-Origin': '*',
2454
+ },
2455
+ });
2456
+ }
2457
+
1768
2458
  const { status, body, type } = handleRequest(req.url, req.headers.get('accept') ?? '');
1769
2459
  return new Response(body, {
1770
2460
  status,
@@ -1819,6 +2509,17 @@ async function runServe(opts, config, { appDev = null } = {}) {
1819
2509
  }
1820
2510
  }
1821
2511
 
2512
+ // #4062 PR-B-2 — lazy 라우트(entry alias + on-demand 동적 청크). null = 정적 처리로.
2513
+ const lazy = await tryServeLazy(req.url);
2514
+ if (lazy) {
2515
+ res.writeHead(lazy.status, {
2516
+ 'Content-Type': lazy.type,
2517
+ 'Access-Control-Allow-Origin': '*',
2518
+ });
2519
+ res.end(Buffer.isBuffer(lazy.body) ? lazy.body : Buffer.from(lazy.body));
2520
+ return;
2521
+ }
2522
+
1822
2523
  const { status, body, type } = handleRequest(req.url, req.headers.accept ?? '');
1823
2524
  res.writeHead(status, {
1824
2525
  'Content-Type': type,
@@ -1863,6 +2564,20 @@ async function runServe(opts, config, { appDev = null } = {}) {
1863
2564
  }
1864
2565
 
1865
2566
  async function closeServerForRestart() {
2567
+ // #3779 follow-up — native watch worker thread 가 child process spawn 후에도 살아남으면
2568
+ // outdir 출력이 부모/자식 두 곳에서 일어나 race. emitRestartAfter 의 child spawn 전에 stop.
2569
+ // stop 자체가 throw 해도 server.close 는 시도 (HTTP 포트 해제 우선).
2570
+ // #3803 — stop() throw 시 nativeWatchHandle 을 null 하지 않고 유지 → 다음 호출에서 retry
2571
+ // 가능. 정상 path 에서만 null 로 갱신. idempotent stop (#3794 의 napi_remove_wrap) 라
2572
+ // 다음 시도도 안전.
2573
+ if (nativeWatchHandle) {
2574
+ try {
2575
+ nativeWatchHandle.stop();
2576
+ nativeWatchHandle = null;
2577
+ } catch (err) {
2578
+ console.error('[serve] native watch stop failed (will retry on next invocation):', err);
2579
+ }
2580
+ }
1866
2581
  if (!serverHandle) return;
1867
2582
  if (typeof serverHandle.stop === 'function') {
1868
2583
  await serverHandle.stop();
@@ -1889,8 +2604,26 @@ async function runServe(opts, config, { appDev = null } = {}) {
1889
2604
  let rebuilding = false;
1890
2605
  const dirty = new Set();
1891
2606
 
2607
+ // #3796 — native watch handle 은 HTTP listen 전에 띄움 (위쪽 코드). 이 블록은
2608
+ // fsWatch + drain (CSS / sass / postcss / restart) 만 — JS 변경은 native watch 가 단독 처리.
2609
+
1892
2610
  async function rebuildAppDevCss(changedPath) {
1893
- await appDev.afterBundle({ changedPath });
2611
+ // issue #3861 — drain (fs.watch) prepare skip 시 tempRoot 가 raw root
2612
+ // 와 동기 안 되어 afterBundle 의 mirror 가 stale .css 를 outdir 에 다시
2613
+ // write → reconcileOutdirCss 의 unlink 무효화 (dual watch race).
2614
+ // syncDirty 는 prepare 의 syncDirtyFilesIntoTempRoot 만 호출 — PostCSS
2615
+ // 재실행 skip (prepare 가 full PostCSS reprocess 라 단일 CSS modify 시
2616
+ // 전체 .css 처리 → "processed N" 회귀, dev-hmr/postcss test fail). PostCSS
2617
+ // incremental 처리는 아래 afterBundle 의 changedPath 분기가 cover.
2618
+ appDev.syncDirty([changedPath]);
2619
+ try {
2620
+ await appDev.afterBundle({ changedPath });
2621
+ } catch (cssErr) {
2622
+ if (opts.logLevel !== 'silent') {
2623
+ console.error('[serve] css afterBundle failed:', cssErr);
2624
+ }
2625
+ throw cssErr; // drain outer catch (line 2460) 가 hmr.reportThrownError.
2626
+ }
1894
2627
  hmr?.clearError();
1895
2628
  hmr?.broadcast({
1896
2629
  type: HMR_MSG.CssUpdate,
@@ -1900,6 +2633,9 @@ async function runServe(opts, config, { appDev = null } = {}) {
1900
2633
  if (opts.logLevel !== 'silent') console.error('[serve] css updated');
1901
2634
  }
1902
2635
 
2636
+ // #3779 — sass partial / 다중 sass 변경 (drain 의 sass 분기) 전용. SCSS dependents
2637
+ // 까지 재컴파일해야 하므로 full pipeline + FullReload. JS module 변경은 native watch
2638
+ // handle 이 단독 처리 — 이 함수는 더 이상 JS 분기에서 호출되지 않는다.
1903
2639
  async function rebuildAppDevFull(dirtyPaths = null) {
1904
2640
  const prepared = await appDev.prepare(dirtyPaths);
1905
2641
  opts.entryPoints = [prepared.entryPath];
@@ -1947,6 +2683,12 @@ async function runServe(opts, config, { appDev = null } = {}) {
1947
2683
  } else {
1948
2684
  await rebuildAppDevFull();
1949
2685
  }
2686
+ } else if (paths.some((p) => p.endsWith('.scss') || p.endsWith('.sass'))) {
2687
+ // #71: sass 변경인데 fast-path 자격 없음(다른 root scss 가 @import 하는 partial,
2688
+ // 또는 다중 sass 변경) → full pipeline rebuild 로 dirty 의 transitive dependents 까지
2689
+ // 재컴파일. CSS-only 분기(afterBundle = postcss only)는 sass 를 재컴파일하지 않아
2690
+ // partial 변경 시 root scss 가 stale 로 남는다(code-review max 적발).
2691
+ await rebuildAppDevFull(paths);
1950
2692
  } else if (cssChanges.length === 1 && paths.length === 1) {
1951
2693
  await rebuildAppDevCss(cssChanges[0]);
1952
2694
  } else {
@@ -1956,7 +2698,33 @@ async function runServe(opts, config, { appDev = null } = {}) {
1956
2698
  if (opts.logLevel !== 'silent') console.error('[serve] css updated');
1957
2699
  }
1958
2700
  } else {
1959
- await rebuildAppDevFull(paths);
2701
+ // #3797 — paths 의 종류에 따라 3분기:
2702
+ // - 전부 CSS-derived (CSS Module / Sass Module / postcss): native watch 의 module
2703
+ // graph 밖이라 incremental update 트리거 안 됨 → full pipeline + FullReload.
2704
+ // - JS module (.ts/.tsx/.js 등) 섞임: native watch 가 단독 처리. drain noop
2705
+ // (중복 컴파일/output race 회피).
2706
+ // - 그 외 (HTML / JSON / static asset 등 native watch graph 밖 + non-CSS): fallback
2707
+ // FullReload broadcast. pre-#3779 의 unconditional rebuildAppDevFull 회귀 가드.
2708
+ // #3801 — appDev 의 isCssLikeChange 가 단일 진실 소스. inline literal endsWith
2709
+ // 가 `.less` (미지원) 포함하거나 `.styl/.pcss` 누락하던 drift 회귀 방지.
2710
+ const isJsModule = (p) =>
2711
+ p.endsWith('.ts') ||
2712
+ p.endsWith('.tsx') ||
2713
+ p.endsWith('.js') ||
2714
+ p.endsWith('.jsx') ||
2715
+ p.endsWith('.mjs') ||
2716
+ p.endsWith('.cjs');
2717
+ const allCssDerived = paths.every((p) => appDev.isCssLikeChange(p));
2718
+ const hasJs = paths.some(isJsModule);
2719
+ if (allCssDerived) {
2720
+ await rebuildAppDevFull(paths);
2721
+ } else if (!hasJs) {
2722
+ // HTML/JSON/asset 변경 — native watch 가 안 보는 변경에 대한 reload 신호.
2723
+ hmr?.clearError();
2724
+ hmr?.broadcast({ type: HMR_MSG.FullReload, timestamp: Date.now() });
2725
+ if (opts.logLevel !== 'silent') console.error('[serve] file changed, full reload');
2726
+ }
2727
+ // hasJs=true: native watch handle 의 onRebuild 가 단독 broadcast. drain noop.
1960
2728
  }
1961
2729
  }
1962
2730
  } catch (err) {
@@ -1990,7 +2758,7 @@ async function runServe(opts, config, { appDev = null } = {}) {
1990
2758
  }
1991
2759
  dirty.add(absPath);
1992
2760
  clearTimeout(debounceTimer);
1993
- debounceTimer = setTimeout(drain, opts.watchDelay);
2761
+ debounceTimer = setTimeout(drain, opts.watchDelay ?? 16);
1994
2762
  });
1995
2763
  attachWatcherErrorHandler(watcher, dir, opts.logLevel);
1996
2764
  }