@mandujs/core 0.18.5 → 0.18.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1205,6 +1205,52 @@ export async function buildClientBundles(
1205
1205
  };
1206
1206
  }
1207
1207
 
1208
+ // 부분 빌드 모드: targetRouteIds가 지정되면 해당 Island만 재빌드 (#122)
1209
+ if (options.targetRouteIds && options.targetRouteIds.length > 0) {
1210
+ const targetRoutes = hydratedRoutes.filter((r) => options.targetRouteIds!.includes(r.id));
1211
+
1212
+ for (const route of targetRoutes) {
1213
+ try {
1214
+ const result = await buildIsland(route, rootDir, outDir, options);
1215
+ outputs.push(result);
1216
+ } catch (error) {
1217
+ errors.push(`[${route.id}] ${String(error)}`);
1218
+ }
1219
+ }
1220
+
1221
+ // 기존 매니페스트를 읽어 변경된 Island만 갱신
1222
+ let existingManifest: BundleManifest;
1223
+ try {
1224
+ const manifestData = await fs.readFile(path.join(rootDir, ".mandu/manifest.json"), "utf-8");
1225
+ existingManifest = JSON.parse(manifestData) as BundleManifest;
1226
+ } catch {
1227
+ // 기존 매니페스트 없으면 전체 빌드로 재시도 (targetRouteIds 제거)
1228
+ return buildClientBundles(manifest, rootDir, { ...options, targetRouteIds: undefined });
1229
+ }
1230
+
1231
+ for (const output of outputs) {
1232
+ if (existingManifest.bundles[output.routeId]) {
1233
+ existingManifest.bundles[output.routeId].js = output.outputPath;
1234
+ } else {
1235
+ const route = targetRoutes.find((r) => r.id === output.routeId);
1236
+ const hydration = route ? getRouteHydration(route) : null;
1237
+ existingManifest.bundles[output.routeId] = {
1238
+ js: output.outputPath,
1239
+ dependencies: ["_runtime", "_react"],
1240
+ priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
1241
+ };
1242
+ }
1243
+ }
1244
+
1245
+ await fs.writeFile(
1246
+ path.join(rootDir, ".mandu/manifest.json"),
1247
+ JSON.stringify(existingManifest, null, 2)
1248
+ );
1249
+
1250
+ const stats = calculateStats(outputs, startTime);
1251
+ return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
1252
+ }
1253
+
1208
1254
  // 3-4. Runtime, Router, Vendor 번들 병렬 빌드 (서로 독립적)
1209
1255
  const [runtimeResult, routerResult, vendorResult] = await Promise.all([
1210
1256
  buildRuntime(outDir, options),
@@ -311,7 +311,12 @@ export async function startCSSWatch(options: CSSBuildOptions): Promise<CSSWatche
311
311
  serverPath: SERVER_CSS_PATH,
312
312
  close: () => {
313
313
  fsWatcher?.close();
314
- proc.kill();
314
+ // Windows에서는 SIGTERM이 무시될 수 있으므로 SIGKILL 사용 (#117)
315
+ if (process.platform === "win32") {
316
+ proc.kill("SIGKILL");
317
+ } else {
318
+ proc.kill();
319
+ }
315
320
  },
316
321
  };
317
322
  }
@@ -103,8 +103,18 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
103
103
  const normalizedPath = absPath.replace(/\\/g, "/");
104
104
  clientModuleToRoute.set(normalizedPath, route.id);
105
105
 
106
- // 감시할 디렉토리 추가
106
+ // Also register *.client.tsx/ts files in the same directory (#140)
107
+ // e.g. if clientModule is app/page.island.tsx, also map app/page.client.tsx → same routeId
107
108
  const dir = path.dirname(absPath);
109
+ const baseStem = path.basename(absPath).replace(/\.(island|client)\.(tsx?|jsx?)$/, "");
110
+ for (const ext of [".client.tsx", ".client.ts", ".client.jsx", ".client.js"]) {
111
+ const clientPath = path.join(dir, baseStem + ext).replace(/\\/g, "/");
112
+ if (clientPath !== normalizedPath) {
113
+ clientModuleToRoute.set(clientPath, route.id);
114
+ }
115
+ }
116
+
117
+ // 감시할 디렉토리 추가
108
118
  watchDirs.add(dir);
109
119
  }
110
120
  }
@@ -143,6 +153,9 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
143
153
  // 파일 감시 설정
144
154
  const watchers: fs.FSWatcher[] = [];
145
155
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
156
+ // 동시 빌드 방지 (#121): 빌드 중에 변경 발생 시 다음 빌드 대기
157
+ let isBuilding = false;
158
+ let pendingBuildFile: string | null = null;
146
159
 
147
160
  // 파일이 공통 디렉토리에 있는지 확인
