@simplysm/sd-cli 14.0.17 → 14.0.19

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 (49) hide show
  1. package/dist/angular/vite-angular-plugin.d.ts +2 -0
  2. package/dist/angular/vite-angular-plugin.d.ts.map +1 -1
  3. package/dist/angular/vite-angular-plugin.js +57 -28
  4. package/dist/angular/vite-angular-plugin.js.map +1 -1
  5. package/dist/capacitor/capacitor.d.ts +0 -1
  6. package/dist/capacitor/capacitor.d.ts.map +1 -1
  7. package/dist/capacitor/capacitor.js +12 -37
  8. package/dist/capacitor/capacitor.js.map +1 -1
  9. package/dist/commands/device.d.ts.map +1 -1
  10. package/dist/commands/device.js +3 -2
  11. package/dist/commands/device.js.map +1 -1
  12. package/dist/electron/electron.d.ts.map +1 -1
  13. package/dist/electron/electron.js +9 -4
  14. package/dist/electron/electron.js.map +1 -1
  15. package/dist/orchestrators/DevWatchOrchestrator.d.ts.map +1 -1
  16. package/dist/orchestrators/DevWatchOrchestrator.js +12 -0
  17. package/dist/orchestrators/DevWatchOrchestrator.js.map +1 -1
  18. package/dist/utils/vite-config.d.ts +1 -1
  19. package/dist/utils/vite-config.d.ts.map +1 -1
  20. package/dist/utils/vite-config.js +76 -26
  21. package/dist/utils/vite-config.js.map +1 -1
  22. package/dist/utils/vite-scope-watch-plugin.d.ts.map +1 -1
  23. package/dist/utils/vite-scope-watch-plugin.js +7 -1
  24. package/dist/utils/vite-scope-watch-plugin.js.map +1 -1
  25. package/dist/workers/server-runtime.worker.d.ts.map +1 -1
  26. package/dist/workers/server-runtime.worker.js +15 -0
  27. package/dist/workers/server-runtime.worker.js.map +1 -1
  28. package/package.json +9 -7
  29. package/src/angular/vite-angular-plugin.ts +88 -34
  30. package/src/capacitor/capacitor.ts +14 -46
  31. package/src/commands/device.ts +3 -2
  32. package/src/electron/electron.ts +11 -4
  33. package/src/orchestrators/DevWatchOrchestrator.ts +14 -0
  34. package/src/utils/vite-config.ts +83 -27
  35. package/src/utils/vite-scope-watch-plugin.ts +6 -1
  36. package/src/workers/server-runtime.worker.ts +15 -0
  37. package/tests/angular/linker-disk-cache.spec.ts +31 -25
  38. package/tests/angular/vite-angular-plugin-hmr-fallback.spec.ts +15 -15
  39. package/tests/angular/vite-angular-plugin-hmr.spec.ts +9 -9
  40. package/tests/angular/vite-angular-plugin-legacy-watch.spec.ts +108 -0
  41. package/tests/angular/vite-angular-plugin-lint.spec.ts +4 -4
  42. package/tests/angular/vite-angular-plugin-scss-hmr.spec.ts +10 -15
  43. package/tests/angular/vite-angular-plugin.spec.ts +80 -15
  44. package/tests/capacitor/capacitor-workspace.spec.ts +22 -12
  45. package/tests/commands/device.spec.ts +12 -7
  46. package/tests/electron/electron.spec.ts +27 -2
  47. package/tests/utils/vite-config.spec.ts +255 -133
  48. package/tests/utils/vite-scope-watch-plugin.spec.ts +22 -0
  49. package/tests/workers/server-runtime-worker.spec.ts +48 -4
@@ -1,4 +1,4 @@
1
- import type { Plugin, EnvironmentModuleNode, HotUpdateOptions, ViteDevServer } from "vite";
1
+ import type { Plugin, ModuleNode, ViteDevServer } from "vite";
2
2
  import type { IncomingMessage, ServerResponse } from "http";
3
3
  import { JavaScriptTransformer } from "@angular/build/private";
4
4
  import { createHash } from "crypto";
@@ -49,6 +49,8 @@ export interface SdAngularPluginOptions {
49
49
  postCssPlugins?: unknown[];
50
50
  /** Linker 캐시 디렉토리 (기본값: {cwd}/.cache/linker/{sm|nosm}) */
51
51
  linkerCacheDir?: string;
52
+ /** replaceDeps 패키지의 resolved dist 경로 목록 (Linker 캐시 skip + full-reload 트리거) */
53
+ replaceDepDistPaths?: string[];
52
54
  }
