@simplysm/sd-cli 14.0.43 → 14.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/angular/ngtsc-build-core.d.ts +12 -3
  2. package/dist/angular/ngtsc-build-core.d.ts.map +1 -1
  3. package/dist/angular/ngtsc-build-core.js +70 -6
  4. package/dist/angular/ngtsc-build-core.js.map +1 -1
  5. package/dist/commands/publish/version-upgrade.d.ts.map +1 -1
  6. package/dist/commands/publish/version-upgrade.js +15 -12
  7. package/dist/commands/publish/version-upgrade.js.map +1 -1
  8. package/dist/deps/replace-deps/replace-deps-resolve.d.ts.map +1 -1
  9. package/dist/deps/replace-deps/replace-deps-resolve.js +6 -7
  10. package/dist/deps/replace-deps/replace-deps-resolve.js.map +1 -1
  11. package/dist/deps/replace-deps/replace-deps.d.ts.map +1 -1
  12. package/dist/deps/replace-deps/replace-deps.js +79 -15
  13. package/dist/deps/replace-deps/replace-deps.js.map +1 -1
  14. package/dist/deps/server-externals/server-production-files.d.ts +10 -5
  15. package/dist/deps/server-externals/server-production-files.d.ts.map +1 -1
  16. package/dist/deps/server-externals/server-production-files.js +22 -26
  17. package/dist/deps/server-externals/server-production-files.js.map +1 -1
  18. package/dist/esbuild/esbuild-angular-compiler-plugin.d.ts +3 -8
  19. package/dist/esbuild/esbuild-angular-compiler-plugin.d.ts.map +1 -1
  20. package/dist/esbuild/esbuild-angular-compiler-plugin.js +57 -83
  21. package/dist/esbuild/esbuild-angular-compiler-plugin.js.map +1 -1
  22. package/dist/esbuild/esbuild-postcss-plugin.d.ts.map +1 -1
  23. package/dist/esbuild/esbuild-postcss-plugin.js +9 -6
  24. package/dist/esbuild/esbuild-postcss-plugin.js.map +1 -1
  25. package/dist/esbuild/esbuild-worker-plugin.d.ts +30 -0
  26. package/dist/esbuild/esbuild-worker-plugin.d.ts.map +1 -0
  27. package/dist/esbuild/esbuild-worker-plugin.js +197 -0
  28. package/dist/esbuild/esbuild-worker-plugin.js.map +1 -0
  29. package/dist/workers/client.worker.d.ts.map +1 -1
  30. package/dist/workers/client.worker.js +4 -25
  31. package/dist/workers/client.worker.js.map +1 -1
  32. package/dist/workers/incremental-mtime-tracker.d.ts +13 -0
  33. package/dist/workers/incremental-mtime-tracker.d.ts.map +1 -0
  34. package/dist/workers/incremental-mtime-tracker.js +65 -0
  35. package/dist/workers/incremental-mtime-tracker.js.map +1 -0
  36. package/dist/workers/library-build.worker.d.ts.map +1 -1
  37. package/dist/workers/library-build.worker.js +37 -15
  38. package/dist/workers/library-build.worker.js.map +1 -1
  39. package/dist/workers/server-build.worker.d.ts.map +1 -1
  40. package/dist/workers/server-build.worker.js +6 -5
  41. package/dist/workers/server-build.worker.js.map +1 -1
  42. package/dist/workers/server-esbuild-context.d.ts.map +1 -1
  43. package/dist/workers/server-esbuild-context.js +3 -1
  44. package/dist/workers/server-esbuild-context.js.map +1 -1
  45. package/dist/workers/server-watch-manager.js +1 -1
  46. package/dist/workers/server-watch-manager.js.map +1 -1
  47. package/package.json +5 -4
  48. package/src/angular/ngtsc-build-core.ts +73 -5
  49. package/src/commands/publish/version-upgrade.ts +43 -34
  50. package/src/deps/replace-deps/replace-deps-resolve.ts +12 -7
  51. package/src/deps/replace-deps/replace-deps.ts +90 -16
  52. package/src/deps/server-externals/server-production-files.ts +26 -28
  53. package/src/esbuild/esbuild-angular-compiler-plugin.ts +82 -123
  54. package/src/esbuild/esbuild-postcss-plugin.ts +9 -6
  55. package/src/esbuild/esbuild-worker-plugin.ts +266 -0
  56. package/src/workers/client.worker.ts +4 -23
  57. package/src/workers/incremental-mtime-tracker.ts +68 -0
  58. package/src/workers/library-build.worker.ts +41 -14
  59. package/src/workers/server-build.worker.ts +6 -5
  60. package/src/workers/server-esbuild-context.ts +3 -1
  61. package/src/workers/server-watch-manager.ts +1 -1
  62. package/tests/angular/ngtsc-build-core.acc.spec.ts +210 -0
  63. package/tests/angular/ngtsc-build-core.spec.ts +52 -0
  64. package/tests/commands/version-upgrade.acc.spec.ts +210 -0
  65. package/tests/commands/version-upgrade.spec.ts +148 -0
  66. package/tests/deps/replace-deps/replace-deps-perf.verify.md +15 -0
  67. package/tests/deps/replace-deps/replace-deps-resolve.acc.spec.ts +124 -0
  68. package/tests/esbuild/esbuild-angular-compiler-plugin-worker.verify.md +56 -28
  69. package/tests/esbuild/esbuild-postcss-plugin-chunking.verify.md +17 -0
  70. package/tests/esbuild/esbuild-postcss-plugin.acc.spec.ts +152 -0
  71. package/tests/esbuild/esbuild-worker-plugin-node.verify.md +11 -0
  72. package/tests/esbuild/esbuild-worker-plugin.acc.spec.ts +318 -0
  73. package/tests/esbuild/esbuild-worker-plugin.spec.ts +297 -0
  74. package/tests/esbuild/esbuild-worker-plugin.verify.md +7 -0
  75. package/tests/esbuild/fixtures/worker-plugin/node-worker.js +2 -0
  76. package/tests/esbuild/fixtures/worker-plugin/shared-worker.js +6 -0
  77. package/tests/esbuild/fixtures/worker-plugin/worker-error.js +1 -0
  78. package/tests/esbuild/fixtures/worker-plugin/worker.js +3 -0
  79. package/tests/esbuild/fixtures/worker-plugin/worker2.js +3 -0
  80. package/tests/utils/ngtsc-build-core-write-emit.spec.ts +124 -0
  81. package/tests/workers/client-worker-mtime-incremental.verify.md +10 -0
  82. package/tests/workers/incremental-mtime-tracker.acc.spec.ts +144 -0
  83. package/tests/workers/incremental-mtime-tracker.spec.ts +102 -0
  84. package/tests/workers/library-build-worker.spec.ts +4 -0
  85. package/tests/workers/server-build-worker-plugin.verify.md +9 -0
  86. package/tests/workers/server-build-worker.spec.ts +26 -12
  87. package/tests/workers/server-esbuild-context.spec.ts +13 -5
  88. package/tests/workers/server-watch-manager.acc.spec.ts +2 -2
  89. package/tests/workers/server-watch-manager.spec.ts +2 -2
  90. package/dist/angular/web-worker-transformer.d.ts +0 -9
  91. package/dist/angular/web-worker-transformer.d.ts.map +0 -1
  92. package/dist/angular/web-worker-transformer.js +0 -73
  93. package/dist/angular/web-worker-transformer.js.map +0 -1
  94. package/src/angular/web-worker-transformer.ts +0 -117
  95. package/tests/angular/web-worker-transformer.spec.ts +0 -154
  96. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.d.ts +0 -2
  97. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.d.ts.map +0 -1
  98. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.js +0 -4
  99. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/index.js.map +0 -1
  100. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.d.ts +0 -2
  101. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.d.ts.map +0 -1
  102. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.js +0 -4
  103. package/tests/ts-compiler/fixtures/non-angular-pkg/dist/util.js.map +0 -1
