@mandujs/core 0.54.2 → 0.54.4
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 +5 -2
- package/scripts/postinstall-lock.ts +153 -0
- package/src/agent/__tests__/context.test.ts +237 -0
- package/src/agent/context.ts +535 -0
- package/src/agent/index.ts +6 -0
- package/src/agent/plan.ts +283 -0
- package/src/agent/repair.ts +172 -0
- package/src/agent/sync.ts +200 -0
- package/src/agent/types.ts +308 -0
- package/src/agent/verify.ts +406 -0
- package/src/bundler/__tests__/build-runner.ts +33 -13
- package/src/bundler/__tests__/cold-start.test.ts +35 -7
- package/src/bundler/__tests__/css.test.ts +20 -0
- package/src/bundler/analyzer.ts +15 -7
- package/src/bundler/build.test.ts +53 -11
- package/src/bundler/build.ts +447 -185
- package/src/bundler/css.ts +42 -12
- package/src/bundler/manifest-schema.ts +21 -14
- package/src/bundler/types.ts +31 -14
- package/src/client/island.ts +79 -29
- package/src/guard/config-guard.ts +13 -7
- package/src/guard/fs-routes-policy.ts +51 -0
- package/src/guard/index.ts +11 -6
- package/src/index.ts +3 -2
- package/src/router/client-entry.ts +71 -0
- package/src/router/fs-routes.ts +16 -8
- package/src/router/fs-scanner.ts +4 -3
- package/src/runtime/__tests__/page-render-response.test.ts +49 -0
- package/src/runtime/page-render-response.ts +1 -5
- package/src/runtime/ssr.ts +39 -30
- package/src/runtime/streaming-ssr.ts +22 -13
package/src/bundler/build.ts
CHANGED
|
@@ -10,18 +10,20 @@ import type {
|
|
|
10
10
|
BundleResult,
|
|
11
11
|
BundleOutput,
|
|
12
12
|
BundleManifest,
|
|
13
|
-
BundleStats,
|
|
14
|
-
BundlerOptions,
|
|
15
|
-
IslandFileEntry,
|
|
16
|
-
|
|
13
|
+
BundleStats,
|
|
14
|
+
BundlerOptions,
|
|
15
|
+
IslandFileEntry,
|
|
16
|
+
PartialFileEntry,
|
|
17
|
+
} from "./types";
|
|
17
18
|
import { HYDRATION } from "../constants";
|
|
18
19
|
import { safeBuild } from "./safe-build";
|
|
19
20
|
import { fastRefreshPlugin } from "./fast-refresh-plugin";
|
|
20
21
|
import { defaultBundlerPlugins } from "./plugins";
|
|
21
22
|
import type { BunPlugin } from "bun";
|
|
22
|
-
import { mark, measure } from "../perf";
|
|
23
|
-
import { HMR_PERF } from "../perf/hmr-markers";
|
|
24
|
-
import { runOnBundleComplete } from "../plugins/runner";
|
|
23
|
+
import { mark, measure } from "../perf";
|
|
24
|
+
import { HMR_PERF } from "../perf/hmr-markers";
|
|
25
|
+
import { runOnBundleComplete } from "../plugins/runner";
|
|
26
|
+
import { validateClientModuleForBrowserBundle } from "../router/client-entry";
|
|
25
27
|
import {
|
|
26
28
|
readVendorCache,
|
|
27
29
|
writeVendorCache,
|
|
@@ -186,7 +188,46 @@ async function scanIslandFiles(routes: RouteSpec[], rootDir: string): Promise<Is
|
|
|
186
188
|
*
|
|
187
189
|
* @internal
|
|
188
190
|
*/
|
|
189
|
-
export const _testOnly_scanIslandFiles = scanIslandFiles;
|
|
191
|
+
export const _testOnly_scanIslandFiles = scanIslandFiles;
|
|
192
|
+
|
|
193
|
+
function normalizeClientEntryName(name: string): string {
|
|
194
|
+
return name
|
|
195
|
+
.trim()
|
|
196
|
+
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
197
|
+
.replace(/^-+|-+$/g, "") || "partial";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function scanPartialFiles(rootDir: string): Promise<PartialFileEntry[]> {
|
|
201
|
+
const entries: PartialFileEntry[] = [];
|
|
202
|
+
const seen = new Set<string>();
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const glob = new Bun.Glob("**/*.partial.{ts,tsx}");
|
|
206
|
+
for await (const rel of glob.scan({ cwd: rootDir, absolute: true })) {
|
|
207
|
+
if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue;
|
|
208
|
+
if (rel.includes(`${path.sep}.mandu${path.sep}`)) continue;
|
|
209
|
+
|
|
210
|
+
const filePath = path.resolve(rel);
|
|
211
|
+
if (seen.has(filePath)) continue;
|
|
212
|
+
seen.add(filePath);
|
|
213
|
+
|
|
214
|
+
const base = path.basename(filePath).replace(/\.partial\.tsx?$/, "");
|
|
215
|
+
entries.push({
|
|
216
|
+
name: normalizeClientEntryName(base),
|
|
217
|
+
filePath,
|
|
218
|
+
priority: HYDRATION.DEFAULT_PRIORITY,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// Bun.Glob unavailable or scan failed — a missing partial bundle should
|
|
223
|
+
// not prevent route-level islands from building.
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return entries.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** @internal test helper */
|
|
230
|
+
export const _testOnly_scanPartialFiles = scanPartialFiles;
|
|
190
231
|
|
|
191
232
|
/**
|
|
192
233
|
* Test-only accessor for the hydrated-routes filter. Mirrors the rationale
|
|
@@ -214,49 +255,42 @@ export const _testOnly_getHydratedRoutes = getHydratedRoutes;
|
|
|
214
255
|
* Paths are absolute and deduplicated. Returns `[]` when the manifest
|
|
215
256
|
* has no hydrated routes.
|
|
216
257
|
*/
|
|
217
|
-
export async function collectCompilerLintTargets(
|
|
218
|
-
manifest: RoutesManifest,
|
|
219
|
-
rootDir: string,
|
|
220
|
-
): Promise<string[]> {
|
|
221
|
-
const hydratedRoutes = getHydratedRoutes(manifest);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
} catch {
|
|
249
|
-
// Bun.Glob unavailable or scan failed — skip partials rather than
|
|
250
|
-
// aborting the whole diagnostic run.
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
return Array.from(out).sort();
|
|
254
|
-
}
|
|
258
|
+
export async function collectCompilerLintTargets(
|
|
259
|
+
manifest: RoutesManifest,
|
|
260
|
+
rootDir: string,
|
|
261
|
+
): Promise<string[]> {
|
|
262
|
+
const hydratedRoutes = getHydratedRoutes(manifest);
|
|
263
|
+
|
|
264
|
+
const out = new Set<string>();
|
|
265
|
+
|
|
266
|
+
// 1) Islands (reuses the bundler's canonical scanner).
|
|
267
|
+
if (hydratedRoutes.length > 0) {
|
|
268
|
+
const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
|
|
269
|
+
for (const entry of islandFiles) {
|
|
270
|
+
out.add(path.resolve(entry.filePath));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 2) `"use client"` page modules — the hydrated-route filter
|
|
274
|
+
// already asserted `clientModule` exists.
|
|
275
|
+
for (const route of hydratedRoutes) {
|
|
276
|
+
const rel = route.clientModule ?? route.module;
|
|
277
|
+
if (rel) out.add(path.resolve(rootDir, rel));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 3) Partials — glob the whole project once. Partials live anywhere
|
|
282
|
+
// the user chooses, they're not sibling-scoped like islands.
|
|
283
|
+
for (const entry of await scanPartialFiles(rootDir)) {
|
|
284
|
+
out.add(path.resolve(entry.filePath));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return Array.from(out).sort();
|
|
288
|
+
}
|
|
255
289
|
|
|
256
290
|
/** Build a single per-island bundle. */
|
|
257
|
-
async function buildPerIslandBundle(
|
|
258
|
-
entry: IslandFileEntry, outDir: string, options: BundlerOptions
|
|
259
|
-
): Promise<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> {
|
|
291
|
+
async function buildPerIslandBundle(
|
|
292
|
+
entry: IslandFileEntry, outDir: string, options: BundlerOptions
|
|
293
|
+
): Promise<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> {
|
|
260
294
|
const entryPath = path.join(outDir, `_entry_island_${entry.name}.js`);
|
|
261
295
|
const outputName = `${entry.name}.island.js`;
|
|
262
296
|
// Phase 7.1 B-1/B-4: wire Bun's native React Fast Refresh transform +
|
|
@@ -285,9 +319,66 @@ async function buildPerIslandBundle(
|
|
|
285
319
|
return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
|
|
286
320
|
} catch (error) {
|
|
287
321
|
await fs.unlink(entryPath).catch(() => {});
|
|
288
|
-
throw error;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
interface PartialBundleBuild {
|
|
327
|
+
name: string;
|
|
328
|
+
js: string;
|
|
329
|
+
priority: PartialFileEntry["priority"];
|
|
330
|
+
size: number;
|
|
331
|
+
gzipSize: number;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Build a single inline partial bundle. */
|
|
335
|
+
async function buildPartialBundle(
|
|
336
|
+
entry: PartialFileEntry,
|
|
337
|
+
outDir: string,
|
|
338
|
+
options: BundlerOptions,
|
|
339
|
+
): Promise<PartialBundleBuild> {
|
|
340
|
+
const entryPath = path.join(outDir, `_entry_partial_${entry.name}.js`);
|
|
341
|
+
const outputName = `${entry.name}.partial.js`;
|
|
342
|
+
const isDev = isDevelopmentBuild(options);
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
await Bun.write(entryPath, generatePartialEntry(entry.name, entry.filePath));
|
|
346
|
+
const result = await safeBuild({
|
|
347
|
+
entrypoints: [entryPath],
|
|
348
|
+
outdir: outDir,
|
|
349
|
+
naming: outputName,
|
|
350
|
+
minify: shouldMinify(options),
|
|
351
|
+
sourcemap: options.sourcemap ? "external" : "none",
|
|
352
|
+
target: "browser",
|
|
353
|
+
...(isDev ? { reactFastRefresh: true } : {}),
|
|
354
|
+
plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
|
|
355
|
+
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
356
|
+
define: { "process.env.NODE_ENV": nodeEnvDefine(options), ...options.define },
|
|
357
|
+
});
|
|
358
|
+
await fs.unlink(entryPath).catch(() => {});
|
|
359
|
+
|
|
360
|
+
if (!result.success) {
|
|
361
|
+
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
362
|
+
throw new Error(`Partial build failed for '${entry.name}' (source: ${entry.filePath}):\n${grouped}\n Hint: Export a Mandu partial from this file with \`partial({ component })\`.`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const outputPath = path.join(outDir, outputName);
|
|
366
|
+
const outputFile = Bun.file(outputPath);
|
|
367
|
+
const content = await outputFile.text();
|
|
368
|
+
const gzipped = Bun.gzipSync(Buffer.from(content));
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
name: entry.name,
|
|
372
|
+
js: `/.mandu/client/${outputName}`,
|
|
373
|
+
priority: entry.priority,
|
|
374
|
+
size: outputFile.size,
|
|
375
|
+
gzipSize: gzipped.length,
|
|
376
|
+
};
|
|
377
|
+
} catch (error) {
|
|
378
|
+
await fs.unlink(entryPath).catch(() => {});
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
291
382
|
|
|
292
383
|
/**
|
|
293
384
|
* 빈 매니페스트 생성
|
|
@@ -413,12 +504,31 @@ function generateRuntimeSource(): string {
|
|
|
413
504
|
import React, { useState, useEffect, Component } from 'react';
|
|
414
505
|
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
415
506
|
|
|
416
|
-
// Hydrated roots 추적 (unmount용) - 전역 초기화
|
|
417
|
-
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
418
|
-
const hydratedRoots = window.__MANDU_ROOTS__;
|
|
419
|
-
|
|
420
|
-
// 서버 데이터
|
|
421
|
-
|
|
507
|
+
// Hydrated roots 추적 (unmount용) - 전역 초기화
|
|
508
|
+
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
509
|
+
const hydratedRoots = window.__MANDU_ROOTS__;
|
|
510
|
+
|
|
511
|
+
// 서버 데이터
|
|
512
|
+
function readManduData() {
|
|
513
|
+
if (window.__MANDU_DATA__) return window.__MANDU_DATA__;
|
|
514
|
+
|
|
515
|
+
const raw = window.__MANDU_DATA_RAW__ || document.getElementById('__MANDU_DATA__')?.textContent;
|
|
516
|
+
if (!raw) {
|
|
517
|
+
window.__MANDU_DATA__ = {};
|
|
518
|
+
return window.__MANDU_DATA__;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
window.__MANDU_DATA__ = JSON.parse(raw);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
console.warn('[Mandu] Failed to parse server data:', error);
|
|
525
|
+
window.__MANDU_DATA__ = {};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return window.__MANDU_DATA__;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const getServerData = (id) => readManduData()[id]?.serverData || {};
|
|
422
532
|
|
|
423
533
|
/**
|
|
424
534
|
* Error Boundary 컴포넌트 (Class Component)
|
|
@@ -632,12 +742,16 @@ async function loadAndHydrate(element, src) {
|
|
|
632
742
|
const island = module.default;
|
|
633
743
|
let data = getServerData(id);
|
|
634
744
|
|
|
635
|
-
// Fallback: read data-props from child element if
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
745
|
+
// Fallback: read data-props from the island root or a child element if
|
|
746
|
+
// __MANDU_DATA__ is empty. Inline partials put their serialized props on
|
|
747
|
+
// the root marker itself.
|
|
748
|
+
if (!data || Object.keys(data).length === 0) {
|
|
749
|
+
const propsEl = element.hasAttribute('data-props')
|
|
750
|
+
? element
|
|
751
|
+
: element.querySelector('[data-props]');
|
|
752
|
+
if (propsEl) {
|
|
753
|
+
try {
|
|
754
|
+
data = JSON.parse(propsEl.getAttribute('data-props'));
|
|
641
755
|
} catch (e) {
|
|
642
756
|
console.warn('[Mandu] Failed to parse data-props fallback:', e);
|
|
643
757
|
}
|
|
@@ -1311,22 +1425,63 @@ async function buildRouterRuntime(
|
|
|
1311
1425
|
* - Runtime이 dynamic import로 로드
|
|
1312
1426
|
* - 등록/초기화 코드 없음
|
|
1313
1427
|
*/
|
|
1314
|
-
function generateIslandEntry(routeId: string, clientModulePath: string): string {
|
|
1315
|
-
// Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
|
|
1316
|
-
const normalizedPath = clientModulePath.replace(/\\/g, "/");
|
|
1317
|
-
return `
|
|
1428
|
+
function generateIslandEntry(routeId: string, clientModulePath: string): string {
|
|
1429
|
+
// Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
|
|
1430
|
+
const normalizedPath = clientModulePath.replace(/\\/g, "/");
|
|
1431
|
+
return `
|
|
1318
1432
|
/**
|
|
1319
1433
|
* Mandu Island: ${routeId} (Generated)
|
|
1320
1434
|
* Pure export - no side effects
|
|
1321
1435
|
*/
|
|
1322
1436
|
import island from "${normalizedPath}";
|
|
1323
|
-
export default island;
|
|
1324
|
-
`;
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1437
|
+
export default island;
|
|
1438
|
+
`;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function generatePartialEntry(partialId: string, partialModulePath: string): string {
|
|
1442
|
+
const normalizedPath = partialModulePath.replace(/\\/g, "/");
|
|
1443
|
+
return `
|
|
1444
|
+
/**
|
|
1445
|
+
* Mandu Partial: ${partialId} (Generated)
|
|
1446
|
+
* Exports a runtime-compatible island wrapper around a compiled partial.
|
|
1447
|
+
*/
|
|
1448
|
+
import React from "react";
|
|
1449
|
+
import * as partialModule from "${normalizedPath}";
|
|
1450
|
+
|
|
1451
|
+
function findPartial(mod) {
|
|
1452
|
+
if (mod.default && mod.default.__mandu_partial === true) return mod.default;
|
|
1453
|
+
for (const value of Object.values(mod)) {
|
|
1454
|
+
if (value && value.__mandu_partial === true) return value;
|
|
1455
|
+
}
|
|
1456
|
+
throw new Error("[Mandu Partial] ${partialId} must export a value returned by partial({ component })");
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const partial = findPartial(partialModule);
|
|
1460
|
+
const definition = partial.definition;
|
|
1461
|
+
const component = definition.component;
|
|
1462
|
+
|
|
1463
|
+
export default {
|
|
1464
|
+
__mandu_island: true,
|
|
1465
|
+
definition: {
|
|
1466
|
+
setup(serverData) {
|
|
1467
|
+
if (serverData && typeof serverData === "object" && Object.keys(serverData).length > 0) {
|
|
1468
|
+
return serverData;
|
|
1469
|
+
}
|
|
1470
|
+
return definition.initialProps || {};
|
|
1471
|
+
},
|
|
1472
|
+
render(props) {
|
|
1473
|
+
return React.createElement(component, props);
|
|
1474
|
+
},
|
|
1475
|
+
errorBoundary: definition.errorBoundary,
|
|
1476
|
+
loading: definition.loading,
|
|
1477
|
+
},
|
|
1478
|
+
};
|
|
1479
|
+
`;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Runtime 번들 빌드
|
|
1484
|
+
*/
|
|
1330
1485
|
async function buildRuntime(
|
|
1331
1486
|
outDir: string,
|
|
1332
1487
|
options: BundlerOptions
|
|
@@ -1810,15 +1965,16 @@ async function buildIsland(
|
|
|
1810
1965
|
/**
|
|
1811
1966
|
* 번들 매니페스트 생성
|
|
1812
1967
|
*/
|
|
1813
|
-
function createBundleManifest(
|
|
1814
|
-
outputs: BundleOutput[],
|
|
1815
|
-
routes: RouteSpec[],
|
|
1816
|
-
runtimePath: string,
|
|
1817
|
-
vendorResult: VendorBuildResult,
|
|
1818
|
-
routerPath: string,
|
|
1819
|
-
env: "development" | "production",
|
|
1820
|
-
islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }
|
|
1821
|
-
|
|
1968
|
+
function createBundleManifest(
|
|
1969
|
+
outputs: BundleOutput[],
|
|
1970
|
+
routes: RouteSpec[],
|
|
1971
|
+
runtimePath: string,
|
|
1972
|
+
vendorResult: VendorBuildResult,
|
|
1973
|
+
routerPath: string,
|
|
1974
|
+
env: "development" | "production",
|
|
1975
|
+
islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
|
|
1976
|
+
partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
|
|
1977
|
+
): BundleManifest {
|
|
1822
1978
|
const bundles: BundleManifest["bundles"] = {};
|
|
1823
1979
|
|
|
1824
1980
|
for (const output of outputs) {
|
|
@@ -1834,16 +1990,27 @@ function createBundleManifest(
|
|
|
1834
1990
|
|
|
1835
1991
|
// Per-island bundles (code splitting)
|
|
1836
1992
|
let islands: BundleManifest["islands"];
|
|
1837
|
-
if (islandBundles && islandBundles.length > 0) {
|
|
1838
|
-
islands = {};
|
|
1839
|
-
for (const ib of islandBundles) {
|
|
1840
|
-
islands[ib.name] = {
|
|
1841
|
-
js: ib.js,
|
|
1993
|
+
if (islandBundles && islandBundles.length > 0) {
|
|
1994
|
+
islands = {};
|
|
1995
|
+
for (const ib of islandBundles) {
|
|
1996
|
+
islands[ib.name] = {
|
|
1997
|
+
js: ib.js,
|
|
1842
1998
|
route: ib.route,
|
|
1843
1999
|
priority: ib.priority,
|
|
1844
|
-
};
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
let partials: BundleManifest["partials"];
|
|
2005
|
+
if (partialBundles && partialBundles.length > 0) {
|
|
2006
|
+
partials = {};
|
|
2007
|
+
for (const partial of partialBundles) {
|
|
2008
|
+
partials[partial.name] = {
|
|
2009
|
+
js: partial.js,
|
|
2010
|
+
priority: partial.priority,
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
1847
2014
|
|
|
1848
2015
|
// Phase 7.1 B-2: expose Fast Refresh dev bundles so the HTML
|
|
1849
2016
|
// preamble can inject a dynamic import pointing at them. Only
|
|
@@ -1859,10 +2026,11 @@ function createBundleManifest(
|
|
|
1859
2026
|
return {
|
|
1860
2027
|
version: 1,
|
|
1861
2028
|
buildTime: new Date().toISOString(),
|
|
1862
|
-
env,
|
|
1863
|
-
bundles,
|
|
1864
|
-
...(islands ? { islands } : {}),
|
|
1865
|
-
|
|
2029
|
+
env,
|
|
2030
|
+
bundles,
|
|
2031
|
+
...(islands ? { islands } : {}),
|
|
2032
|
+
...(partials ? { partials } : {}),
|
|
2033
|
+
shared: {
|
|
1866
2034
|
runtime: runtimePath,
|
|
1867
2035
|
vendor: vendorResult.react, // primary vendor for backwards compatibility
|
|
1868
2036
|
router: routerPath, // Client-side Router
|
|
@@ -1883,28 +2051,32 @@ function createBundleManifest(
|
|
|
1883
2051
|
/**
|
|
1884
2052
|
* 번들 통계 계산
|
|
1885
2053
|
*/
|
|
1886
|
-
function calculateStats(
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
2054
|
+
function calculateStats(
|
|
2055
|
+
outputs: BundleOutput[],
|
|
2056
|
+
startTime: number,
|
|
2057
|
+
extraOutputs: Array<{ routeId: string; size: number; gzipSize: number }> = [],
|
|
2058
|
+
): BundleStats {
|
|
2059
|
+
let totalSize = 0;
|
|
2060
|
+
let totalGzipSize = 0;
|
|
2061
|
+
let largestBundle = { routeId: "", size: 0 };
|
|
2062
|
+
|
|
2063
|
+
for (const output of [...outputs, ...extraOutputs]) {
|
|
2064
|
+
totalSize += output.size;
|
|
2065
|
+
totalGzipSize += output.gzipSize;
|
|
2066
|
+
|
|
2067
|
+
if (output.size > largestBundle.size) {
|
|
2068
|
+
largestBundle = { routeId: output.routeId, size: output.size };
|
|
1897
2069
|
}
|
|
1898
2070
|
}
|
|
1899
2071
|
|
|
1900
2072
|
return {
|
|
1901
2073
|
totalSize,
|
|
1902
|
-
totalGzipSize,
|
|
1903
|
-
largestBundle,
|
|
1904
|
-
buildTime: performance.now() - startTime,
|
|
1905
|
-
bundleCount: outputs.length,
|
|
1906
|
-
};
|
|
1907
|
-
}
|
|
2074
|
+
totalGzipSize,
|
|
2075
|
+
largestBundle,
|
|
2076
|
+
buildTime: performance.now() - startTime,
|
|
2077
|
+
bundleCount: outputs.length + extraOutputs.length,
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
1908
2080
|
|
|
1909
2081
|
/**
|
|
1910
2082
|
* 클라이언트 번들 빌드
|
|
@@ -1950,8 +2122,24 @@ export async function buildClientBundles(
|
|
|
1950
2122
|
};
|
|
1951
2123
|
const env = resolveBundlerMode(options);
|
|
1952
2124
|
|
|
1953
|
-
// 1. Hydration이 필요한 라우트 필터링
|
|
1954
|
-
const
|
|
2125
|
+
// 1. Hydration이 필요한 라우트 필터링
|
|
2126
|
+
const invalidClientRouteIds = new Set<string>();
|
|
2127
|
+
let hydratedRoutes = getHydratedRoutes(manifest);
|
|
2128
|
+
if (hydratedRoutes.length > 0) {
|
|
2129
|
+
const validRoutes: RouteSpec[] = [];
|
|
2130
|
+
for (const route of hydratedRoutes) {
|
|
2131
|
+
const validationError = await validateClientModuleForBrowserBundle(route, rootDir);
|
|
2132
|
+
if (validationError) {
|
|
2133
|
+
invalidClientRouteIds.add(route.id);
|
|
2134
|
+
errors.push(validationError);
|
|
2135
|
+
continue;
|
|
2136
|
+
}
|
|
2137
|
+
validRoutes.push(route);
|
|
2138
|
+
}
|
|
2139
|
+
hydratedRoutes = validRoutes;
|
|
2140
|
+
}
|
|
2141
|
+
const runtimeRoutes = manifest.routes.filter((route) => route.kind === "page" && needsHydration(route));
|
|
2142
|
+
const partialFiles = runtimeRoutes.length > 0 ? await scanPartialFiles(rootDir) : [];
|
|
1955
2143
|
|
|
1956
2144
|
// 2. 출력 디렉토리 생성 (항상 필요 - 매니페스트 저장용)
|
|
1957
2145
|
const outDir = resolveClientOutDir(rootDir, options.outDir);
|
|
@@ -1959,9 +2147,9 @@ export async function buildClientBundles(
|
|
|
1959
2147
|
|
|
1960
2148
|
// Hydration 라우트가 없어도 빈 매니페스트를 저장해야 함
|
|
1961
2149
|
// (이전 빌드의 stale 매니페스트 참조 방지)
|
|
1962
|
-
if (hydratedRoutes.length === 0) {
|
|
2150
|
+
if (hydratedRoutes.length === 0 && partialFiles.length === 0) {
|
|
1963
2151
|
// #185: skipFrameworkBundles 모드에서는 기존 manifest를 그대로 유지 (devtools 재빌드도 스킵)
|
|
1964
|
-
if (options.skipFrameworkBundles) {
|
|
2152
|
+
if (options.skipFrameworkBundles && errors.length === 0) {
|
|
1965
2153
|
const manifestPath = path.join(rootDir, ".mandu/manifest.json");
|
|
1966
2154
|
try {
|
|
1967
2155
|
const manifestRaw = await fs.readFile(manifestPath, "utf-8");
|
|
@@ -2012,11 +2200,11 @@ export async function buildClientBundles(
|
|
|
2012
2200
|
path.join(rootDir, ".mandu/manifest.json"),
|
|
2013
2201
|
JSON.stringify(emptyManifest, null, 2)
|
|
2014
2202
|
);
|
|
2015
|
-
return {
|
|
2016
|
-
success:
|
|
2017
|
-
outputs: [],
|
|
2018
|
-
errors
|
|
2019
|
-
manifest: emptyManifest,
|
|
2203
|
+
return {
|
|
2204
|
+
success: errors.length === 0,
|
|
2205
|
+
outputs: [],
|
|
2206
|
+
errors,
|
|
2207
|
+
manifest: emptyManifest,
|
|
2020
2208
|
stats: {
|
|
2021
2209
|
totalSize: 0,
|
|
2022
2210
|
totalGzipSize: 0,
|
|
@@ -2055,11 +2243,14 @@ export async function buildClientBundles(
|
|
|
2055
2243
|
return buildClientBundles(manifest, rootDir, { ...options, targetRouteIds: undefined });
|
|
2056
2244
|
}
|
|
2057
2245
|
|
|
2058
|
-
// Only update manifest with successfully built outputs (#10: preserve previous good manifest on failure)
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2246
|
+
// Only update manifest with successfully built outputs (#10: preserve previous good manifest on failure)
|
|
2247
|
+
for (const routeId of invalidClientRouteIds) {
|
|
2248
|
+
delete existingManifest.bundles[routeId];
|
|
2249
|
+
}
|
|
2250
|
+
if (outputs.length > 0 || invalidClientRouteIds.size > 0) {
|
|
2251
|
+
for (const output of outputs) {
|
|
2252
|
+
if (existingManifest.bundles[output.routeId]) {
|
|
2253
|
+
existingManifest.bundles[output.routeId].js = output.outputPath;
|
|
2063
2254
|
} else {
|
|
2064
2255
|
const route = targetRoutes.find((r) => r.id === output.routeId);
|
|
2065
2256
|
const hydration = route ? getRouteHydration(route) : null;
|
|
@@ -2071,11 +2262,11 @@ export async function buildClientBundles(
|
|
|
2071
2262
|
}
|
|
2072
2263
|
}
|
|
2073
2264
|
|
|
2074
|
-
await fs.writeFile(
|
|
2075
|
-
path.join(rootDir, ".mandu/manifest.json"),
|
|
2076
|
-
JSON.stringify(existingManifest, null, 2)
|
|
2077
|
-
);
|
|
2078
|
-
}
|
|
2265
|
+
await fs.writeFile(
|
|
2266
|
+
path.join(rootDir, ".mandu/manifest.json"),
|
|
2267
|
+
JSON.stringify(existingManifest, null, 2)
|
|
2268
|
+
);
|
|
2269
|
+
}
|
|
2079
2270
|
// When all builds failed, do NOT overwrite manifest — keep previous good state
|
|
2080
2271
|
|
|
2081
2272
|
const stats = calculateStats(outputs, startTime);
|
|
@@ -2111,9 +2302,13 @@ export async function buildClientBundles(
|
|
|
2111
2302
|
"[Mandu] Existing manifest missing required fields (shared/bundles), falling back to full build",
|
|
2112
2303
|
);
|
|
2113
2304
|
return buildClientBundles(manifest, rootDir, { ...options, skipFrameworkBundles: false });
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
for (const routeId of invalidClientRouteIds) {
|
|
2308
|
+
delete existingManifest.bundles[routeId];
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// Pre-build validation + 병렬 island 빌드 (framework 번들은 스킵)
|
|
2117
2312
|
for (const route of hydratedRoutes) {
|
|
2118
2313
|
if (!route.clientModule) continue;
|
|
2119
2314
|
const clientModulePath = path.join(rootDir, route.clientModule);
|
|
@@ -2178,25 +2373,62 @@ export async function buildClientBundles(
|
|
|
2178
2373
|
};
|
|
2179
2374
|
}
|
|
2180
2375
|
}
|
|
2181
|
-
if (perIslandBundles.length > 0) {
|
|
2182
|
-
existingManifest.islands = existingManifest.islands || {};
|
|
2183
|
-
for (const ib of perIslandBundles) {
|
|
2184
|
-
existingManifest.islands[ib.name] = {
|
|
2185
|
-
js: ib.js,
|
|
2376
|
+
if (perIslandBundles.length > 0) {
|
|
2377
|
+
existingManifest.islands = existingManifest.islands || {};
|
|
2378
|
+
for (const ib of perIslandBundles) {
|
|
2379
|
+
existingManifest.islands[ib.name] = {
|
|
2380
|
+
js: ib.js,
|
|
2186
2381
|
route: ib.route,
|
|
2187
2382
|
priority: ib.priority,
|
|
2188
|
-
};
|
|
2189
|
-
}
|
|
2190
|
-
}
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
const partialBundles: PartialBundleBuild[] = [];
|
|
2388
|
+
if (partialFiles.length > 0) {
|
|
2389
|
+
const partialResults = await Promise.all(
|
|
2390
|
+
partialFiles.map(async (entry) => {
|
|
2391
|
+
try {
|
|
2392
|
+
return await buildPartialBundle(entry, outDir, options);
|
|
2393
|
+
} catch (error) {
|
|
2394
|
+
errors.push(`[partial:${entry.name}] ${String(error)}`);
|
|
2395
|
+
return null;
|
|
2396
|
+
}
|
|
2397
|
+
}),
|
|
2398
|
+
);
|
|
2399
|
+
for (const result of partialResults) {
|
|
2400
|
+
if (result) partialBundles.push(result);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
if (partialFiles.length > 0 || existingManifest.partials) {
|
|
2404
|
+
existingManifest.partials = {};
|
|
2405
|
+
for (const partial of partialBundles) {
|
|
2406
|
+
existingManifest.partials[partial.name] = {
|
|
2407
|
+
js: partial.js,
|
|
2408
|
+
priority: partial.priority,
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
if (Object.keys(existingManifest.partials).length === 0) {
|
|
2412
|
+
delete existingManifest.partials;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
await fs.writeFile(
|
|
2417
|
+
path.join(rootDir, ".mandu/manifest.json"),
|
|
2418
|
+
JSON.stringify(existingManifest, null, 2),
|
|
2419
|
+
);
|
|
2420
|
+
|
|
2421
|
+
const stats = calculateStats(
|
|
2422
|
+
outputs,
|
|
2423
|
+
startTime,
|
|
2424
|
+
partialBundles.map((partial) => ({
|
|
2425
|
+
routeId: `partial:${partial.name}`,
|
|
2426
|
+
size: partial.size,
|
|
2427
|
+
gzipSize: partial.gzipSize,
|
|
2428
|
+
})),
|
|
2429
|
+
);
|
|
2430
|
+
return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
|
|
2431
|
+
}
|
|
2200
2432
|
|
|
2201
2433
|
// 3-4. Runtime, Router, Vendor, DevTools 번들 병렬 빌드 (서로 독립적)
|
|
2202
2434
|
const isDev = env === "development";
|
|
@@ -2282,11 +2514,11 @@ export async function buildClientBundles(
|
|
|
2282
2514
|
const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
|
|
2283
2515
|
const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
|
|
2284
2516
|
|
|
2285
|
-
if (islandFiles.length > 0) {
|
|
2286
|
-
const islandResults = await Promise.all(
|
|
2287
|
-
islandFiles.map(async (entry) => {
|
|
2288
|
-
try {
|
|
2289
|
-
return await buildPerIslandBundle(entry, outDir, options);
|
|
2517
|
+
if (islandFiles.length > 0) {
|
|
2518
|
+
const islandResults = await Promise.all(
|
|
2519
|
+
islandFiles.map(async (entry) => {
|
|
2520
|
+
try {
|
|
2521
|
+
return await buildPerIslandBundle(entry, outDir, options);
|
|
2290
2522
|
} catch (error) {
|
|
2291
2523
|
errors.push(`[island:${entry.name}] ${String(error)}`);
|
|
2292
2524
|
return null;
|
|
@@ -2294,20 +2526,38 @@ export async function buildClientBundles(
|
|
|
2294
2526
|
})
|
|
2295
2527
|
);
|
|
2296
2528
|
for (const result of islandResults) {
|
|
2297
|
-
if (result) islandBundles.push(result);
|
|
2298
|
-
}
|
|
2299
|
-
}
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2529
|
+
if (result) islandBundles.push(result);
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
const partialBundles: PartialBundleBuild[] = [];
|
|
2534
|
+
if (partialFiles.length > 0) {
|
|
2535
|
+
const partialResults = await Promise.all(
|
|
2536
|
+
partialFiles.map(async (entry) => {
|
|
2537
|
+
try {
|
|
2538
|
+
return await buildPartialBundle(entry, outDir, options);
|
|
2539
|
+
} catch (error) {
|
|
2540
|
+
errors.push(`[partial:${entry.name}] ${String(error)}`);
|
|
2541
|
+
return null;
|
|
2542
|
+
}
|
|
2543
|
+
}),
|
|
2544
|
+
);
|
|
2545
|
+
for (const result of partialResults) {
|
|
2546
|
+
if (result) partialBundles.push(result);
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// 6. 번들 매니페스트 생성
|
|
2551
|
+
const bundleManifest = createBundleManifest(
|
|
2552
|
+
outputs,
|
|
2553
|
+
hydratedRoutes,
|
|
2554
|
+
runtimeResult.outputPath,
|
|
2555
|
+
vendorResult,
|
|
2556
|
+
routerResult.outputPath,
|
|
2557
|
+
env,
|
|
2558
|
+
islandBundles,
|
|
2559
|
+
partialBundles,
|
|
2560
|
+
);
|
|
2311
2561
|
|
|
2312
2562
|
await fs.writeFile(
|
|
2313
2563
|
path.join(rootDir, ".mandu/manifest.json"),
|
|
@@ -2315,7 +2565,15 @@ export async function buildClientBundles(
|
|
|
2315
2565
|
);
|
|
2316
2566
|
|
|
2317
2567
|
// 7. 통계 계산
|
|
2318
|
-
const stats = calculateStats(
|
|
2568
|
+
const stats = calculateStats(
|
|
2569
|
+
outputs,
|
|
2570
|
+
startTime,
|
|
2571
|
+
partialBundles.map((partial) => ({
|
|
2572
|
+
routeId: `partial:${partial.name}`,
|
|
2573
|
+
size: partial.size,
|
|
2574
|
+
gzipSize: partial.gzipSize,
|
|
2575
|
+
})),
|
|
2576
|
+
);
|
|
2319
2577
|
|
|
2320
2578
|
// Phase 18.τ — fire onBundleComplete(stats) before return.
|
|
2321
2579
|
await fireOnBundleComplete(stats);
|
|
@@ -2343,13 +2601,14 @@ export function formatSize(bytes: number): string {
|
|
|
2343
2601
|
* 번들 결과 요약 출력
|
|
2344
2602
|
*/
|
|
2345
2603
|
export function printBundleStats(result: BundleResult): void {
|
|
2346
|
-
console.log("\n📦 Mandu Client Bundles");
|
|
2347
|
-
console.log("=".repeat(50));
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2604
|
+
console.log("\n📦 Mandu Client Bundles");
|
|
2605
|
+
console.log("=".repeat(50));
|
|
2606
|
+
|
|
2607
|
+
const partialCount = Object.keys(result.manifest.partials ?? {}).length;
|
|
2608
|
+
if (result.outputs.length === 0 && partialCount === 0) {
|
|
2609
|
+
console.log("No islands or partials to bundle (hydration: none or no client entry)");
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2353
2612
|
|
|
2354
2613
|
console.log(`Environment: ${result.manifest.env}`);
|
|
2355
2614
|
console.log(`Bundles: ${result.stats.bundleCount}`);
|
|
@@ -2358,12 +2617,15 @@ export function printBundleStats(result: BundleResult): void {
|
|
|
2358
2617
|
console.log(`Build Time: ${result.stats.buildTime.toFixed(0)}ms`);
|
|
2359
2618
|
console.log("");
|
|
2360
2619
|
|
|
2361
|
-
// 각 번들 정보
|
|
2362
|
-
for (const output of result.outputs) {
|
|
2363
|
-
console.log(
|
|
2364
|
-
` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
|
|
2365
|
-
);
|
|
2366
|
-
}
|
|
2620
|
+
// 각 번들 정보
|
|
2621
|
+
for (const output of result.outputs) {
|
|
2622
|
+
console.log(
|
|
2623
|
+
` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
|
|
2624
|
+
);
|
|
2625
|
+
}
|
|
2626
|
+
if (partialCount > 0) {
|
|
2627
|
+
console.log(` Partials: ${partialCount}`);
|
|
2628
|
+
}
|
|
2367
2629
|
|
|
2368
2630
|
if (result.errors.length > 0) {
|
|
2369
2631
|
console.log("\n⚠️ Errors:");
|