@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/README.md +35 -25
- package/bin/cli-flags.mjs +16 -0
- package/bin/rn-dev-input.mjs +11 -0
- package/bin/zntc.mjs +816 -48
- package/dist/core/index.d.cts +300 -3
- package/dist/core/index.d.ts +300 -3
- package/dist/core/src/config-loader.d.ts +1 -1
- package/dist/core/src/schema-allowlists.d.ts +2 -2
- package/dist/index.cjs +283 -82
- package/dist/index.js +361 -83
- package/dist/shared/compat-engines.d.ts +1 -1
- package/package.json +11 -11
package/bin/zntc.mjs
CHANGED
|
@@ -7,7 +7,16 @@
|
|
|
7
7
|
* Watch/Serve는 JS 레이어에서 구현.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
plugins.push(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|