148
161
  const isInCommonDir = (filePath: string): boolean => {
@@ -157,9 +170,30 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
157
170
  };
158
171
 
159
172
  const handleFileChange = async (changedFile: string) => {
173
+ // 동시 빌드 방지 (#121): 빌드 중이면 대기 큐에 저장
174
+ if (isBuilding) {
175
+ pendingBuildFile = changedFile;
176
+ return;
177
+ }
178
+
179
+ isBuilding = true;
180
+ try {
181
+ await _doBuild(changedFile);
182
+ } finally {
183
+ isBuilding = false;
184
+ // 빌드 중 대기 중인 파일이 있으면 즉시 처리
185
+ if (pendingBuildFile) {
186
+ const next = pendingBuildFile;
187
+ pendingBuildFile = null;
188
+ await handleFileChange(next);
189
+ }
190
+ }
191
+ };
192
+
193
+ const _doBuild = async (changedFile: string) => {
160
194
  const normalizedPath = changedFile.replace(/\\/g, "/");
161
195
 
162
- // 공통 컴포넌트 디렉토리 변경 → 전체 재빌드
196
+ // 공통 컴포넌트 디렉토리 변경 → 전체 재빌드 (targetRouteIds 없이)
163
197
  if (isInCommonDir(changedFile)) {
164
198
  console.log(`\n🔄 Common file changed: ${path.basename(changedFile)}`);
165
199
  console.log(` Rebuilding all islands...`);
@@ -200,21 +234,17 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
200
234
  // clientModule 매핑에서 routeId 찾기
201
235
  let routeId = clientModuleToRoute.get(normalizedPath);
202
236
 
203
- // .client.ts 또는 .client.tsx 파일인 경우 파일명에서 routeId 추출
204
- if (!routeId) {
205
- let basename: string | null = null;
206
-
207
- if (changedFile.endsWith(".client.ts")) {
208
- basename = path.basename(changedFile, ".client.ts");
209
- } else if (changedFile.endsWith(".client.tsx")) {
210
- basename = path.basename(changedFile, ".client.tsx");
211
- }
212
-
213
- if (basename) {
214
- const route = manifest.routes.find((r) => r.id === basename);
215
- if (route) {
216
- routeId = route.id;
217
- }
237
+ // Fallback for *.client.tsx/ts: find route whose clientModule is in the same directory (#140)
238
+ // basename matching (e.g. "page" !== "index") is unreliable — use directory-based matching instead
239
+ if (!routeId && (changedFile.endsWith(".client.ts") || changedFile.endsWith(".client.tsx"))) {
240
+ const changedDir = path.dirname(path.resolve(rootDir, changedFile)).replace(/\\/g, "/");
241
+ const matchedRoute = manifest.routes.find((r) => {
242
+ if (!r.clientModule) return false;
243
+ const routeDir = path.dirname(path.resolve(rootDir, r.clientModule)).replace(/\\/g, "/");
244
+ return routeDir === changedDir;
245
+ });
246
+ if (matchedRoute) {
247
+ routeId = matchedRoute.id;
218
248
  }
219
249
  }
220
250
 
@@ -223,13 +253,15 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
223
253
  const route = manifest.routes.find((r) => r.id === routeId);
224
254
  if (!route || !route.clientModule) return;
225
255
 
226
- console.log(`\n🔄 Rebuilding: ${routeId}`);
256
+ console.log(`\n🔄 Rebuilding island: ${routeId}`);
227
257
  const startTime = performance.now();
228
258
 
229
259
  try {
260
+ // 단일 island만 재빌드 (Runtime/Router/Vendor 스킵, #122)
230
261
  const result = await buildClientBundles(manifest, rootDir, {
231
262
  minify: false,
232
263
  sourcemap: true,
264
+ targetRouteIds: [routeId],
233
265
  });
234
266
 
235
267
  const buildTime = performance.now() - startTime;
@@ -286,7 +318,7 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
286
318
  console.log(`👀 Watching ${watchers.length} directories for changes...`);
287
319
  if (commonWatchDirs.size > 0) {
288
320
  const commonDirNames = Array.from(commonWatchDirs)
289
- .map(d => path.relative(rootDir, d) || ".")
321
+ .map(d => (path.relative(rootDir, d) || ".").replace(/\\/g, "/"))
290
322
  .join(", ");
291
323
  console.log(`📦 Common dirs (full rebuild): ${commonDirNames}`);
292
324
  }
@@ -111,4 +111,10 @@ export interface BundlerOptions {
111
111
  * 주의: splitting=true인 경우 청크 파일 관리가 필요합니다.
112
112
  */
113
113
  splitting?: boolean;
114
+ /**
115
+ * 빌드할 Island routeId 목록 (부분 빌드용, #122)
116
+ * - 지정 시 해당 Island만 재빌드 (Runtime/Router/Vendor 스킵)
117
+ * - 미지정 시 전체 빌드 (기본값)
118
+ */
119
+ targetRouteIds?: string[];
114
120
  }
@@ -335,7 +335,7 @@ export class FSScanner {
335
335
  // 루트 레이아웃
336
336
  const rootLayout = layoutMap.get(".");
337
337
  if (rootLayout) {
338
- chain.push(join(this.config.routesDir, rootLayout.relativePath));
338
+ chain.push(join(this.config.routesDir, rootLayout.relativePath).replace(/\\/g, "/"));
339
339
  }
340
340
 
341
341
  // 중첩 레이아웃
@@ -344,7 +344,7 @@ export class FSScanner {
344
344
  currentPath = currentPath ? `${currentPath}/${segment.raw}` : segment.raw;
345
345
  const layout = layoutMap.get(currentPath);
346
346
  if (layout) {
347
- chain.push(join(this.config.routesDir, layout.relativePath));
347
+ chain.push(join(this.config.routesDir, layout.relativePath).replace(/\\/g, "/"));
348
348
  }
349
349
  }
350
350
 
@@ -364,7 +364,7 @@ export class FSScanner {
364
364
  while (currentPath) {
365
365
  const file = fileMap.get(currentPath);
366
366
  if (file) {
367
- return join(this.config.routesDir, file.relativePath);
367
+ return join(this.config.routesDir, file.relativePath).replace(/\\/g, "/");
368
368
  }
369
369
  // 상위 디렉토리로
370
370
  const lastSlash = currentPath.lastIndexOf("/");
@@ -373,7 +373,7 @@ export class FSScanner {
373
373
 
374
374
  // 루트 체크
375
375
  const rootFile = fileMap.get(".");
376
- return rootFile ? join(this.config.routesDir, rootFile.relativePath) : undefined;
376
+ return rootFile ? join(this.config.routesDir, rootFile.relativePath).replace(/\\/g, "/") : undefined;
377
377
  }
378
378
 
379
379
  /**
@@ -577,6 +577,7 @@ function generateDeferredDataScript(routeId: string, key: string, data: unknown)
577
577
 
578
578
  /**
579
579
  * HMR 스크립트 생성
580
+ * ssr.ts의 generateHMRScript와 동일한 구현을 유지해야 함 (#114)
580
581
  */
581
582
  function generateHMRScript(port: number): string {
582
583
  const hmrPort = port + PORTS.HMR_OFFSET;
@@ -585,6 +586,15 @@ function generateHMRScript(port: number): string {
585
586
  var ws = null;
586
587
  var reconnectAttempts = 0;
587
588
  var maxReconnectAttempts = ${TIMEOUTS.HMR_MAX_RECONNECT};
589
+ var baseDelay = ${TIMEOUTS.HMR_RECONNECT_DELAY};
590
+
591
+ function scheduleReconnect() {
592
+ if (reconnectAttempts < maxReconnectAttempts) {
593
+ reconnectAttempts++;
594
+ var delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts - 1), 30000);
595
+ setTimeout(connect, delay);
596
+ }
597
+ }
588
598
 
589
599
  function connect() {
590
600
  try {
@@ -599,17 +609,27 @@ function generateHMRScript(port: number): string {
599
609
  if (msg.type === 'reload' || msg.type === 'island-update') {
600
610
  console.log('[Mandu HMR] Reloading...');
601
611
  location.reload();
612
+ } else if (msg.type === 'css-update') {
613
+ var cssPath = (msg.data && msg.data.cssPath) || '/.mandu/client/globals.css';
614
+ var links = document.querySelectorAll('link[rel="stylesheet"]');
615
+ var updated = false;
616
+ for (var i = 0; i < links.length; i++) {
617
+ var href = links[i].getAttribute('href') || '';
618
+ var base = href.split('?')[0];
619
+ if (base === cssPath || href.includes('globals.css') || href.includes('.mandu/client')) {
620
+ links[i].setAttribute('href', base + '?t=' + Date.now());
621
+ updated = true;
622
+ }
623
+ }
624
+ if (!updated) location.reload();
625
+ } else if (msg.type === 'error') {
626
+ console.error('[Mandu HMR] Build error:', msg.data && msg.data.message);
602
627
  }
603
628
  } catch(err) {}
604
629
  };
605
- ws.onclose = function() {
606
- if (reconnectAttempts < maxReconnectAttempts) {
607
- reconnectAttempts++;
608
- setTimeout(connect, ${TIMEOUTS.HMR_RECONNECT_DELAY} * reconnectAttempts);
609
- }
610
- };
630
+ ws.onclose = function() { scheduleReconnect(); };
611
631
  } catch(err) {
612
- setTimeout(connect, ${TIMEOUTS.HMR_RECONNECT_DELAY});
632
+ scheduleReconnect();
613
633
  }
614
634
  }
615
635
  connect();