@mandujs/core 0.54.16 → 0.54.18
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 +3 -1
- package/src/agent/__tests__/context.test.ts +54 -16
- package/src/agent/context.ts +17 -0
- package/src/agent/types.ts +32 -12
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
- package/src/bundler/__tests__/build-runner.ts +130 -17
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
- package/src/bundler/build.test.ts +440 -8
- package/src/bundler/build.ts +455 -132
- package/src/bundler/client-boundary-transform.ts +977 -0
- package/src/bundler/dev.ts +39 -112
- package/src/bundler/fast-refresh-preamble.ts +47 -0
- package/src/bundler/index.ts +3 -2
- package/src/bundler/manifest-schema.ts +10 -0
- package/src/bundler/types.ts +20 -2
- package/src/diagnose/__tests__/checks.test.ts +117 -17
- package/src/diagnose/checks.ts +184 -3
- package/src/diagnose/run.ts +10 -8
- package/src/generator/templates.test.ts +48 -5
- package/src/generator/templates.ts +10 -1
- package/src/internal/client-boundary.ts +266 -0
- package/src/internal/index.ts +2 -1
- package/src/router/client-entry.test.ts +43 -6
- package/src/router/client-entry.ts +33 -12
- package/src/router/fs-routes.test.ts +388 -1
- package/src/router/fs-routes.ts +16 -3
- package/src/router/fs-scanner.ts +166 -18
- package/src/router/fs-types.ts +4 -1
- package/src/runtime/__tests__/inline-client-hydration.test.ts +134 -0
- package/src/runtime/__tests__/page-render-response.test.ts +212 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +43 -3
- package/src/runtime/server.ts +42 -5
- package/src/runtime/ssr.ts +16 -5
- package/src/runtime/streaming-ssr.ts +119 -76
- package/src/spec/schema.ts +31 -5
package/src/bundler/build.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type * as __ManduPluginsReactCompilerTypes0 from "./plugins/react-compile
|
|
|
4
4
|
* Bun.build 기반 클라이언트 번들 빌드
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { RoutesManifest, RouteSpec } from "../spec/schema";
|
|
7
|
+
import type { RouteClientBoundary, RoutesManifest, RouteSpec } from "../spec/schema";
|
|
8
8
|
import { needsHydration, getRouteHydration } from "../spec/schema";
|
|
9
9
|
import type {
|
|
10
10
|
BundleResult,
|
|
@@ -36,7 +36,19 @@ import {
|
|
|
36
36
|
type VendorCacheWriteEntry,
|
|
37
37
|
} from "./vendor-cache";
|
|
38
38
|
import path from "path";
|
|
39
|
-
import fs from "fs/promises";
|
|
39
|
+
import fs from "fs/promises";
|
|
40
|
+
|
|
41
|
+
interface BoundaryBundleBuild {
|
|
42
|
+
id: string;
|
|
43
|
+
route: string;
|
|
44
|
+
js: string;
|
|
45
|
+
module: string;
|
|
46
|
+
exportName: string;
|
|
47
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
48
|
+
hydrate: string;
|
|
49
|
+
size: number;
|
|
50
|
+
gzipSize: number;
|
|
51
|
+
}
|
|
40
52
|
|
|
41
53
|
/**
|
|
42
54
|
* Resolve Mandu's default bundler plugin set from a `BundlerOptions`
|
|
@@ -411,23 +423,24 @@ function createEmptyManifest(env: "development" | "production"): BundleManifest
|
|
|
411
423
|
/**
|
|
412
424
|
* Hydration이 필요한 라우트 필터링
|
|
413
425
|
*/
|
|
414
|
-
function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
|
|
415
|
-
return manifest.routes.filter(
|
|
416
|
-
(route) =>
|
|
417
|
-
route.kind === "page" &&
|
|
418
|
-
route.clientModule &&
|
|
419
|
-
needsHydration(route)
|
|
420
|
-
);
|
|
421
|
-
}
|
|
426
|
+
function getHydratedRoutes(manifest: RoutesManifest): RouteSpec[] {
|
|
427
|
+
return manifest.routes.filter(
|
|
428
|
+
(route) =>
|
|
429
|
+
route.kind === "page" &&
|
|
430
|
+
(!!route.clientModule || !!route.boundaries?.length) &&
|
|
431
|
+
needsHydration(route)
|
|
432
|
+
);
|
|
433
|
+
}
|
|
422
434
|
|
|
423
435
|
function getHydrationRoutesMissingClientModule(manifest: RoutesManifest): RouteSpec[] {
|
|
424
436
|
return manifest.routes.filter(
|
|
425
|
-
(route) =>
|
|
426
|
-
route.kind === "page" &&
|
|
427
|
-
!route.clientModule &&
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
437
|
+
(route) =>
|
|
438
|
+
route.kind === "page" &&
|
|
439
|
+
!route.clientModule &&
|
|
440
|
+
!route.boundaries?.length &&
|
|
441
|
+
needsHydration(route)
|
|
442
|
+
);
|
|
443
|
+
}
|
|
431
444
|
|
|
432
445
|
const REACT_SHIM_EXPORTS = [
|
|
433
446
|
"Activity",
|
|
@@ -525,6 +538,7 @@ import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
|
525
538
|
// Hydrated roots 추적 (unmount용) - 전역 초기화
|
|
526
539
|
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
527
540
|
const hydratedRoots = window.__MANDU_ROOTS__;
|
|
541
|
+
const warnedBoundaryPropFallbacks = new Set();
|
|
528
542
|
|
|
529
543
|
const TYPE_MARKERS = {
|
|
530
544
|
UNDEFINED: "\\u0000_",
|
|
@@ -624,7 +638,21 @@ function readManduData() {
|
|
|
624
638
|
return window.__MANDU_DATA__;
|
|
625
639
|
}
|
|
626
640
|
|
|
627
|
-
const getServerData = (id) =>
|
|
641
|
+
const getServerData = (id, element) => {
|
|
642
|
+
const data = readManduData();
|
|
643
|
+
if (data[id] && Object.prototype.hasOwnProperty.call(data[id], 'serverData')) {
|
|
644
|
+
return data[id].serverData;
|
|
645
|
+
}
|
|
646
|
+
const routeId = element?.getAttribute?.('data-mandu-route-id');
|
|
647
|
+
if (
|
|
648
|
+
routeId &&
|
|
649
|
+
data[routeId] &&
|
|
650
|
+
Object.prototype.hasOwnProperty.call(data[routeId], 'serverData')
|
|
651
|
+
) {
|
|
652
|
+
return data[routeId].serverData;
|
|
653
|
+
}
|
|
654
|
+
return {};
|
|
655
|
+
};
|
|
628
656
|
|
|
629
657
|
function findPropsScript(id) {
|
|
630
658
|
const scripts = document.querySelectorAll('script[data-mandu-props]');
|
|
@@ -661,7 +689,33 @@ function readDataProps(element) {
|
|
|
661
689
|
}
|
|
662
690
|
|
|
663
691
|
function getIslandProps(id, element) {
|
|
664
|
-
|
|
692
|
+
const inlineProps = parsePropsScript(id);
|
|
693
|
+
if (inlineProps) return inlineProps;
|
|
694
|
+
|
|
695
|
+
const dataProps = readDataProps(element);
|
|
696
|
+
if (dataProps) return dataProps;
|
|
697
|
+
|
|
698
|
+
const boundaryId = element?.getAttribute?.('data-mandu-boundary-id');
|
|
699
|
+
if (boundaryId && !warnedBoundaryPropFallbacks.has(id)) {
|
|
700
|
+
warnedBoundaryPropFallbacks.add(id);
|
|
701
|
+
console.warn(
|
|
702
|
+
'[Mandu] Missing boundary-local props for transformed client boundary ' +
|
|
703
|
+
boundaryId +
|
|
704
|
+
'; falling back to route server data.'
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return getServerData(id, element);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function resolveIslandExport(module, element) {
|
|
712
|
+
const exportName = element.getAttribute('data-mandu-client-export');
|
|
713
|
+
if (exportName) {
|
|
714
|
+
if (exportName === 'default' && module.default) return module.default;
|
|
715
|
+
if (exportName !== 'default' && module[exportName]) return module[exportName];
|
|
716
|
+
console.warn('[Mandu] Client boundary export "' + exportName + '" was not found; falling back to default export.');
|
|
717
|
+
}
|
|
718
|
+
return module.default;
|
|
665
719
|
}
|
|
666
720
|
|
|
667
721
|
/**
|
|
@@ -908,8 +962,8 @@ async function loadAndHydrate(element, src) {
|
|
|
908
962
|
|
|
909
963
|
try {
|
|
910
964
|
// Dynamic import - 이 시점에 Island 모듈 로드
|
|
911
|
-
const module = await import(src);
|
|
912
|
-
const island = module
|
|
965
|
+
const module = await import(src);
|
|
966
|
+
const island = resolveIslandExport(module, element);
|
|
913
967
|
const data = getIslandProps(id, element);
|
|
914
968
|
|
|
915
969
|
// Mandu Island (preferred)
|
|
@@ -995,20 +1049,27 @@ async function loadAndHydrate(element, src) {
|
|
|
995
1049
|
|
|
996
1050
|
console.log('[Mandu] Hydrated:', id, '(' + renderMode + ')');
|
|
997
1051
|
}
|
|
998
|
-
// Plain React component fallback (e.g. "use client" pages)
|
|
999
|
-
else if (typeof island === 'function' || React.isValidElement(island)) {
|
|
1000
|
-
console.warn('[Mandu] Plain component hydration:', id);
|
|
1001
|
-
const
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1052
|
+
// Plain React component fallback (e.g. "use client" pages)
|
|
1053
|
+
else if (typeof island === 'function' || React.isValidElement(island)) {
|
|
1054
|
+
console.warn('[Mandu] Plain component hydration:', id);
|
|
1055
|
+
const shouldHydrate = hasHydratableMarkup(element);
|
|
1056
|
+
const renderMode = shouldHydrate ? 'hydrate' : 'mount';
|
|
1057
|
+
|
|
1058
|
+
const root = shouldHydrate
|
|
1059
|
+
? (typeof island === 'function'
|
|
1060
|
+
? hydrateRoot(
|
|
1061
|
+
element,
|
|
1062
|
+
React.createElement(island, data),
|
|
1063
|
+
createHydrationOptions(element, id, renderMode)
|
|
1064
|
+
)
|
|
1065
|
+
: hydrateRoot(element, island, createHydrationOptions(element, id, renderMode)))
|
|
1066
|
+
: createRoot(element);
|
|
1067
|
+
|
|
1068
|
+
if (!shouldHydrate) {
|
|
1069
|
+
root.render(typeof island === 'function' ? React.createElement(island, data) : island);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
hydratedRoots.set(id, root);
|
|
1012
1073
|
|
|
1013
1074
|
// 완료 표시
|
|
1014
1075
|
element.setAttribute('data-mandu-render-mode', renderMode);
|
|
@@ -1589,6 +1650,9 @@ function generateIslandEntry(routeId: string, clientModulePath: string, exportNa
|
|
|
1589
1650
|
// Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
|
|
1590
1651
|
const normalizedPath = clientModulePath.replace(/\\/g, "/");
|
|
1591
1652
|
const normalizedExportName = exportName && exportName !== "default" ? exportName : undefined;
|
|
1653
|
+
const namedExport = normalizedExportName && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(normalizedExportName)
|
|
1654
|
+
? `export const ${normalizedExportName} = exportedIsland;`
|
|
1655
|
+
: "";
|
|
1592
1656
|
const candidates = [
|
|
1593
1657
|
normalizedExportName,
|
|
1594
1658
|
inferClientExportNameFromPath(clientModulePath),
|
|
@@ -1608,8 +1672,10 @@ import React from "react";
|
|
|
1608
1672
|
import * as islandModule from ${importSpecifier};
|
|
1609
1673
|
|
|
1610
1674
|
const candidateExportNames = ${JSON.stringify(candidates)};
|
|
1675
|
+
const explicitExportName = ${JSON.stringify(normalizedExportName ?? null)};
|
|
1611
1676
|
|
|
1612
1677
|
function resolveIslandExport(mod) {
|
|
1678
|
+
if (explicitExportName && mod[explicitExportName]) return mod[explicitExportName];
|
|
1613
1679
|
if (mod.default) return mod.default;
|
|
1614
1680
|
for (const name of candidateExportNames) {
|
|
1615
1681
|
if (mod[name]) return mod[name];
|
|
@@ -1630,6 +1696,7 @@ const exportedIsland = island && island.__mandu_island === true
|
|
|
1630
1696
|
};
|
|
1631
1697
|
|
|
1632
1698
|
export default exportedIsland;
|
|
1699
|
+
${namedExport}
|
|
1633
1700
|
`;
|
|
1634
1701
|
}
|
|
1635
1702
|
|
|
@@ -2095,7 +2162,7 @@ function routeIdToAssetStem(routeId: string): string {
|
|
|
2095
2162
|
/**
|
|
2096
2163
|
* 단일 Island 번들 빌드
|
|
2097
2164
|
*/
|
|
2098
|
-
async function buildIsland(
|
|
2165
|
+
async function buildIsland(
|
|
2099
2166
|
route: RouteSpec,
|
|
2100
2167
|
rootDir: string,
|
|
2101
2168
|
outDir: string,
|
|
@@ -2178,8 +2245,171 @@ async function buildIsland(
|
|
|
2178
2245
|
} catch (error) {
|
|
2179
2246
|
await fs.unlink(entryPath).catch(() => {});
|
|
2180
2247
|
throw error;
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
async function buildBoundaryBundle(
|
|
2252
|
+
boundary: RouteClientBoundary,
|
|
2253
|
+
rootDir: string,
|
|
2254
|
+
outDir: string,
|
|
2255
|
+
options: BundlerOptions,
|
|
2256
|
+
): Promise<BoundaryBundleBuild> {
|
|
2257
|
+
const clientModulePath = path.join(rootDir, boundary.module);
|
|
2258
|
+
const assetStem = routeIdToAssetStem(boundary.id);
|
|
2259
|
+
const entryStem = `_entry_boundary_${assetStem}`;
|
|
2260
|
+
const entryPath = path.join(outDir, `${entryStem}.js`);
|
|
2261
|
+
const outputName = `${assetStem}.boundary.js`;
|
|
2262
|
+
const isDev = isDevelopmentBuild(options);
|
|
2263
|
+
|
|
2264
|
+
try {
|
|
2265
|
+
await Bun.write(entryPath, generateIslandEntry(boundary.id, clientModulePath, boundary.exportName));
|
|
2266
|
+
|
|
2267
|
+
const result = await safeBuild({
|
|
2268
|
+
entrypoints: [entryPath],
|
|
2269
|
+
outdir: outDir,
|
|
2270
|
+
naming: options.splitting ? "[name]-[hash].js" : outputName,
|
|
2271
|
+
minify: shouldMinify(options),
|
|
2272
|
+
sourcemap: options.sourcemap ? "external" : "none",
|
|
2273
|
+
target: "browser",
|
|
2274
|
+
splitting: shouldSplitChunks(options),
|
|
2275
|
+
...(isDev ? { reactFastRefresh: true } : {}),
|
|
2276
|
+
plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
|
|
2277
|
+
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
2278
|
+
define: {
|
|
2279
|
+
"process.env.NODE_ENV": nodeEnvDefine(options),
|
|
2280
|
+
...options.define,
|
|
2281
|
+
},
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
await fs.unlink(entryPath).catch(() => {});
|
|
2285
|
+
|
|
2286
|
+
if (!result.success) {
|
|
2287
|
+
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
2288
|
+
throw new Error(`Boundary build failed for '${boundary.id}' (source: ${clientModulePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types in this client boundary file.`);
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
let actualOutputPath: string;
|
|
2292
|
+
let actualOutputName: string;
|
|
2293
|
+
if (options.splitting && result.outputs.length > 0) {
|
|
2294
|
+
const entryOutput = result.outputs.find(
|
|
2295
|
+
(o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem),
|
|
2296
|
+
);
|
|
2297
|
+
actualOutputPath = entryOutput?.path ?? result.outputs[0].path;
|
|
2298
|
+
actualOutputName = path.basename(actualOutputPath);
|
|
2299
|
+
} else {
|
|
2300
|
+
actualOutputPath = path.join(outDir, outputName);
|
|
2301
|
+
actualOutputName = outputName;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
const outputFile = Bun.file(actualOutputPath);
|
|
2305
|
+
const content = await sanitizeGeneratedClientBundle(actualOutputPath, isDev);
|
|
2306
|
+
const gzipped = Bun.gzipSync(Buffer.from(content));
|
|
2307
|
+
const priority = boundaryPriorityToLegacyPriority(boundary.hydrate);
|
|
2308
|
+
|
|
2309
|
+
return {
|
|
2310
|
+
id: boundary.id,
|
|
2311
|
+
route: boundary.routeId,
|
|
2312
|
+
js: `/.mandu/client/${actualOutputName}`,
|
|
2313
|
+
module: boundary.module,
|
|
2314
|
+
exportName: boundary.exportName,
|
|
2315
|
+
priority,
|
|
2316
|
+
hydrate: boundary.hydrate,
|
|
2317
|
+
size: outputFile.size,
|
|
2318
|
+
gzipSize: gzipped.length,
|
|
2319
|
+
};
|
|
2320
|
+
} finally {
|
|
2321
|
+
await fs.unlink(entryPath).catch(() => {});
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
async function buildBoundaryBundlesForRecords(
|
|
2326
|
+
boundaries: RouteClientBoundary[],
|
|
2327
|
+
rootDir: string,
|
|
2328
|
+
outDir: string,
|
|
2329
|
+
options: BundlerOptions,
|
|
2330
|
+
errors: string[],
|
|
2331
|
+
): Promise<BoundaryBundleBuild[]> {
|
|
2332
|
+
if (boundaries.length === 0) return [];
|
|
2333
|
+
if (pushDuplicateBoundaryIdErrors(boundaries, errors)) return [];
|
|
2334
|
+
|
|
2335
|
+
const results = await Promise.all(
|
|
2336
|
+
boundaries.map(async (boundary) => {
|
|
2337
|
+
try {
|
|
2338
|
+
return await buildBoundaryBundle(boundary, rootDir, outDir, options);
|
|
2339
|
+
} catch (error) {
|
|
2340
|
+
errors.push(`[boundary:${boundary.id}] ${String(error)}`);
|
|
2341
|
+
return null;
|
|
2342
|
+
}
|
|
2343
|
+
}),
|
|
2344
|
+
);
|
|
2345
|
+
|
|
2346
|
+
return results.filter((result): result is BoundaryBundleBuild => result !== null);
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
function pushDuplicateBoundaryIdErrors(boundaries: RouteClientBoundary[], errors: string[]): boolean {
|
|
2350
|
+
const firstById = new Map<string, RouteClientBoundary>();
|
|
2351
|
+
let hasDuplicate = false;
|
|
2352
|
+
|
|
2353
|
+
for (const boundary of boundaries) {
|
|
2354
|
+
const first = firstById.get(boundary.id);
|
|
2355
|
+
if (!first) {
|
|
2356
|
+
firstById.set(boundary.id, boundary);
|
|
2357
|
+
continue;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
hasDuplicate = true;
|
|
2361
|
+
errors.push(
|
|
2362
|
+
`[boundary:${boundary.id}] MANDU_BOUNDARY_DUPLICATE_ID Duplicate client boundary id. ` +
|
|
2363
|
+
`First route="${first.routeId}" source="${first.source.file}", duplicate route="${boundary.routeId}" source="${boundary.source.file}". ` +
|
|
2364
|
+
"Boundary ids must be unique before bundle manifest generation.",
|
|
2365
|
+
);
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
return hasDuplicate;
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
function mergeBoundaryBundlesIntoManifest(
|
|
2372
|
+
manifest: BundleManifest,
|
|
2373
|
+
routeIds: Iterable<string>,
|
|
2374
|
+
boundaryBundles: BoundaryBundleBuild[],
|
|
2375
|
+
): void {
|
|
2376
|
+
const rebuiltRouteIds = new Set(routeIds);
|
|
2377
|
+
if (rebuiltRouteIds.size === 0 && boundaryBundles.length === 0) return;
|
|
2378
|
+
|
|
2379
|
+
if (manifest.boundaries) {
|
|
2380
|
+
for (const [id, boundary] of Object.entries(manifest.boundaries)) {
|
|
2381
|
+
if (rebuiltRouteIds.has(boundary.route)) {
|
|
2382
|
+
delete manifest.boundaries[id];
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
if (boundaryBundles.length > 0) {
|
|
2388
|
+
manifest.boundaries = manifest.boundaries || {};
|
|
2389
|
+
for (const boundary of boundaryBundles) {
|
|
2390
|
+
manifest.boundaries[boundary.id] = {
|
|
2391
|
+
route: boundary.route,
|
|
2392
|
+
js: boundary.js,
|
|
2393
|
+
module: boundary.module,
|
|
2394
|
+
exportName: boundary.exportName,
|
|
2395
|
+
priority: boundary.priority,
|
|
2396
|
+
hydrate: boundary.hydrate,
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
if (manifest.boundaries && Object.keys(manifest.boundaries).length === 0) {
|
|
2402
|
+
delete manifest.boundaries;
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
function boundaryPriorityToLegacyPriority(value: string): BoundaryBundleBuild["priority"] {
|
|
2407
|
+
if (value === "load") return "immediate";
|
|
2408
|
+
if (value === "immediate" || value === "visible" || value === "idle" || value === "interaction") {
|
|
2409
|
+
return value;
|
|
2410
|
+
}
|
|
2411
|
+
return "visible";
|
|
2412
|
+
}
|
|
2183
2413
|
|
|
2184
2414
|
async function sanitizeGeneratedClientBundle(outputPath: string, isDev: boolean): Promise<string> {
|
|
2185
2415
|
const source = await Bun.file(outputPath).text();
|
|
@@ -2232,10 +2462,11 @@ function createBundleManifest(
|
|
|
2232
2462
|
runtimePath: string,
|
|
2233
2463
|
vendorResult: VendorBuildResult,
|
|
2234
2464
|
routerPath: string,
|
|
2235
|
-
env: "development" | "production",
|
|
2236
|
-
islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
|
|
2237
|
-
partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
|
|
2238
|
-
|
|
2465
|
+
env: "development" | "production",
|
|
2466
|
+
islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
|
|
2467
|
+
partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
|
|
2468
|
+
boundaryBundles?: BoundaryBundleBuild[],
|
|
2469
|
+
): BundleManifest {
|
|
2239
2470
|
const bundles: BundleManifest["bundles"] = {};
|
|
2240
2471
|
|
|
2241
2472
|
for (const output of outputs) {
|
|
@@ -2262,7 +2493,7 @@ function createBundleManifest(
|
|
|
2262
2493
|
}
|
|
2263
2494
|
}
|
|
2264
2495
|
|
|
2265
|
-
let partials: BundleManifest["partials"];
|
|
2496
|
+
let partials: BundleManifest["partials"];
|
|
2266
2497
|
if (partialBundles && partialBundles.length > 0) {
|
|
2267
2498
|
partials = {};
|
|
2268
2499
|
for (const partial of partialBundles) {
|
|
@@ -2271,7 +2502,22 @@ function createBundleManifest(
|
|
|
2271
2502
|
priority: partial.priority,
|
|
2272
2503
|
};
|
|
2273
2504
|
}
|
|
2274
|
-
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
let boundaries: BundleManifest["boundaries"];
|
|
2508
|
+
if (boundaryBundles && boundaryBundles.length > 0) {
|
|
2509
|
+
boundaries = {};
|
|
2510
|
+
for (const boundary of boundaryBundles) {
|
|
2511
|
+
boundaries[boundary.id] = {
|
|
2512
|
+
route: boundary.route,
|
|
2513
|
+
js: boundary.js,
|
|
2514
|
+
module: boundary.module,
|
|
2515
|
+
exportName: boundary.exportName,
|
|
2516
|
+
priority: boundary.priority,
|
|
2517
|
+
hydrate: boundary.hydrate,
|
|
2518
|
+
};
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2275
2521
|
|
|
2276
2522
|
// Phase 7.1 B-2: expose Fast Refresh dev bundles so the HTML
|
|
2277
2523
|
// preamble can inject a dynamic import pointing at them. Only
|
|
@@ -2288,10 +2534,11 @@ function createBundleManifest(
|
|
|
2288
2534
|
version: 1,
|
|
2289
2535
|
buildTime: new Date().toISOString(),
|
|
2290
2536
|
env,
|
|
2291
|
-
bundles,
|
|
2292
|
-
...(islands ? { islands } : {}),
|
|
2293
|
-
...(partials ? { partials } : {}),
|
|
2294
|
-
|
|
2537
|
+
bundles,
|
|
2538
|
+
...(islands ? { islands } : {}),
|
|
2539
|
+
...(partials ? { partials } : {}),
|
|
2540
|
+
...(boundaries ? { boundaries } : {}),
|
|
2541
|
+
shared: {
|
|
2295
2542
|
runtime: runtimePath,
|
|
2296
2543
|
vendor: vendorResult.react, // primary vendor for backwards compatibility
|
|
2297
2544
|
router: routerPath, // Client-side Router
|
|
@@ -2487,26 +2734,37 @@ export async function buildClientBundles(
|
|
|
2487
2734
|
};
|
|
2488
2735
|
}
|
|
2489
2736
|
|
|
2490
|
-
// 부분 빌드 모드: targetRouteIds가 지정되면 해당 Island만 재빌드 (#122)
|
|
2491
|
-
if (options.targetRouteIds && options.targetRouteIds.length > 0) {
|
|
2492
|
-
const
|
|
2493
|
-
|
|
2494
|
-
const
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2737
|
+
// 부분 빌드 모드: targetRouteIds가 지정되면 해당 Island만 재빌드 (#122)
|
|
2738
|
+
if (options.targetRouteIds && options.targetRouteIds.length > 0) {
|
|
2739
|
+
const targetRouteIds = new Set(options.targetRouteIds);
|
|
2740
|
+
const targetRoutes = hydratedRoutes.filter((r) => targetRouteIds.has(r.id));
|
|
2741
|
+
const targetIslandRoutes = targetRoutes.filter((route) => !!route.clientModule);
|
|
2742
|
+
|
|
2743
|
+
const targetResults = await Promise.all(
|
|
2744
|
+
targetIslandRoutes.map(async (route) => {
|
|
2745
|
+
try {
|
|
2746
|
+
return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
|
|
2747
|
+
} catch (error) {
|
|
2499
2748
|
return { ok: false as const, routeId: route.id, error: String(error) };
|
|
2500
2749
|
}
|
|
2501
2750
|
}),
|
|
2502
2751
|
);
|
|
2503
2752
|
for (const r of targetResults) {
|
|
2504
2753
|
if (r.ok) outputs.push(r.result);
|
|
2505
|
-
else errors.push(`[${r.routeId}] ${r.error}`);
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2754
|
+
else errors.push(`[${r.routeId}] ${r.error}`);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
const boundaryRecords = targetRoutes.flatMap((route) => route.boundaries ?? []);
|
|
2758
|
+
const boundaryBundles = await buildBoundaryBundlesForRecords(
|
|
2759
|
+
boundaryRecords,
|
|
2760
|
+
rootDir,
|
|
2761
|
+
outDir,
|
|
2762
|
+
options,
|
|
2763
|
+
errors,
|
|
2764
|
+
);
|
|
2765
|
+
|
|
2766
|
+
// 기존 매니페스트를 읽어 변경된 Island만 갱신
|
|
2767
|
+
let existingManifest: BundleManifest;
|
|
2510
2768
|
try {
|
|
2511
2769
|
const manifestData = await fs.readFile(path.join(rootDir, ".mandu/manifest.json"), "utf-8");
|
|
2512
2770
|
existingManifest = JSON.parse(manifestData) as BundleManifest;
|
|
@@ -2519,31 +2777,45 @@ export async function buildClientBundles(
|
|
|
2519
2777
|
for (const routeId of invalidClientRouteIds) {
|
|
2520
2778
|
delete existingManifest.bundles[routeId];
|
|
2521
2779
|
}
|
|
2522
|
-
if (outputs.length > 0 || invalidClientRouteIds.size > 0) {
|
|
2523
|
-
for (const output of outputs) {
|
|
2524
|
-
if (existingManifest.bundles[output.routeId]) {
|
|
2525
|
-
existingManifest.bundles[output.routeId].js = output.outputPath;
|
|
2526
|
-
} else {
|
|
2527
|
-
const route =
|
|
2528
|
-
const hydration = route ? getRouteHydration(route) : null;
|
|
2529
|
-
existingManifest.bundles[output.routeId] = {
|
|
2530
|
-
js: output.outputPath,
|
|
2780
|
+
if (outputs.length > 0 || invalidClientRouteIds.size > 0 || boundaryRecords.length > 0) {
|
|
2781
|
+
for (const output of outputs) {
|
|
2782
|
+
if (existingManifest.bundles[output.routeId]) {
|
|
2783
|
+
existingManifest.bundles[output.routeId].js = output.outputPath;
|
|
2784
|
+
} else {
|
|
2785
|
+
const route = targetIslandRoutes.find((r) => r.id === output.routeId);
|
|
2786
|
+
const hydration = route ? getRouteHydration(route) : null;
|
|
2787
|
+
existingManifest.bundles[output.routeId] = {
|
|
2788
|
+
js: output.outputPath,
|
|
2531
2789
|
dependencies: ["_runtime", "_react"],
|
|
2532
2790
|
priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
|
|
2533
|
-
};
|
|
2534
|
-
}
|
|
2535
|
-
}
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
mergeBoundaryBundlesIntoManifest(
|
|
2796
|
+
existingManifest,
|
|
2797
|
+
targetRoutes.map((route) => route.id),
|
|
2798
|
+
boundaryBundles,
|
|
2799
|
+
);
|
|
2800
|
+
|
|
2801
|
+
await fs.writeFile(
|
|
2802
|
+
path.join(rootDir, ".mandu/manifest.json"),
|
|
2539
2803
|
JSON.stringify(existingManifest, null, 2)
|
|
2540
2804
|
);
|
|
2541
2805
|
}
|
|
2542
|
-
// When all builds failed, do NOT overwrite manifest — keep previous good state
|
|
2543
|
-
|
|
2544
|
-
const stats = calculateStats(
|
|
2545
|
-
|
|
2546
|
-
|
|
2806
|
+
// When all builds failed, do NOT overwrite manifest — keep previous good state
|
|
2807
|
+
|
|
2808
|
+
const stats = calculateStats(
|
|
2809
|
+
outputs,
|
|
2810
|
+
startTime,
|
|
2811
|
+
boundaryBundles.map((boundary) => ({
|
|
2812
|
+
routeId: `boundary:${boundary.id}`,
|
|
2813
|
+
size: boundary.size,
|
|
2814
|
+
gzipSize: boundary.gzipSize,
|
|
2815
|
+
})),
|
|
2816
|
+
);
|
|
2817
|
+
return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
|
|
2818
|
+
}
|
|
2547
2819
|
|
|
2548
2820
|
// #185: Framework-internal 번들 스킵 모드
|
|
2549
2821
|
// 사용자 코드(src/shared 등) 변경 시 runtime/router/vendor/devtools 재빌드는 낭비.
|
|
@@ -2599,7 +2871,7 @@ export async function buildClientBundles(
|
|
|
2599
2871
|
}
|
|
2600
2872
|
|
|
2601
2873
|
const islandResults = await Promise.all(
|
|
2602
|
-
hydratedRoutes.map(async (route) => {
|
|
2874
|
+
hydratedRoutes.filter((route) => !!route.clientModule).map(async (route) => {
|
|
2603
2875
|
try {
|
|
2604
2876
|
return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
|
|
2605
2877
|
} catch (error) {
|
|
@@ -2645,18 +2917,32 @@ export async function buildClientBundles(
|
|
|
2645
2917
|
};
|
|
2646
2918
|
}
|
|
2647
2919
|
}
|
|
2648
|
-
if (perIslandBundles.length > 0) {
|
|
2649
|
-
existingManifest.islands = existingManifest.islands || {};
|
|
2650
|
-
for (const ib of perIslandBundles) {
|
|
2651
|
-
existingManifest.islands[ib.name] = {
|
|
2920
|
+
if (perIslandBundles.length > 0) {
|
|
2921
|
+
existingManifest.islands = existingManifest.islands || {};
|
|
2922
|
+
for (const ib of perIslandBundles) {
|
|
2923
|
+
existingManifest.islands[ib.name] = {
|
|
2652
2924
|
js: ib.js,
|
|
2653
2925
|
route: ib.route,
|
|
2654
2926
|
priority: ib.priority,
|
|
2655
|
-
};
|
|
2656
|
-
}
|
|
2657
|
-
}
|
|
2658
|
-
|
|
2659
|
-
const
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
const boundaryRecords = hydratedRoutes.flatMap((route) => route.boundaries ?? []);
|
|
2932
|
+
const boundaryBundles = await buildBoundaryBundlesForRecords(
|
|
2933
|
+
boundaryRecords,
|
|
2934
|
+
rootDir,
|
|
2935
|
+
outDir,
|
|
2936
|
+
options,
|
|
2937
|
+
errors,
|
|
2938
|
+
);
|
|
2939
|
+
mergeBoundaryBundlesIntoManifest(
|
|
2940
|
+
existingManifest,
|
|
2941
|
+
hydratedRoutes.map((route) => route.id),
|
|
2942
|
+
boundaryBundles,
|
|
2943
|
+
);
|
|
2944
|
+
|
|
2945
|
+
const partialBundles: PartialBundleBuild[] = [];
|
|
2660
2946
|
if (partialFiles.length > 0) {
|
|
2661
2947
|
const partialResults = await Promise.all(
|
|
2662
2948
|
partialFiles.map(async (entry) => {
|
|
@@ -2690,15 +2976,22 @@ export async function buildClientBundles(
|
|
|
2690
2976
|
JSON.stringify(existingManifest, null, 2),
|
|
2691
2977
|
);
|
|
2692
2978
|
|
|
2693
|
-
const stats = calculateStats(
|
|
2694
|
-
outputs,
|
|
2695
|
-
startTime,
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2979
|
+
const stats = calculateStats(
|
|
2980
|
+
outputs,
|
|
2981
|
+
startTime,
|
|
2982
|
+
[
|
|
2983
|
+
...partialBundles.map((partial) => ({
|
|
2984
|
+
routeId: `partial:${partial.name}`,
|
|
2985
|
+
size: partial.size,
|
|
2986
|
+
gzipSize: partial.gzipSize,
|
|
2987
|
+
})),
|
|
2988
|
+
...boundaryBundles.map((boundary) => ({
|
|
2989
|
+
routeId: `boundary:${boundary.id}`,
|
|
2990
|
+
size: boundary.size,
|
|
2991
|
+
gzipSize: boundary.gzipSize,
|
|
2992
|
+
})),
|
|
2993
|
+
],
|
|
2994
|
+
);
|
|
2702
2995
|
return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
|
|
2703
2996
|
}
|
|
2704
2997
|
|
|
@@ -2756,7 +3049,7 @@ export async function buildClientBundles(
|
|
|
2756
3049
|
|
|
2757
3050
|
// 5. 각 Island 번들 병렬 빌드 (#185: L1631의 per-island와 일관성 확보)
|
|
2758
3051
|
const fullIslandResults = await Promise.all(
|
|
2759
|
-
hydratedRoutes.map(async (route) => {
|
|
3052
|
+
hydratedRoutes.filter((route) => !!route.clientModule).map(async (route) => {
|
|
2760
3053
|
try {
|
|
2761
3054
|
return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
|
|
2762
3055
|
} catch (error) {
|
|
@@ -2783,9 +3076,9 @@ export async function buildClientBundles(
|
|
|
2783
3076
|
|
|
2784
3077
|
// 5.5. Per-island code splitting: scan and build individual island bundles
|
|
2785
3078
|
const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
|
|
2786
|
-
const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
|
|
3079
|
+
const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
|
|
2787
3080
|
|
|
2788
|
-
if (islandFiles.length > 0) {
|
|
3081
|
+
if (islandFiles.length > 0) {
|
|
2789
3082
|
const islandResults = await Promise.all(
|
|
2790
3083
|
islandFiles.map(async (entry) => {
|
|
2791
3084
|
try {
|
|
@@ -2799,9 +3092,27 @@ export async function buildClientBundles(
|
|
|
2799
3092
|
for (const result of islandResults) {
|
|
2800
3093
|
if (result) islandBundles.push(result);
|
|
2801
3094
|
}
|
|
2802
|
-
}
|
|
2803
|
-
|
|
2804
|
-
const
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
const boundaryRecords = hydratedRoutes.flatMap((route) => route.boundaries ?? []);
|
|
3098
|
+
const boundaryBundles: BoundaryBundleBuild[] = [];
|
|
3099
|
+
if (boundaryRecords.length > 0 && !pushDuplicateBoundaryIdErrors(boundaryRecords, errors)) {
|
|
3100
|
+
const boundaryResults = await Promise.all(
|
|
3101
|
+
boundaryRecords.map(async (boundary) => {
|
|
3102
|
+
try {
|
|
3103
|
+
return await buildBoundaryBundle(boundary, rootDir, outDir, options);
|
|
3104
|
+
} catch (error) {
|
|
3105
|
+
errors.push(`[boundary:${boundary.id}] ${String(error)}`);
|
|
3106
|
+
return null;
|
|
3107
|
+
}
|
|
3108
|
+
}),
|
|
3109
|
+
);
|
|
3110
|
+
for (const result of boundaryResults) {
|
|
3111
|
+
if (result) boundaryBundles.push(result);
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
const partialBundles: PartialBundleBuild[] = [];
|
|
2805
3116
|
if (partialFiles.length > 0) {
|
|
2806
3117
|
const partialResults = await Promise.all(
|
|
2807
3118
|
partialFiles.map(async (entry) => {
|
|
@@ -2825,10 +3136,11 @@ export async function buildClientBundles(
|
|
|
2825
3136
|
runtimeResult.outputPath,
|
|
2826
3137
|
vendorResult,
|
|
2827
3138
|
routerResult.outputPath,
|
|
2828
|
-
env,
|
|
2829
|
-
islandBundles,
|
|
2830
|
-
partialBundles,
|
|
2831
|
-
|
|
3139
|
+
env,
|
|
3140
|
+
islandBundles,
|
|
3141
|
+
partialBundles,
|
|
3142
|
+
boundaryBundles,
|
|
3143
|
+
);
|
|
2832
3144
|
|
|
2833
3145
|
await fs.writeFile(
|
|
2834
3146
|
path.join(rootDir, ".mandu/manifest.json"),
|
|
@@ -2836,15 +3148,22 @@ export async function buildClientBundles(
|
|
|
2836
3148
|
);
|
|
2837
3149
|
|
|
2838
3150
|
// 7. 통계 계산
|
|
2839
|
-
const stats = calculateStats(
|
|
2840
|
-
outputs,
|
|
2841
|
-
startTime,
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
3151
|
+
const stats = calculateStats(
|
|
3152
|
+
outputs,
|
|
3153
|
+
startTime,
|
|
3154
|
+
[
|
|
3155
|
+
...partialBundles.map((partial) => ({
|
|
3156
|
+
routeId: `partial:${partial.name}`,
|
|
3157
|
+
size: partial.size,
|
|
3158
|
+
gzipSize: partial.gzipSize,
|
|
3159
|
+
})),
|
|
3160
|
+
...boundaryBundles.map((boundary) => ({
|
|
3161
|
+
routeId: `boundary:${boundary.id}`,
|
|
3162
|
+
size: boundary.size,
|
|
3163
|
+
gzipSize: boundary.gzipSize,
|
|
3164
|
+
})),
|
|
3165
|
+
],
|
|
3166
|
+
);
|
|
2848
3167
|
|
|
2849
3168
|
// Phase 18.τ — fire onBundleComplete(stats) before return.
|
|
2850
3169
|
await fireOnBundleComplete(stats);
|
|
@@ -2873,14 +3192,15 @@ export function formatSize(bytes: number): string {
|
|
|
2873
3192
|
*/
|
|
2874
3193
|
export function printBundleStats(result: BundleResult): void {
|
|
2875
3194
|
console.log("\n📦 Mandu Client Bundles");
|
|
2876
|
-
console.log("=".repeat(50));
|
|
2877
|
-
|
|
2878
|
-
const partialCount = Object.keys(result.manifest.partials ?? {}).length;
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
3195
|
+
console.log("=".repeat(50));
|
|
3196
|
+
|
|
3197
|
+
const partialCount = Object.keys(result.manifest.partials ?? {}).length;
|
|
3198
|
+
const boundaryCount = Object.keys(result.manifest.boundaries ?? {}).length;
|
|
3199
|
+
if (result.outputs.length === 0 && partialCount === 0 && boundaryCount === 0) {
|
|
3200
|
+
console.log("No islands, partials, or boundaries to bundle (hydration: none or no client entry)");
|
|
3201
|
+
if (result.errors.length > 0) {
|
|
3202
|
+
console.log("\n⚠️ Errors:");
|
|
3203
|
+
for (const error of result.errors) {
|
|
2884
3204
|
console.log(` ${error}`);
|
|
2885
3205
|
}
|
|
2886
3206
|
}
|
|
@@ -2900,11 +3220,14 @@ export function printBundleStats(result: BundleResult): void {
|
|
|
2900
3220
|
` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
|
|
2901
3221
|
);
|
|
2902
3222
|
}
|
|
2903
|
-
if (partialCount > 0) {
|
|
2904
|
-
console.log(` Partials: ${partialCount}`);
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
|
|
3223
|
+
if (partialCount > 0) {
|
|
3224
|
+
console.log(` Partials: ${partialCount}`);
|
|
3225
|
+
}
|
|
3226
|
+
if (boundaryCount > 0) {
|
|
3227
|
+
console.log(` Boundaries: ${boundaryCount}`);
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
if (result.errors.length > 0) {
|
|
2908
3231
|
console.log("\n⚠️ Errors:");
|
|
2909
3232
|
for (const error of result.errors) {
|
|
2910
3233
|
console.log(` ${error}`);
|