@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 +1 -1
- package/src/bundler/build.ts +46 -0
- package/src/bundler/css.ts +6 -1
- package/src/bundler/dev.ts +51 -19
- package/src/bundler/types.ts +6 -0
- package/src/router/fs-scanner.ts +4 -4
- package/src/runtime/streaming-ssr.ts +27 -7
package/package.json
CHANGED
package/src/bundler/build.ts
CHANGED
|
@@ -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),
|
package/src/bundler/css.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/bundler/dev.ts
CHANGED
|
@@ -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
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
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
|
}
|
package/src/bundler/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/router/fs-scanner.ts
CHANGED
|
@@ -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
|
-
|
|
632
|
+
scheduleReconnect();
|
|
613
633
|
}
|
|
614
634
|
}
|
|
615
635
|
connect();
|