@@ -17,6 +17,7 @@ import { copyPublicFiles, watchPublicFiles } from "../utils/copy-public";
17
17
  import type { SdBrowserSupportConfig, SdPwaConfig } from "../sd-config.types";
18
18
  import type esbuild from "esbuild";
19
19
  import type { PartialMessage } from "esbuild";
20
+ import { IncrementalMtimeTracker } from "./incremental-mtime-tracker";
20
21
 
21
22
  //#region Types
22
23
 
@@ -194,32 +195,19 @@ function createSourceFileCachePlugin(): esbuild.Plugin {
194
195
  return {
195
196
  name: "sd-build-start",
196
197
  setup(pluginBuild: esbuild.PluginBuild) {
197
- const prevMtimes = new Map<string, number>();
198
+ const mtimeTracker = new IncrementalMtimeTracker();
198
199
 
199
200
  pluginBuild.onStart(() => {
200
201
  // sourceFileCache 무효화: 변경된 파일의 loadResultCache + TypeScript 소스 캐시 모두 제거
201
202
  if (esbuildResult != null) {
202
203
  const { loadResultCache, typeScriptFileCache } =
203
204
  esbuildResult.sourceFileCache;
204
- const changedFiles = new Set<string>();
205
205
  // JS 파일 (loadResultCache) + TS 파일 (typeScriptFileCache) 모두 감시
206
206
  const watchTargets = [
207
207
  ...loadResultCache.watchFiles,
208
208
  ...typeScriptFileCache.keys(),
209
209
  ];
210
- for (const file of watchTargets) {
211
- try {
212
- const mtime = fs.statSync(file).mtimeMs;
213
- const prev = prevMtimes.get(file);
214
- if (prev != null && prev !== mtime) {
215
- changedFiles.add(file);
216
- }
217
- } catch {
218
- if (prevMtimes.has(file)) {
219
- changedFiles.add(file);
220
- }
221
- }
222
- }
210
+ const changedFiles = mtimeTracker.detectChanges(watchTargets);
223
211
  if (changedFiles.size > 0) {
224
212
  esbuildResult.sourceFileCache.invalidate(changedFiles);
225
213
  }
@@ -232,19 +220,12 @@ function createSourceFileCachePlugin(): esbuild.Plugin {
232
220
 
233
221
  pluginBuild.onEnd(() => {
234
222
  if (esbuildResult == null) return;
235
- prevMtimes.clear();
236
223
  // JS 파일 (loadResultCache) + TS 파일 (typeScriptFileCache) 모두 기록
237
224
  const watchTargets = [
238
225
  ...esbuildResult.sourceFileCache.loadResultCache.watchFiles,
239
226
  ...esbuildResult.sourceFileCache.typeScriptFileCache.keys(),
240
227
  ];
241
- for (const file of watchTargets) {
242
- try {
243
- prevMtimes.set(file, fs.statSync(file).mtimeMs);
244
- } catch {
245
- // 삭제된 파일
246
- }
247
- }
228
+ mtimeTracker.updateMtimes(watchTargets);
248
229
  });
249
230
  },
250
231
  };
@@ -0,0 +1,68 @@
1
+ import fs from "node:fs";
2
+
3
+ /**
4
+ * 증분 방식으로 파일 mtime을 추적하여 변경 파일을 감지한다.
5
+ *
6
+ * - `detectChanges`: watchTargets 전체를 stat하여 이전 mtime과 비교, 변경 파일 Set 반환
7
+ * - `updateMtimes`: 변경/신규 파일만 stat하여 prevMtimes를 증분 갱신
8
+ */
9
+ export class IncrementalMtimeTracker {
10
+ private readonly _prevMtimes = new Map<string, number>();
11
+ private _lastChangedFiles = new Set<string>();
12
+
13
+ detectChanges(watchTargets: Iterable<string>): Set<string> {
14
+ const changedFiles = new Set<string>();
15
+ for (const file of watchTargets) {
16
+ try {
17
+ const mtime = fs.statSync(file).mtimeMs;
18
+ const prev = this._prevMtimes.get(file);
19
+ if (prev != null && prev !== mtime) {
20
+ changedFiles.add(file);
21
+ }
22
+ } catch {
23
+ if (this._prevMtimes.has(file)) {
24
+ changedFiles.add(file);
25
+ }
26
+ }
27
+ }
28
+ this._lastChangedFiles = changedFiles;
29
+ return changedFiles;
30
+ }
31
+
32
+ updateMtimes(currentWatchTargets: Iterable<string>): void {
33
+ const targetSet =
34
+ currentWatchTargets instanceof Set
35
+ ? (currentWatchTargets as Set<string>)
36
+ : new Set(currentWatchTargets);
37
+
38
+ // 1) 삭제된 파일 정리: prevMtimes에 있지만 watchTargets에 없는 파일 제거
39
+ for (const file of this._prevMtimes.keys()) {
40
+ if (!targetSet.has(file)) {
41
+ this._prevMtimes.delete(file);
42
+ }
43
+ }
44
+
45
+ // 2) 변경된 파일 mtime 재조회 (watchTargets에 있는 파일만)
46
+ for (const file of this._lastChangedFiles) {
47
+ if (!targetSet.has(file)) continue;
48
+ try {
49
+ this._prevMtimes.set(file, fs.statSync(file).mtimeMs);
50
+ } catch {
51
+ this._prevMtimes.delete(file);
52
+ }
53
+ }
54
+
55
+ // 3) 신규 파일만 stat (prevMtimes에 없는 파일)
56
+ for (const file of targetSet) {
57
+ if (!this._prevMtimes.has(file)) {
58
+ try {
59
+ this._prevMtimes.set(file, fs.statSync(file).mtimeMs);
60
+ } catch {
61
+ // 삭제된 파일 — 무시
62
+ }
63
+ }
64
+ }
65
+
66
+ this._lastChangedFiles = new Set();
67
+ }
68
+ }
@@ -7,7 +7,7 @@ import type { SerializedDiagnostic } from "../typecheck/typecheck-serialization"
7
7
  import type { LintWithProgramResult } from "../lint/lint-with-program";
8
8
  import { SdTsCompiler } from "../ts-compiler/SdTsCompiler";
9
9
  import type { ISdTsCompilerResult } from "../ts-compiler/sd-ts-compiler-result";
10
- import { writeEmitResults, compileSideEffectScss } from "../angular/ngtsc-build-core";
10
+ import { writeEmitResults, compileSideEffectScss, buildReverseDeps } from "../angular/ngtsc-build-core";
11
11
  import { setupWorkerLifecycle } from "./shared-worker-lifecycle";
12
12
  import { buildWatchPaths } from "./build-watch-paths";
13
13
  import { hasFileAddOrRemove, shouldSkipRebuild } from "./build-change-filter";
@@ -46,6 +46,9 @@ export interface LibraryBuildWorkerEvents extends Record<string, unknown> {
46
46
  let fsWatcher: FsWatcher | undefined;
47
47
  let compiler: SdTsCompiler | undefined;
48
48
  let combinedScssDeps = new Map<string, Set<string>>();
49
+ let reverseScssDeps = new Map<string, Set<string>>();
50
+ let registryReverseIndex = new Map<string, Set<string>>();
51
+ let sideEffectScssDeps = new Map<string, Set<string>>();
49
52
 
50
53
  async function cleanup(): Promise<void> {
51
54
  const watcherToClose = fsWatcher;
@@ -53,6 +56,9 @@ async function cleanup(): Promise<void> {
53
56
  compiler = undefined;
54
57
  lastSourceFilePaths = undefined;
55
58
  combinedScssDeps = new Map();
59
+ reverseScssDeps = new Map();
60
+ registryReverseIndex = new Map();
61
+ sideEffectScssDeps = new Map();
56
62
  await watcherToClose?.close();
57
63
  }
58
64
 
@@ -93,13 +99,17 @@ async function build(info: LibraryBuildInfo): Promise<LibraryBuildResult> {
93
99
  scssErrors: sideEffectScssErrors,
94
100
  scssDependencies: combinedScssDeps,
95
101
  registry: compiler.sideEffectScssRegistry,
102
+ registryReverseIndex,
103
+ sideEffectScssDeps,
96
104
  });
97
- // 초기 빌드: 등록된 side-effect SCSS 전체 컴파일
105
+ // 초기 빌드: 등록된 side-effect SCSS 전체 컴파일 (changedScssFiles 미전달)
98
106
  compileSideEffectScss(
99
107
  compiler.sideEffectScssRegistry,
100
108
  loadPaths,
101
109
  sideEffectScssErrors,
102
110
  combinedScssDeps,
111
+ undefined,
112
+ sideEffectScssDeps,
103
113
  );
104
114
  } else {
105
115
  writeEmitResults(filteredEmitResults, info.pkgDir);
@@ -147,12 +157,18 @@ function extractSourceFilePaths(program: ts.Program | undefined): Set<string> |
147
157
  return paths;
148
158
  }
149
159
 
150
- /** compile-time SCSS 의존성으로 combinedScssDeps를 갱신한다 */
160
+ /** compile-time SCSS 의존성으로 combinedScssDeps와 reverseScssDeps를 갱신한다 */
151
161
  function updateCombinedScssDeps(result: ISdTsCompilerResult): void {
152
162
  combinedScssDeps = new Map();
153
163
  for (const [file, deps] of result.scssDependencies) {
154
164
  combinedScssDeps.set(file, new Set(deps));
155
165
  }
166
+ rebuildReverseScssDeps();
167
+ }
168
+
169
+ /** combinedScssDeps에서 역방향 인덱스를 재구축한다 */
170
+ function rebuildReverseScssDeps(): void {
171
+ reverseScssDeps = buildReverseDeps(combinedScssDeps);
156
172
  }
157
173
 
158
174
  /**
@@ -162,7 +178,7 @@ function updateCombinedScssDeps(result: ISdTsCompilerResult): void {
162
178
  function buildWatchEvent(
163
179
  info: LibraryBuildInfo,
164
180
  result: ISdTsCompilerResult,
165
- hasScssChanges: boolean,
181
+ changedScssFiles?: ReadonlySet<string>,
166
182
  ): CombinedBuildEvent {
167
183
  const isAngular = info.output.globalScss === true;
168
184
 
@@ -182,17 +198,25 @@ function buildWatchEvent(
182
198
  scssErrors: sideEffectScssErrors,
183
199
  scssDependencies: combinedScssDeps,
184
200
  registry: compiler!.sideEffectScssRegistry,
201
+ registryReverseIndex,
202
+ sideEffectScssDeps,
185
203
  },
186
204
  );
187
205
 
188
- // SCSS 변경 시 side-effect SCSS 재컴파일
189
- if (hasScssChanges) {
206
+ // side-effect SCSS 재컴파일
207
+ // changedScssFiles 미제공 = 초기 빌드 → 전체 컴파일
208
+ // changedScssFiles 제공 + size > 0 → 증분 컴파일
209
+ if (changedScssFiles == null || changedScssFiles.size > 0) {
190
210
  compileSideEffectScss(
191
211
  compiler!.sideEffectScssRegistry,
192
212
  loadPaths,
193
213
  sideEffectScssErrors,
194
214
  combinedScssDeps,
215
+ changedScssFiles,
216
+ sideEffectScssDeps,
195
217
  );
218
+ // side-effect SCSS 의존성이 combinedScssDeps에 추가되었으므로 역방향 인덱스 재구축
219
+ rebuildReverseScssDeps();
196
220
  }
197
221
 
198
222
  // 모든 에러 통합
@@ -240,7 +264,7 @@ async function startWatch(info: LibraryBuildInfo): Promise<void> {
240
264
  if (isAngular) {
241
265
  updateCombinedScssDeps(initialResult);
242
266
  }
243
- const initialEvent = buildWatchEvent(info, initialResult, isAngular);
267
+ const initialEvent = buildWatchEvent(info, initialResult);
244
268
  sender.send("build", initialEvent);
245
269
 
246
270
  // workspace 의존성 경로 + replaceDeps 수집
@@ -265,12 +289,13 @@ async function startWatch(info: LibraryBuildInfo): Promise<void> {
265
289
  for (const f of changedFiles) {
266
290
  modifiedFiles.add(f.path);
267
291
 
268
- // Angular: SCSS 역방향 의존성 탐색
292
+ // Angular: SCSS 역방향 의존성 탐색 (O(1) 조회)
269
293
  if (isAngular && (f.path.endsWith(".scss") || f.path.endsWith(".css"))) {
270
294
  const normalizedPath = pathx.posix(f.path);
271
- for (const [ownerFile, deps] of combinedScssDeps) {
272
- if (deps.has(normalizedPath)) {
273
- modifiedFiles.add(ownerFile);
295
+ const owners = reverseScssDeps.get(normalizedPath);
296
+ if (owners != null) {
297
+ for (const owner of owners) {
298
+ modifiedFiles.add(owner);
274
299
  }
275
300
  }
276
301
  }
@@ -289,11 +314,13 @@ async function startWatch(info: LibraryBuildInfo): Promise<void> {
289
314
  updateCombinedScssDeps(result);
290
315
  }
291
316
 
292
- const hasScssChanges = changedFiles.some(
293
- (f) => f.path.endsWith(".scss") || f.path.endsWith(".css"),
317
+ const changedScssFiles = new Set(
318
+ changedFiles
319
+ .filter((f) => f.path.endsWith(".scss") || f.path.endsWith(".css"))
320
+ .map((f) => pathx.posix(f.path)),
294
321
  );
295
322
 
296
- const event = buildWatchEvent(info, result, hasScssChanges);
323
+ const event = buildWatchEvent(info, result, changedScssFiles);
297
324
  sender.send("build", event);
298
325
  } catch (err) {
299
326
  sender.send("error", { message: errNs.message(err) });
@@ -18,6 +18,7 @@ import {
18
18
  import { collectAllExternals, generateProductionFiles } from "../deps/server-externals/server-production-files";
19
19
  import { SdTsCompiler } from "../ts-compiler/SdTsCompiler";
20
20
  import { createTscPlugin } from "../esbuild/esbuild-tsc-plugin";
21
+ import { createWorkerBundlePlugin } from "../esbuild/esbuild-worker-plugin";
21
22
  import { setupWorkerLifecycle } from "./shared-worker-lifecycle";
22
23
  import { buildWatchPaths } from "./build-watch-paths";
23
24
  import { copyPublicFiles, watchPublicFiles } from "../utils/copy-public";
@@ -140,7 +141,7 @@ async function build(info: ServerBuildInfo): Promise<ServerBuildResult> {
140
141
  const entryPoints = getPackageSourceFiles(info.pkgDir, parsedConfig);
141
142
 
142
143
  // 외부 모듈 수집
143
- const external = collectAllExternals(info.pkgDir, info.externals);
144
+ const { bundleExternals, prodDependencies } = collectAllExternals(info.pkgDir, info.externals);
144
145
 
145
146
  let jsResult: { success: boolean; errors?: string[]; warnings?: string[] };
146
147
  let tscErrors: string[];
@@ -162,10 +163,10 @@ async function build(info: ServerBuildInfo): Promise<ServerBuildResult> {
162
163
  pkgDir: info.pkgDir,
163
164
  entryPoints,
164
165
  env: info.env,
165
- external,
166
+ external: bundleExternals,
166
167
  });
167
168
 
168
- jsResult = await esbuild.build({ ...esbuildOptions, plugins: [tscPlugin.plugin] })
169
+ jsResult = await esbuild.build({ ...esbuildOptions, plugins: [createWorkerBundlePlugin(), tscPlugin.plugin] })
169
170
  .then(async (result) => {
170
171
  if (result.outputFiles) {
171
172
  await writeChangedOutputFiles(result.outputFiles);
@@ -212,7 +213,7 @@ async function build(info: ServerBuildInfo): Promise<ServerBuildResult> {
212
213
 
213
214
  await copyPublicFiles(info.pkgDir, false);
214
215
 
215
- generateProductionFiles(info, external);
216
+ generateProductionFiles(info, prodDependencies);
216
217
  }
217
218
 
218
219
  const allErrors = [...(jsResult.errors ?? []), ...tscErrors];
@@ -310,7 +311,7 @@ async function startWatch(info: ServerWatchInfo): Promise<void> {
310
311
  const entryPoints = getPackageSourceFiles(info.pkgDir, parsedConfig);
311
312
 
312
313
  // 외부 모듈 수집 (watch 모드용 — watch manager가 자체 캐시를 유지)
313
- const cachedExternal = collectAllExternals(info.pkgDir, info.externals);
314
+ const { bundleExternals: cachedExternal } = collectAllExternals(info.pkgDir, info.externals);
314
315
 
315
316
  // esbuild 컨텍스트 생성 (JS 출력 필요 시, tsc 플러그인 포함)
316
317
  if (info.output.js) {
@@ -10,6 +10,7 @@ import { createTscPlugin, type TscPluginResult } from "../esbuild/esbuild-tsc-pl
10
10
  import type { TypecheckEnv } from "../utils/tsconfig";
11
11
  import type { SerializedDiagnostic } from "../typecheck/typecheck-serialization";
12
12
  import type { LintWithProgramResult } from "../lint/lint-with-program";
13
+ import { createWorkerBundlePlugin } from "../esbuild/esbuild-worker-plugin";
13
14
 
14
15
  /**
15
16
  * esbuild watch context 생성 옵션
@@ -62,9 +63,10 @@ export async function createContext(options: EsbuildContextOptions): Promise<voi
62
63
  dev: true,
63
64
  });
64
65
 
66
+ const workerPlugin = createWorkerBundlePlugin();
65
67
  context = await esbuild.context({
66
68
  ...baseOptions,
67
- plugins: tscPlugin != null ? [tscPlugin.plugin] : [],
69
+ plugins: tscPlugin != null ? [workerPlugin, tscPlugin.plugin] : [workerPlugin],
68
70
  metafile: true,
69
71
  write: false,
70
72
  });
@@ -59,7 +59,7 @@ export async function startServerWatchLoop(config: ServerWatchLoopConfig): Promi
59
59
  c.path.endsWith("package.json"),
60
60
  );
61
61
  if (hasPackageJsonChange) {
62
- cachedExternal = collectAllExternals(info.pkgDir, info.externals);
62
+ cachedExternal = collectAllExternals(info.pkgDir, info.externals).bundleExternals;
63
63
  }
64
64
 
65
65
  if (info.output.js) {
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import path from "path";
3
+ import type { SideEffectScssEntry, SideEffectScssOptions } from "../../src/angular/ngtsc-build-core";
4
+
5
+ // Mock fs — filesystem I/O (OS 의존)
6
+ vi.mock("fs", () => ({
7
+ default: {
8
+ mkdirSync: vi.fn(),
9
+ writeFileSync: vi.fn(),
10
+ existsSync: vi.fn(() => false),
11
+ },
12
+ }));
13
+
14
+ // Mock scss-compiler — dart-sass 외부 의존성
15
+ const mockCompileScssFile = vi.fn<(filePath: string, loadPaths: string[]) => { css: string; dependencies: string[] }>()
16
+ .mockReturnValue({ css: "/* compiled */", dependencies: [] });
17
+
18
+ vi.mock("../../src/angular/scss-compiler", () => ({
19
+ compileScssFile: (filePath: string, loadPaths: string[]) => mockCompileScssFile(filePath, loadPaths),
20
+ compileScssString: vi.fn(() => ({ css: "", dependencies: [] })),
21
+ }));
22
+
23
+ const { writeEmitResults, compileSideEffectScss } = await import("../../src/angular/ngtsc-build-core");
24
+
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ mockCompileScssFile.mockReturnValue({ css: "/* compiled */", dependencies: [] as string[] });
28
+ });
29
+
30
+ describe("writeEmitResults — registryReverseIndex", () => {
31
+ // 테스트용 경로 헬퍼
32
+ const pkgDir = path.resolve("/test-pkg");
33
+ const srcFile = path.resolve(pkgDir, "src", "comp.ts");
34
+ const distDir = path.resolve(pkgDir, "dist");
35
+
36
+ function makeEmitResult(jsContent: string, sourceFileName?: string) {
37
+ // createOutputPathRewriter는 dist/ 하위 파일만 처리하므로 dist/ 경로로 생성
38
+ return {
39
+ filename: path.resolve(distDir, "comp.js"),
40
+ contents: jsContent,
41
+ sourceFileName,
42
+ };
43
+ }
44
+
45
+ // Acceptance: registryReverseIndex가 제공되면 O(1) 삭제를 수행한다
46
+ it("deletes registry entries using reverseIndex and cleans up reverseIndex", () => {
47
+ const oldScssPath = path.resolve(pkgDir, "src", "old.scss");
48
+ const registry = new Map<string, SideEffectScssEntry>([
49
+ [oldScssPath, { scssAbsPath: oldScssPath, cssAbsPath: "/out/old.css", sourceFileName: srcFile }],
50
+ ]);
51
+ const registryReverseIndex = new Map<string, Set<string>>([
52
+ [srcFile, new Set([oldScssPath])],
53
+ ]);
54
+ const scss: SideEffectScssOptions = {
55
+ loadPaths: [],
56
+ scssErrors: [],
57
+ scssDependencies: new Map(),
58
+ registry,
59
+ registryReverseIndex,
60
+ };
61
+
62
+ // SCSS import 없는 JS → 삭제만 발생, 등록 없음
63
+ writeEmitResults([makeEmitResult("export class Comp {}", srcFile)], pkgDir, scss);
64
+
65
+ expect(registry.has(oldScssPath)).toBe(false);
66
+ expect(registryReverseIndex.has(srcFile)).toBe(false);
67
+ });
68
+
69
+ // Acceptance: 삭제 후 새 SCSS import가 있으면 등록하고 reverseIndex도 갱신
70
+ it("registers new entries and updates reverseIndex after deletion", () => {
71
+ const oldScssPath = path.resolve(pkgDir, "src", "old.scss");
72
+ const registry = new Map<string, SideEffectScssEntry>([
73
+ [oldScssPath, { scssAbsPath: oldScssPath, cssAbsPath: "/out/old.css", sourceFileName: srcFile }],
74
+ ]);
75
+ const registryReverseIndex = new Map<string, Set<string>>([
76
+ [srcFile, new Set([oldScssPath])],
77
+ ]);
78
+ const scss: SideEffectScssOptions = {
79
+ loadPaths: [],
80
+ scssErrors: [],
81
+ scssDependencies: new Map(),
82
+ registry,
83
+ registryReverseIndex,
84
+ };
85
+
86
+ // JS with SCSS import → 삭제 + 등록
87
+ const jsContent = 'import "./button.scss";\nexport class Comp {}';
88
+ writeEmitResults([makeEmitResult(jsContent, srcFile)], pkgDir, scss);
89
+
90
+ // old.scss 삭제됨
91
+ expect(registry.has(oldScssPath)).toBe(false);
92
+ // button.scss가 등록됨 (scssAbsPath = path.resolve(srcDir, "./button.scss"))
93
+ const expectedScssPath = path.resolve(pkgDir, "src", "button.scss");
94
+ expect(registry.has(expectedScssPath)).toBe(true);
95
+ // reverseIndex에 새 항목 반영
96
+ expect(registryReverseIndex.get(srcFile)).toEqual(new Set([expectedScssPath]));
97
+ });
98
+
99
+ // Acceptance: reverseIndex에 없는 sourceFileName이면 삭제 없음
100
+ it("does not delete when sourceFileName is not in reverseIndex", () => {
101
+ const otherScssPath = path.resolve(pkgDir, "src", "other.scss");
102
+ const otherSrcFile = path.resolve(pkgDir, "src", "other.ts");
103
+ const registry = new Map<string, SideEffectScssEntry>([
104
+ [otherScssPath, { scssAbsPath: otherScssPath, cssAbsPath: "/out/other.css", sourceFileName: otherSrcFile }],
105
+ ]);
106
+ const registryReverseIndex = new Map<string, Set<string>>([
107
+ [otherSrcFile, new Set([otherScssPath])],
108
+ ]);
109
+ const scss: SideEffectScssOptions = {
110
+ loadPaths: [],
111
+ scssErrors: [],
112
+ scssDependencies: new Map(),
113
+ registry,
114
+ registryReverseIndex,
115
+ };
116
+
117
+ // srcFile (comp.ts)에 대한 emit → other.ts의 항목은 건드리지 않음
118
+ writeEmitResults([makeEmitResult("export class Comp {}", srcFile)], pkgDir, scss);
119
+
120
+ expect(registry.has(otherScssPath)).toBe(true);
121
+ expect(registryReverseIndex.has(otherSrcFile)).toBe(true);
122
+ });
123
+ });
124
+
125
+ describe("compileSideEffectScss — incremental compilation", () => {
126
+ function makeEntry(scssAbsPath: string, sourceFileName: string): SideEffectScssEntry {
127
+ return {
128
+ scssAbsPath,
129
+ cssAbsPath: scssAbsPath.replace(/\.scss$/, ".css"),
130
+ sourceFileName,
131
+ };
132
+ }
133
+
134
+ // Acceptance: changedScssFiles 미제공 시 전체 재컴파일
135
+ it("compiles all entries when changedScssFiles is not provided", () => {
136
+ const registry = new Map<string, SideEffectScssEntry>([
137
+ ["/src/button.scss", makeEntry("/src/button.scss", "/src/comp.ts")],
138
+ ["/src/dialog.scss", makeEntry("/src/dialog.scss", "/src/comp.ts")],
139
+ ["/src/card.scss", makeEntry("/src/card.scss", "/src/other.ts")],
140
+ ]);
141
+
142
+ compileSideEffectScss(registry, [], [], new Map());
143
+
144
+ expect(mockCompileScssFile).toHaveBeenCalledTimes(3);
145
+ });
146
+
147
+ // Acceptance: 변경된 SCSS 파일만 재컴파일 (직접 히트)
148
+ it("only compiles entries whose scssAbsPath is in changedScssFiles", () => {
149
+ const registry = new Map<string, SideEffectScssEntry>([
150
+ ["/src/button.scss", makeEntry("/src/button.scss", "/src/comp.ts")],
151
+ ["/src/dialog.scss", makeEntry("/src/dialog.scss", "/src/other.ts")],
152
+ ]);
153
+ const changedScssFiles = new Set(["/src/button.scss"]);
154
+ const sideEffectScssDeps = new Map<string, Set<string>>();
155
+
156
+ compileSideEffectScss(registry, [], [], new Map(), changedScssFiles, sideEffectScssDeps);
157
+
158
+ expect(mockCompileScssFile).toHaveBeenCalledTimes(1);
159
+ expect(mockCompileScssFile).toHaveBeenCalledWith("/src/button.scss", []);
160
+ });
161
+
162
+ // Acceptance: 의존성이 변경된 SCSS도 재컴파일
163
+ it("recompiles entries whose dependency is in changedScssFiles", () => {
164
+ const registry = new Map<string, SideEffectScssEntry>([
165
+ ["/src/button.scss", makeEntry("/src/button.scss", "/src/comp.ts")],
166
+ ]);
167
+ const changedScssFiles = new Set(["/src/shared.scss"]);
168
+ // button.scss의 이전 컴파일에서 shared.scss가 의존성이었음
169
+ const sideEffectScssDeps = new Map<string, Set<string>>([
170
+ ["/src/button.scss", new Set(["/src/shared.scss"])],
171
+ ]);
172
+
173
+ compileSideEffectScss(registry, [], [], new Map(), changedScssFiles, sideEffectScssDeps);
174
+
175
+ expect(mockCompileScssFile).toHaveBeenCalledTimes(1);
176
+ expect(mockCompileScssFile).toHaveBeenCalledWith("/src/button.scss", []);
177
+ });
178
+
179
+ // Acceptance: 영향받지 않는 항목은 건너뜀
180
+ it("skips entries not affected by changedScssFiles", () => {
181
+ const registry = new Map<string, SideEffectScssEntry>([
182
+ ["/src/button.scss", makeEntry("/src/button.scss", "/src/comp.ts")],
183
+ ["/src/dialog.scss", makeEntry("/src/dialog.scss", "/src/other.ts")],
184
+ ]);
185
+ const changedScssFiles = new Set(["/src/shared.scss"]);
186
+ const sideEffectScssDeps = new Map<string, Set<string>>([
187
+ ["/src/button.scss", new Set(["/src/shared.scss"])],
188
+ ["/src/dialog.scss", new Set(["/src/theme.scss"])],
189
+ ]);
190
+
191
+ compileSideEffectScss(registry, [], [], new Map(), changedScssFiles, sideEffectScssDeps);
192
+
193
+ // button.scss만 재컴파일 (shared.scss가 의존성), dialog.scss는 건너뜀
194
+ expect(mockCompileScssFile).toHaveBeenCalledTimes(1);
195
+ expect(mockCompileScssFile).toHaveBeenCalledWith("/src/button.scss", []);
196
+ });
197
+
198
+ // Unit: 컴파일 후 sideEffectScssDeps가 갱신됨
199
+ it("updates sideEffectScssDeps after compilation", () => {
200
+ const registry = new Map<string, SideEffectScssEntry>([
201
+ ["/src/button.scss", makeEntry("/src/button.scss", "/src/comp.ts")],
202
+ ]);
203
+ mockCompileScssFile.mockReturnValue({ css: "/* ok */", dependencies: ["/src/new-dep.scss"] });
204
+ const sideEffectScssDeps = new Map<string, Set<string>>();
205
+
206
+ compileSideEffectScss(registry, [], [], new Map(), undefined, sideEffectScssDeps);
207
+
208
+ expect(sideEffectScssDeps.get("/src/button.scss")).toEqual(new Set(["/src/new-dep.scss"]));
209
+ });
210
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildReverseDeps } from "../../src/angular/ngtsc-build-core";
3
+
4
+ describe("buildReverseDeps", () => {
5
+ // Acceptance: 정방향 맵에서 역방향 인덱스를 구축한다
6
+ it("builds reverse index from forward deps with multiple owners sharing a dep", () => {
7
+ const forward = new Map<string, ReadonlySet<string>>([
8
+ ["comp.ts", new Set(["shared.scss", "theme.scss"])],
9
+ ["dialog.ts", new Set(["shared.scss"])],
10
+ ]);
11
+
12
+ const reverse = buildReverseDeps(forward);
13
+
14
+ expect(reverse.get("shared.scss")).toEqual(new Set(["comp.ts", "dialog.ts"]));
15
+ expect(reverse.get("theme.scss")).toEqual(new Set(["comp.ts"]));
16
+ expect(reverse.size).toBe(2);
17
+ });
18
+
19
+ // Acceptance: 빈 맵이면 역방향 맵도 비어있다
20
+ it("returns empty map for empty input", () => {
21
+ const reverse = buildReverseDeps(new Map());
22
+
23
+ expect(reverse.size).toBe(0);
24
+ });
25
+
26
+ // Unit: 하나의 소유자가 여러 의존성을 가진 경우
27
+ it("maps each dep to its single owner", () => {
28
+ const forward = new Map<string, ReadonlySet<string>>([
29
+ ["comp.ts", new Set(["a.scss", "b.scss", "c.scss"])],
30
+ ]);
31
+
32
+ const reverse = buildReverseDeps(forward);
33
+
34
+ expect(reverse.size).toBe(3);
35
+ expect(reverse.get("a.scss")).toEqual(new Set(["comp.ts"]));
36
+ expect(reverse.get("b.scss")).toEqual(new Set(["comp.ts"]));
37
+ expect(reverse.get("c.scss")).toEqual(new Set(["comp.ts"]));
38
+ });
39
+
40
+ // Unit: 의존성이 빈 Set인 소유자는 역방향 맵에 영향 없음
41
+ it("ignores owners with empty dep sets", () => {
42
+ const forward = new Map<string, ReadonlySet<string>>([
43
+ ["comp.ts", new Set<string>()],
44
+ ["dialog.ts", new Set(["shared.scss"])],
45
+ ]);
46
+
47
+ const reverse = buildReverseDeps(forward);
48
+
49
+ expect(reverse.size).toBe(1);
50
+ expect(reverse.get("shared.scss")).toEqual(new Set(["dialog.ts"]));
51
+ });
52
+ });