53
55
 
54
56
  /**
@@ -108,6 +110,10 @@ export function sdAngularPlugin(options: SdAngularPluginOptions): Plugin {
108
110
  const templateUpdates = new Map<string, string>();
109
111
  let hmrLock: Promise<void> = Promise.resolve();
110
112
  const scssDependencies = new Map<string, Set<string>>();
113
+ let devServer: ViteDevServer | undefined;
114
+
115
+ /** Rolldown watch 모드에서 변경된 파일 경로를 수집한다. buildStart 재호출 시 캐시 무효화에 사용. */
116
+ const pendingWatchChanges = new Set<string>();
111
117
 
112
118
  const enableSourcemap = options.sourcemap ?? options.dev;
113
119
 
@@ -134,6 +140,10 @@ export function sdAngularPlugin(options: SdAngularPluginOptions): Plugin {
134
140
  name: "sd-angular",
135
141
  enforce: "pre",
136
142
 
143
+ watchChange(id: string) {
144
+ pendingWatchChanges.add(pathx.posix(id));
145
+ },
146
+
137
147
  config() {
138
148
  const linkerCacheDir =
139
149
  options.linkerCacheDir ??
@@ -146,37 +156,53 @@ export function sdAngularPlugin(options: SdAngularPluginOptions): Plugin {
146
156
  ngHmrMode: options.dev && !options.legacyModule ? undefined : "false",
147
157
  },
148
158
  optimizeDeps: {
149
- rolldownOptions: {
159
+ esbuildOptions: {
150
160
  plugins: [
151
161
  {
152
162
  name: "angular-vite-optimize-deps",
153
- async load(id: string) {
154
- if (!/\.[cm]?js$/.test(id)) return null;
155
-
156
- const content = await fsp.readFile(id, "utf-8");
157
- const hash = createHash("sha256").update(content).digest("hex");
158
- const cachePath = path.join(linkerCacheDir, `${hash}.js`);
159
-
160
- try {
161
- return await fsp.readFile(cachePath, "utf-8");
162
- } catch {
163
- // cache miss
164
- }
165
-
166
- const result = await prebundleTransformer.transformFile(id);
167
- const resultStr =
168
- typeof result === "string"
169
- ? result
170
- : new TextDecoder().decode(result);
171
-
172
- try {
173
- await fsp.mkdir(linkerCacheDir, { recursive: true });
174
- await fsp.writeFile(cachePath, resultStr);
175
- } catch {
176
- // cache write failure — non-fatal
177
- }
178
-
179
- return resultStr;
163
+ setup(build: { onLoad: Function }) {
164
+ build.onLoad(
165
+ { filter: /\.[cm]?js$/ },
166
+ async (args: { path: string }) => {
167
+ if (!/\.[cm]?js$/.test(args.path)) return null;
168
+
169
+ // replaceDeps 파일은 Linker 캐시를 건너뛴다 (항상 fresh 처리)
170
+ if (
171
+ options.replaceDepDistPaths != null &&
172
+ options.replaceDepDistPaths.some((p) =>
173
+ pathx.posix(args.path).startsWith(p),
174
+ )
175
+ ) {
176
+ return null;
177
+ }
178
+
179
+ const content = await fsp.readFile(args.path, "utf-8");
180
+ const hash = createHash("sha256").update(content).digest("hex");
181
+ const cachePath = path.join(linkerCacheDir, `${hash}.js`);
182
+
183
+ try {
184
+ const cached = await fsp.readFile(cachePath, "utf-8");
185
+ return { contents: cached, loader: "js" as const };
186
+ } catch {
187
+ // cache miss
188
+ }
189
+
190
+ const result = await prebundleTransformer.transformFile(args.path);
191
+ const resultStr =
192
+ typeof result === "string"
193
+ ? result
194
+ : new TextDecoder().decode(result);
195
+
196
+ try {
197
+ await fsp.mkdir(linkerCacheDir, { recursive: true });
198
+ await fsp.writeFile(cachePath, resultStr);
199
+ } catch {
200
+ // cache write failure — non-fatal
201
+ }
202
+
203
+ return { contents: resultStr, loader: "js" as const };
204
+ },
205
+ );
180
206
  },
181
207
  },
182
208
  ],
@@ -196,6 +222,13 @@ export function sdAngularPlugin(options: SdAngularPluginOptions): Plugin {
196
222
  // AngularSourceFileCache 생성 (또는 재사용)
197
223
  sourceFileCache ??= new AngularSourceFileCache();
198
224
 
225
+ // Rolldown watch 재빌드: 변경된 파일의 캐시 무효화
226
+ if (pendingWatchChanges.size > 0) {
227
+ logger.debug(`watch 변경 파일 ${pendingWatchChanges.size}개 캐시 무효화`);
228
+ sourceFileCache.invalidate(pendingWatchChanges);
229
+ pendingWatchChanges.clear();
230
+ }
231
+
199
232
  // SCSS errors 수집용
200
233
  const scssErrors: string[] = [];
201
234
 
@@ -229,6 +262,7 @@ export function sdAngularPlugin(options: SdAngularPluginOptions): Plugin {
229
262
  noEmit: false,
230
263
  declaration: false,
231
264
  declarationMap: false,
265
+ ...(options.dev ? { removeComments: false } : {}),
232
266
  }),
233
267
  });
234
268
 
@@ -281,16 +315,35 @@ export function sdAngularPlugin(options: SdAngularPluginOptions): Plugin {
281
315
  });
282
316
  },
283
317
 
284
- async hotUpdate({
318
+ async handleHotUpdate({
285
319
  file,
286
320
  modules,
287
- }: HotUpdateOptions): Promise<EnvironmentModuleNode[] | void> {
321
+ server,
322
+ }: {
323
+ file: string;
324
+ modules: ModuleNode[];
325
+ server: ViteDevServer;
326
+ timestamp: number;
327
+ read: () => string | Promise<string>;
328
+ }): Promise<ModuleNode[] | void> {
288
329
  if (compiler == null || !options.dev) return;
289
330
  if (
290
331
  !file.endsWith(".ts") &&
291
332
  !file.endsWith(".html") &&
292
333
  !file.endsWith(".scss")
293
334
  ) {
335
+ // replaceDeps .js 파일 변경 시 full-reload 강제
336
+ if (
337
+ file.endsWith(".js") &&
338
+ devServer != null &&
339
+ options.replaceDepDistPaths != null &&
340
+ options.replaceDepDistPaths.some((p) =>
341
+ pathx.posix(file).startsWith(p),
342
+ )
343
+ ) {
344
+ devServer.hot.send({ type: "full-reload" });
345
+ return [];
346
+ }
294
347
  return;
295
348
  }
296
349
 
@@ -372,10 +425,9 @@ export function sdAngularPlugin(options: SdAngularPluginOptions): Plugin {
372
425
 
373
426
  if (file.endsWith(".scss")) {
374
427
  // SCSS: moduleGraph에서 영향받은 TS 모듈 조회
375
- const moduleGraph = this.environment.moduleGraph;
376
- const result: EnvironmentModuleNode[] = [];
428
+ const result: ModuleNode[] = [];
377
429
  for (const p of affectedPaths) {
378
- const mods = moduleGraph.getModulesByFile(p);
430
+ const mods = server.moduleGraph.getModulesByFile(p);
379
431
  if (mods) result.push(...mods);
380
432
  }
381
433
  return result;
@@ -443,6 +495,8 @@ export function sdAngularPlugin(options: SdAngularPluginOptions): Plugin {
443
495
  },
444
496
 
445
497
  configureServer(server: ViteDevServer) {
498
+ devServer = server;
499
+
446
500
  // component-middleware 등록 (HMR template updates 서빙)
447
501
  server.middlewares.use(angularComponentMiddleware(templateUpdates, server.config.base));
448
502
 
@@ -1,7 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "path";
4
- import { symlink } from "fs/promises";
5
4
  import { createRequire } from "module";
6
5
  import { cpx, fsx, pathx } from "@simplysm/core-node";
7
6
  import { env } from "@simplysm/core-common";
@@ -247,14 +246,12 @@ export class Capacitor {
247
246
  */
248
247
  private async _initCap(): Promise<boolean> {
249
248
  Capacitor._logger.debug("package.json 설정 시작");
250
- const { depChanged, workspacePlugins } = await this._setupNpmConf();
249
+ const depChanged = await this._setupNpmConf();
251
250
  const nodeModulesExists = await fsx.exists(pathx.posixResolve(this._capPath, "node_modules"));
252
251
  Capacitor._logger.debug(`depChanged: ${depChanged}, nodeModulesExists: ${nodeModulesExists}`);
253
252
 
254
253
  if (!depChanged && nodeModulesExists) {
255
- // 의존성 미변경이어도 workspace 플러그인 symlink는 항상 갱신
256
- Capacitor._logger.debug("의존성 변경 없음, workspace 플러그인 symlink만 갱신");
257
- await this._linkWorkspacePlugins(workspacePlugins);
254
+ Capacitor._logger.debug("의존성 변경 없음");
258
255
  return false;
259
256
  }
260
257
 
@@ -270,11 +267,6 @@ export class Capacitor {
270
267
  await this._exec("pnpm", ["approve-builds", "--all"], this._capPath);
271
268
  Capacitor._logger.debug("pnpm install 완료");
272
269
 
273
- // workspace 플러그인 symlink
274
- Capacitor._logger.debug("workspace 플러그인 symlink 시작");
275
- await this._linkWorkspacePlugins(workspacePlugins);
276
- Capacitor._logger.debug("workspace 플러그인 symlink 완료");
277
-
278
270
  // 멱등성: capacitor.config.ts가 없을 때만 cap init 실행
279
271
  const configPath = pathx.posixResolve(this._capPath, "capacitor.config.ts");
280
272
  if (!(await fsx.exists(configPath))) {
@@ -300,7 +292,7 @@ export class Capacitor {
300
292
  /**
301
293
  * package.json 설정
302
294
  */
303
- private async _setupNpmConf(): Promise<{ depChanged: boolean; workspacePlugins: string[] }> {
295
+ private async _setupNpmConf(): Promise<boolean> {
304
296
  const projNpmConfigPath = pathx.posixResolve(this._findWorkspaceRoot(), "package.json");
305
297
 
306
298
  // 루트 package.json 존재 확인
@@ -358,16 +350,17 @@ export class Capacitor {
358
350
  }
359
351
  }
360
352
 
361
- // 새 플러그인 추가 (workspace:* 플러그인은 분리)
362
- const workspacePlugins: string[] = [];
353
+ // 새 플러그인 추가
354
+ const pkgRequire = createRequire(pathx.posixResolve(this._pkgPath, "package.json"));
363
355
  for (const plugin of usePlugins) {
364
356
  const version = mainDeps[plugin] ?? "*";
365
357
  if (typeof version === "string" && version.startsWith("workspace:")) {
366
- // workspace 플러그인은 package.json에 추가하지 않고 symlink로 처리
367
- workspacePlugins.push(plugin);
368
- // 이전에 추가되어 있었으면 제거
369
- delete capNpmConf.dependencies[plugin];
370
- Capacitor._logger.debug(`workspace 플러그인 (symlink 예정): ${plugin}`);
358
+ // workspace 플러그인은 link: 프로토콜로 실제 경로를 지정
359
+ const pluginPkgJsonPath = pkgRequire.resolve(`${plugin}/package.json`);
360
+ const pluginDir = path.dirname(pluginPkgJsonPath);
361
+ const relativePath = path.relative(this._capPath, pluginDir).replace(/\\/g, "/");
362
+ capNpmConf.dependencies[plugin] = `link:${relativePath}`;
363
+ Capacitor._logger.debug(`workspace 플러그인 (link): ${plugin} → ${relativePath}`);
371
364
  } else if (!(plugin in capNpmConf.dependencies)) {
372
365
  capNpmConf.dependencies[plugin] = version;
373
366
  Capacitor._logger.debug(`플러그인 추가: ${plugin}@${version}`);
@@ -388,12 +381,11 @@ export class Capacitor {
388
381
  await fsx.writeJson(capNpmConfPath, capNpmConf, { space: 2 });
389
382
 
390
383
  // 의존성 변경 여부 확인
391
- const isChanged =
384
+ return (
392
385
  orgCapNpmConf.volta !== capNpmConf.volta ||
393
386
  JSON.stringify(orgCapNpmConf.dependencies) !== JSON.stringify(capNpmConf.dependencies) ||
394
- JSON.stringify(orgCapNpmConf.devDependencies) !== JSON.stringify(capNpmConf.devDependencies);
395
-
396
- return { depChanged: isChanged, workspacePlugins };
387
+ JSON.stringify(orgCapNpmConf.devDependencies) !== JSON.stringify(capNpmConf.devDependencies)
388
+ );
397
389
  }
398
390
 
399
391
  /**
@@ -1087,30 +1079,6 @@ export default config;
1087
1079
  * workspace:* 플러그인을 .capacitor/node_modules/에 symlink로 연결한다.
1088
1080
  * cap sync는 플러그인의 android/ 네이티브 코드만 필요하므로 JS 의존성 resolve 불필요.
1089
1081
  */
1090
- private async _linkWorkspacePlugins(plugins: string[]): Promise<void> {
1091
- if (plugins.length === 0) return;
1092
-
1093
- const require = createRequire(pathx.posixResolve(this._pkgPath, "package.json"));
1094
-
1095
- for (const plugin of plugins) {
1096
- const pluginPkgJsonPath = require.resolve(`${plugin}/package.json`);
1097
- const pluginDir = path.dirname(pluginPkgJsonPath);
1098
-
1099
- const linkPath = pathx.posixResolve(this._capPath, "node_modules", ...plugin.split("/"));
1100
-
1101
- // scope 디렉토리 생성 (예: @simplysm/)
1102
- await fsx.mkdir(path.dirname(linkPath));
1103
-
1104
- // 기존 symlink가 있으면 삭제
1105
- if (await fsx.exists(linkPath)) {
1106
- await fsx.rm(linkPath);
1107
- }
1108
-
1109
- await symlink(pluginDir, linkPath, "junction");
1110
- Capacitor._logger.debug(`workspace 플러그인 symlink: ${plugin} → ${pluginDir}`);
1111
- }
1112
- }
1113
-
1114
1082
  /**
1115
1083
  * pnpm-workspace.yaml이 있는 워크스페이스 루트 디렉토리를 찾는다.
1116
1084
  */
@@ -64,8 +64,9 @@ export async function runDevice(options: DeviceOptions): Promise<void> {
64
64
  if (typeof clientConfig.server === "number") {
65
65
  serverUrl = `http://localhost:${clientConfig.server}/${targetName}/`;
66
66
  } else {
67
- // server가 패키지명(string)인 경우: .dev-port 파일에서 포트 자동 탐지
68
- const portFile = path.join(pkgDir, "dist", ".dev-port");
67
+ // server가 패키지명(string)인 경우: 서버 패키지의 .dev-port 파일에서 포트 자동 탐지
68
+ const serverPkgDir = pathx.posixResolve(cwd, "packages", clientConfig.server);
69
+ const portFile = path.join(serverPkgDir, "dist", ".dev-port");
69
70
  let portStr: string;
70
71
  try {
71
72
  portStr = fs.readFileSync(portFile, "utf-8").trim();
@@ -125,6 +125,9 @@ export class Electron {
125
125
  };
126
126
 
127
127
  const envBanner = createEnvBanner({ ELECTRON_DEV_URL: url, ...this._config.env });
128
+ const bannerJs =
129
+ "import { createRequire } from 'module'; const require = createRequire(import.meta.url);" +
130
+ envBanner;
128
131
 
129
132
  Electron._logger.debug("esbuild context 생성 시작");
130
133
  const ctx = await esbuild.context({
@@ -132,10 +135,10 @@ export class Electron {
132
135
  outfile: pathx.posixResolve(this._srcPath, "electron-main.js"),
133
136
  platform: "node",
134
137
  target: "node20",
135
- format: "cjs",
138
+ format: "esm",
136
139
  bundle: true,
137
140
  external: ["electron", ...builtinModules, ...reinstallDeps, ...this._exclude],
138
- banner: { js: envBanner },
141
+ banner: { js: bannerJs },
139
142
  plugins: [
140
143
  {
141
144
  name: "electron-restart",
@@ -264,6 +267,7 @@ export class Electron {
264
267
  name: this._npmConfig.name.replace(/^@/, "").replace(/\//, "-"),
265
268
  version: this._npmConfig.version,
266
269
  description: this._npmConfig.description,
270
+ type: "module",
267
271
  main: "electron-main.js",
268
272
  dependencies,
269
273
  devDependencies,
@@ -292,6 +296,9 @@ export class Electron {
292
296
  const reinstallDeps = this._config.reinstallDependencies ?? [];
293
297
 
294
298
  const envBanner = createEnvBanner(this._config.env);
299
+ const bannerJs =
300
+ "import { createRequire } from 'module'; const require = createRequire(import.meta.url);" +
301
+ envBanner;
295
302
 
296
303
  Electron._logger.debug(`esbuild 번들링: ${entryPoint}`);
297
304
  await esbuild.build({
@@ -299,10 +306,10 @@ export class Electron {
299
306
  outfile: pathx.posixResolve(this._srcPath, "electron-main.js"),
300
307
  platform: "node",
301
308
  target: "node20",
302
- format: "cjs",
309
+ format: "esm",
303
310
  bundle: true,
304
311
  external: ["electron", ...builtinModules, ...reinstallDeps, ...this._exclude],
305
- banner: { js: envBanner },
312
+ banner: { js: bannerJs },
306
313
  });
307
314
  }
308
315
 
@@ -277,6 +277,20 @@ export class DevWatchOrchestrator {
277
277
 
278
278
  private async _startWatchMode(): Promise<void> {
279
279
  this._logger.debug("watch 모드 시작");
280
+
281
+ // [DEBUG] angular/dist 삭제 감지용 임시 워처
282
+ {
283
+ const debugDistDir = pathx.posixResolve(this._cwd, "packages", "angular", "dist");
284
+ const debugWatcher = await FsWatcher.watch([debugDistDir]);
285
+ debugWatcher.onChange({ delay: 100 }, (changes) => {
286
+ for (const c of changes) {
287
+ if (c.event === "unlink" || c.event === "unlinkDir") {
288
+ this._logger.error(`[DEBUG:angular-dist] ${c.event}: ${c.path}\n${new Error().stack}`);
289
+ }
290
+ }
291
+ });
292
+ }
293
+
280
294
  // Start copySrc watchers for library packages
281
295
  for (const pkg of this._libraryPackages) {
282
296
  if (pkg.config.copySrc != null && pkg.config.copySrc.length > 0) {
@@ -1,6 +1,9 @@
1
1
  import type { InlineConfig, PluginOption } from "vite";
2
2
  import path from "path";
3
+ import fs from "fs";
4
+ import tsconfigPaths from "vite-tsconfig-paths";
3
5
  import browserslistToEsbuild from "browserslist-to-esbuild";
6
+ import { pathx } from "@simplysm/core-node";
4
7
  import { sdAngularPlugin } from "../angular/vite-angular-plugin.js";
5
8
  import solidPlugin from "vite-plugin-solid";
6
9
  import {
@@ -9,7 +12,8 @@ import {
9
12
  } from "./vite-scope-watch-plugin.js";
10
13
  import { sdPostCssInlinePlugin } from "../angular/vite-postcss-inline-plugin.js";
11
14
  import type { SdPwaConfig } from "../sd-config.types.js";
12
- import { sdPwaPlugin } from "./vite-pwa-plugin.js";
15
+ import { VitePWA } from "vite-plugin-pwa";
16
+ import { generatePwaIcons } from "./generate-pwa-icons.js";
13
17
 
14
18
  /** createClientViteConfig 옵션 */
15
19
  export interface CreateClientViteConfigOptions {
@@ -68,9 +72,9 @@ export interface CreateClientViteConfigOptions {
68
72
  * Angular AOT 플러그인, tsconfigPaths, env define, server/build 기본 설정,
69
73
  * browserslist, PostCSS, polyfills, legacyModule (inlineDynamicImports) 등을 통합 구성한다.
70
74
  */
71
- export function createClientViteConfig(
75
+ export async function createClientViteConfig(
72
76
  options: CreateClientViteConfigOptions,
73
- ): InlineConfig {
77
+ ): Promise<InlineConfig> {
74
78
  const name = options.pkgName.replace(/^@[^/]+\//, "");
75
79
 
76
80
  // browserslist → esbuild target
@@ -98,8 +102,29 @@ export function createClientViteConfig(
98
102
  }
99
103
  }
100
104
 
105
+ // replaceDeps dist 경로 (symlink → realpath 해결)
106
+ let replaceDepDistPaths: string[] | undefined;
107
+ if (options.replaceDeps != null && options.replaceDeps.length > 0) {
108
+ replaceDepDistPaths = [];
109
+ for (const dep of options.replaceDeps) {
110
+ const distDir = path.join(
111
+ options.pkgDir,
112
+ "node_modules",
113
+ ...dep.packageName.split("/"),
114
+ "dist",
115
+ );
116
+ try {
117
+ replaceDepDistPaths.push(pathx.posix(fs.realpathSync(distDir)));
118
+ } catch {
119
+ replaceDepDistPaths.push(pathx.posix(distDir));
120
+ }
121
+ }
122
+ }
123
+
101
124
  // plugins
102
- const plugins: PluginOption[] = [];
125
+ const plugins: PluginOption[] = [
126
+ tsconfigPaths({ projects: [options.tsconfigPath] }),
127
+ ];
103
128
 
104
129
  if (options.framework === "solid") {
105
130
  plugins.push(solidPlugin());
@@ -115,6 +140,7 @@ export function createClientViteConfig(
115
140
  enableLint: options.enableLint,
116
141
  browserslist: normalizedBrowserslist,
117
142
  postCssPlugins: options.postCssPlugins,
143
+ replaceDepDistPaths,
118
144
  }),
119
145
  );
120
146
 
@@ -157,32 +183,70 @@ export function createClientViteConfig(
157
183
  ? { postcss: { plugins: options.postCssPlugins as import("postcss").AcceptedPlugin[] } }
158
184
  : undefined;
159
185
 
160
- // optimizeDeps.exclude (사용자 지정 exclude)
186
+ // optimizeDeps.exclude (사용자 지정 exclude + replaceDeps 패키지)
187
+ const excludeList = [
188
+ ...(options.exclude ?? []),
189
+ ...(options.replaceDeps?.map((dep) => dep.packageName) ?? []),
190
+ ];
161
191
  const optimizeDepsConfig =
162
- options.exclude != null && options.exclude.length > 0
163
- ? { exclude: options.exclude }
164
- : undefined;
192
+ excludeList.length > 0 ? { exclude: excludeList } : undefined;
165
193
 
166
194
  const config: InlineConfig = {
167
195
  root: options.pkgDir,
168
196
  base: options.base ?? `/${name}/`,
169
- resolve: { tsconfigPaths: true },
170
197
  define: Object.keys(define).length > 0 ? define : undefined,
171
198
  plugins,
172
199
  server: serverConfig,
173
200
  css: cssConfig,
201
+ esbuild: {
202
+ target: esbuildTarget,
203
+ },
174
204
  build: {
175
205
  target: esbuildTarget,
176
206
  },
177
207
  optimizeDeps: {
178
208
  ...optimizeDepsConfig,
209
+ esbuildOptions: {
210
+ target: esbuildTarget as string[],
211
+ },
179
212
  },
180
213
  };
181
214
 
182
215
  // PWA (build 모드 + pwa !== false)
183
216
  if (options.mode === "build" && options.pwa !== false) {
217
+ const pwaConfig = typeof options.pwa === "object" ? options.pwa : {};
218
+
219
+ // 아이콘 자동 생성 (커스텀 icons 미설정 시)
220
+ let iconsConfig: Record<string, unknown> = {};
221
+ if (pwaConfig.manifest?.icons != null) {
222
+ iconsConfig = { icons: pwaConfig.manifest.icons };
223
+ } else {
224
+ const generatedIcons = await generatePwaIcons(options.pkgDir);
225
+ if (generatedIcons.length > 0) {
226
+ iconsConfig = { icons: generatedIcons };
227
+ }
228
+ }
229
+
230
+ const pwaManifest = {
231
+ name: pwaConfig.manifest?.name ?? name,
232
+ short_name: pwaConfig.manifest?.short_name ?? name,
233
+ display: pwaConfig.manifest?.display ?? "standalone",
234
+ theme_color: pwaConfig.manifest?.theme_color ?? "#ffffff",
235
+ background_color: pwaConfig.manifest?.background_color ?? "#ffffff",
236
+ ...iconsConfig,
237
+ };
238
+ const pwaWorkbox = {
239
+ globPatterns: pwaConfig.workbox?.globPatterns ?? [
240
+ "**/*.{js,css,html,ico,png,svg,woff2}",
241
+ ],
242
+ };
184
243
  (config.plugins as PluginOption[]).push(
185
- sdPwaPlugin({ pkgDir: options.pkgDir, pkgName: name, pwa: options.pwa }),
244
+ VitePWA({
245
+ registerType: "prompt",
246
+ injectRegister: "script",
247
+ manifest: pwaManifest,
248
+ workbox: pwaWorkbox,
249
+ }),
186
250
  );
187
251
  }
188
252
 
@@ -231,18 +295,24 @@ export function createClientViteConfig(
231
295
  }
232
296
  }
233
297
 
234
- // legacyModule: true → 코드 스플리팅 비활성화 + import.meta/import() 치환 (Chrome 61 호환)
298
+ // legacyModule: true → 코드 스플리팅 비활성화 + esbuild import.meta 변환 + 잔여 import() 제거
235
299
  if (options.legacyModule === true) {
300
+ config.esbuild = {
301
+ ...config.esbuild,
302
+ supported: {
303
+ "import-meta": false,
304
+ },
305
+ };
236
306
  config.build = {
237
307
  ...config.build,
238
- rolldownOptions: {
308
+ rollupOptions: {
239
309
  output: {
240
310
  inlineDynamicImports: true,
241
311
  },
242
312
  },
243
313
  };
244
314
 
245
- // Rolldown이 인라인하지 못한 잔여 dynamic import()를 제거한다.
315
+ // Rollup이 인라인하지 못한 잔여 dynamic import()를 제거한다.
246
316
  // inlineDynamicImports가 정적 경로를 모두 인라인한 후에도,
247
317
  // @vite-ignore나 런타임 계산 경로의 import()가 남을 수 있다.
248
318
  // Chrome 61은 import() 구문을 파싱하지 못하므로 no-op 함수로 치환한다.
@@ -260,20 +330,6 @@ export function createClientViteConfig(
260
330
  };
261
331
  },
262
332
  });
263
-
264
- // import.meta 구문을 치환한다. Chrome 61은 import.meta를 파싱하지 못한다 (Chrome 64+).
265
- // Vite/Rolldown이 빌드 시 대부분의 import.meta를 resolve하지만, 잔여분에 대한 안전망이다.
266
- (config.plugins as PluginOption[]).push({
267
- name: "sd-legacy-strip-import-meta",
268
- enforce: "post",
269
- renderChunk(code) {
270
- if (!code.includes("import.meta")) return null;
271
- return {
272
- code: code.replace(/\bimport\.meta\b/g, "(void 0)"),
273
- map: null,
274
- };
275
- },
276
- });
277
333
  }
278
334
 
279
335
  // build 모드 설정 (프로덕션 빌드 또는 legacyModule dev)
@@ -51,7 +51,12 @@ export function sdScopeWatchPlugin(options: SdScopeWatchPluginOptions): Plugin {
51
51
  "dist",
52
52
  );
53
53
  if (fs.existsSync(distDir)) {
54
- watchPaths.push(distDir);
54
+ // symlink → realpath 해결 (Vite 모듈 그래프가 realpath를 키로 사용)
55
+ try {
56
+ watchPaths.push(fs.realpathSync(distDir));
57
+ } catch {
58
+ watchPaths.push(distDir);
59
+ }
55
60
  }
56
61
  }
57
62
 
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "path";
1
3
  import { createWorker } from "@simplysm/core-node";
2
4
  import { env, err as errNs } from "@simplysm/core-common";
3
5
  import { consola } from "consola";
@@ -49,10 +51,18 @@ const logger = consola.withTag("sd:cli:server-runtime:worker");
49
51
  /** 서버 인스턴스 (정리 대상) */
50
52
  let serverInstance: { close: () => Promise<void> } | undefined;
51
53
 
54
+ /** .dev-port 기록 경로 (cleanup에서 삭제용) */
55
+ let mainJsDir: string | undefined;
56
+
52
57
  /**
53
58
  * 리소스 정리
54
59
  */
55
60
  async function cleanup(): Promise<void> {
61
+ if (mainJsDir != null) {
62
+ try { fs.unlinkSync(path.join(mainJsDir, ".dev-port")); } catch { /* 파일 없으면 무시 */ }
63
+ mainJsDir = undefined;
64
+ }
65
+
56
66
  const server = serverInstance;
57
67
  serverInstance = undefined;
58
68
  if (server != null) {
@@ -172,6 +182,11 @@ async function start(info: ServerRuntimeStartInfo): Promise<void> {
172
182
  await server.listen();
173
183
  logger.debug(`서버 리슨 완료 (${Math.round(performance.now() - stepStart)}ms)`);
174
184
 
185
+ // .dev-port 기록 (device 명령어에서 자동 탐지용)
186
+ mainJsDir = path.dirname(info.mainJsPath);
187
+ fs.mkdirSync(mainJsDir, { recursive: true });
188
+ fs.writeFileSync(path.join(mainJsDir, ".dev-port"), String(server.options.port));
189
+
175
190
  logger.debug(
176
191
  `런타임 총 시작 시간: ${Math.round(performance.now() - startTime)}ms`,
177
192
  );