@mandujs/core 0.54.1 → 0.54.3
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 +4 -2
- package/scripts/postinstall-lock.ts +153 -0
- package/src/a11y/run-audit.ts +15 -15
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__tests__/cold-start.test.ts +35 -7
- package/src/bundler/analyzer.ts +15 -7
- package/src/bundler/build.test.ts +13 -6
- package/src/bundler/build.ts +429 -182
- package/src/bundler/manifest-schema.ts +21 -14
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -9
- package/src/bundler/plugins/block-generated-imports.ts +13 -12
- package/src/bundler/types.ts +31 -14
- package/src/client/island.ts +79 -29
- package/src/config/validate.ts +1 -1
- package/src/deploy/inference/context.ts +82 -15
- package/src/filling/context.ts +17 -4
- package/src/guard/check.ts +9 -9
- 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/kitchen/api/file-api.ts +11 -8
- package/src/resource/__tests__/schema.test.ts +14 -9
- package/src/resource/generators/slot.ts +72 -71
- package/src/resource/schema.ts +21 -13
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -0
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -0
- package/src/runtime/__tests__/page-render-response.test.ts +103 -0
- package/src/runtime/__tests__/request-middleware.test.ts +70 -0
- package/src/runtime/devtools-adapter.ts +68 -0
- package/src/runtime/escape.ts +34 -6
- package/src/runtime/observability-lifecycle.ts +290 -0
- package/src/runtime/page-render-response.ts +106 -0
- package/src/runtime/request-middleware.ts +31 -0
- package/src/runtime/server.ts +228 -944
- package/src/runtime/ssr.ts +59 -37
- package/src/runtime/static-files.ts +289 -0
- package/src/runtime/streaming-ssr.ts +22 -13
package/src/bundler/build.ts
CHANGED
|
@@ -10,10 +10,11 @@ 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";
|
|
@@ -186,7 +187,46 @@ async function scanIslandFiles(routes: RouteSpec[], rootDir: string): Promise<Is
|
|
|
186
187
|
*
|
|
187
188
|
* @internal
|
|
188
189
|
*/
|
|
189
|
-
export const _testOnly_scanIslandFiles = scanIslandFiles;
|
|
190
|
+
export const _testOnly_scanIslandFiles = scanIslandFiles;
|
|
191
|
+
|
|
192
|
+
function normalizeClientEntryName(name: string): string {
|
|
193
|
+
return name
|
|
194
|
+
.trim()
|
|
195
|
+
.replace(/[^A-Za-z0-9_-]/g, "-")
|
|
196
|
+
.replace(/^-+|-+$/g, "") || "partial";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function scanPartialFiles(rootDir: string): Promise<PartialFileEntry[]> {
|
|
200
|
+
const entries: PartialFileEntry[] = [];
|
|
201
|
+
const seen = new Set<string>();
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const glob = new Bun.Glob("**/*.partial.{ts,tsx}");
|
|
205
|
+
for await (const rel of glob.scan({ cwd: rootDir, absolute: true })) {
|
|
206
|
+
if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue;
|
|
207
|
+
if (rel.includes(`${path.sep}.mandu${path.sep}`)) continue;
|
|
208
|
+
|
|
209
|
+
const filePath = path.resolve(rel);
|
|
210
|
+
if (seen.has(filePath)) continue;
|
|
211
|
+
seen.add(filePath);
|
|
212
|
+
|
|
213
|
+
const base = path.basename(filePath).replace(/\.partial\.tsx?$/, "");
|
|
214
|
+
entries.push({
|
|
215
|
+
name: normalizeClientEntryName(base),
|
|
216
|
+
filePath,
|
|
217
|
+
priority: HYDRATION.DEFAULT_PRIORITY,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// Bun.Glob unavailable or scan failed — a missing partial bundle should
|
|
222
|
+
// not prevent route-level islands from building.
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return entries.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** @internal test helper */
|
|
229
|
+
export const _testOnly_scanPartialFiles = scanPartialFiles;
|
|
190
230
|
|
|
191
231
|
/**
|
|
192
232
|
* Test-only accessor for the hydrated-routes filter. Mirrors the rationale
|
|
@@ -214,49 +254,42 @@ export const _testOnly_getHydratedRoutes = getHydratedRoutes;
|
|
|
214
254
|
* Paths are absolute and deduplicated. Returns `[]` when the manifest
|
|
215
255
|
* has no hydrated routes.
|
|
216
256
|
*/
|
|
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
|
-
}
|
|
257
|
+
export async function collectCompilerLintTargets(
|
|
258
|
+
manifest: RoutesManifest,
|
|
259
|
+
rootDir: string,
|
|
260
|
+
): Promise<string[]> {
|
|
261
|
+
const hydratedRoutes = getHydratedRoutes(manifest);
|
|
262
|
+
|
|
263
|
+
const out = new Set<string>();
|
|
264
|
+
|
|
265
|
+
// 1) Islands (reuses the bundler's canonical scanner).
|
|
266
|
+
if (hydratedRoutes.length > 0) {
|
|
267
|
+
const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
|
|
268
|
+
for (const entry of islandFiles) {
|
|
269
|
+
out.add(path.resolve(entry.filePath));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 2) `"use client"` page modules — the hydrated-route filter
|
|
273
|
+
// already asserted `clientModule` exists.
|
|
274
|
+
for (const route of hydratedRoutes) {
|
|
275
|
+
const rel = route.clientModule ?? route.module;
|
|
276
|
+
if (rel) out.add(path.resolve(rootDir, rel));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 3) Partials — glob the whole project once. Partials live anywhere
|
|
281
|
+
// the user chooses, they're not sibling-scoped like islands.
|
|
282
|
+
for (const entry of await scanPartialFiles(rootDir)) {
|
|
283
|
+
out.add(path.resolve(entry.filePath));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return Array.from(out).sort();
|
|
287
|
+
}
|
|
255
288
|
|
|
256
289
|
/** 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"] }> {
|
|
290
|
+
async function buildPerIslandBundle(
|
|
291
|
+
entry: IslandFileEntry, outDir: string, options: BundlerOptions
|
|
292
|
+
): Promise<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> {
|
|
260
293
|
const entryPath = path.join(outDir, `_entry_island_${entry.name}.js`);
|
|
261
294
|
const outputName = `${entry.name}.island.js`;
|
|
262
295
|
// Phase 7.1 B-1/B-4: wire Bun's native React Fast Refresh transform +
|
|
@@ -285,9 +318,66 @@ async function buildPerIslandBundle(
|
|
|
285
318
|
return { name: entry.name, js: `/.mandu/client/${outputName}`, route: entry.routeId, priority: entry.priority };
|
|
286
319
|
} catch (error) {
|
|
287
320
|
await fs.unlink(entryPath).catch(() => {});
|
|
288
|
-
throw error;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
interface PartialBundleBuild {
|
|
326
|
+
name: string;
|
|
327
|
+
js: string;
|
|
328
|
+
priority: PartialFileEntry["priority"];
|
|
329
|
+
size: number;
|
|
330
|
+
gzipSize: number;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Build a single inline partial bundle. */
|
|
334
|
+
async function buildPartialBundle(
|
|
335
|
+
entry: PartialFileEntry,
|
|
336
|
+
outDir: string,
|
|
337
|
+
options: BundlerOptions,
|
|
338
|
+
): Promise<PartialBundleBuild> {
|
|
339
|
+
const entryPath = path.join(outDir, `_entry_partial_${entry.name}.js`);
|
|
340
|
+
const outputName = `${entry.name}.partial.js`;
|
|
341
|
+
const isDev = isDevelopmentBuild(options);
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
await Bun.write(entryPath, generatePartialEntry(entry.name, entry.filePath));
|
|
345
|
+
const result = await safeBuild({
|
|
346
|
+
entrypoints: [entryPath],
|
|
347
|
+
outdir: outDir,
|
|
348
|
+
naming: outputName,
|
|
349
|
+
minify: shouldMinify(options),
|
|
350
|
+
sourcemap: options.sourcemap ? "external" : "none",
|
|
351
|
+
target: "browser",
|
|
352
|
+
...(isDev ? { reactFastRefresh: true } : {}),
|
|
353
|
+
plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
|
|
354
|
+
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
355
|
+
define: { "process.env.NODE_ENV": nodeEnvDefine(options), ...options.define },
|
|
356
|
+
});
|
|
357
|
+
await fs.unlink(entryPath).catch(() => {});
|
|
358
|
+
|
|
359
|
+
if (!result.success) {
|
|
360
|
+
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
361
|
+
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 })\`.`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const outputPath = path.join(outDir, outputName);
|
|
365
|
+
const outputFile = Bun.file(outputPath);
|
|
366
|
+
const content = await outputFile.text();
|
|
367
|
+
const gzipped = Bun.gzipSync(Buffer.from(content));
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
name: entry.name,
|
|
371
|
+
js: `/.mandu/client/${outputName}`,
|
|
372
|
+
priority: entry.priority,
|
|
373
|
+
size: outputFile.size,
|
|
374
|
+
gzipSize: gzipped.length,
|
|
375
|
+
};
|
|
376
|
+
} catch (error) {
|
|
377
|
+
await fs.unlink(entryPath).catch(() => {});
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
291
381
|
|
|
292
382
|
/**
|
|
293
383
|
* 빈 매니페스트 생성
|
|
@@ -413,12 +503,31 @@ function generateRuntimeSource(): string {
|
|
|
413
503
|
import React, { useState, useEffect, Component } from 'react';
|
|
414
504
|
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
415
505
|
|
|
416
|
-
// Hydrated roots 추적 (unmount용) - 전역 초기화
|
|
417
|
-
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
418
|
-
const hydratedRoots = window.__MANDU_ROOTS__;
|
|
419
|
-
|
|
420
|
-
// 서버 데이터
|
|
421
|
-
|
|
506
|
+
// Hydrated roots 추적 (unmount용) - 전역 초기화
|
|
507
|
+
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
508
|
+
const hydratedRoots = window.__MANDU_ROOTS__;
|
|
509
|
+
|
|
510
|
+
// 서버 데이터
|
|
511
|
+
function readManduData() {
|
|
512
|
+
if (window.__MANDU_DATA__) return window.__MANDU_DATA__;
|
|
513
|
+
|
|
514
|
+
const raw = window.__MANDU_DATA_RAW__ || document.getElementById('__MANDU_DATA__')?.textContent;
|
|
515
|
+
if (!raw) {
|
|
516
|
+
window.__MANDU_DATA__ = {};
|
|
517
|
+
return window.__MANDU_DATA__;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
window.__MANDU_DATA__ = JSON.parse(raw);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
console.warn('[Mandu] Failed to parse server data:', error);
|
|
524
|
+
window.__MANDU_DATA__ = {};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return window.__MANDU_DATA__;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const getServerData = (id) => readManduData()[id]?.serverData || {};
|
|
422
531
|
|
|
423
532
|
/**
|
|
424
533
|
* Error Boundary 컴포넌트 (Class Component)
|
|
@@ -632,12 +741,16 @@ async function loadAndHydrate(element, src) {
|
|
|
632
741
|
const island = module.default;
|
|
633
742
|
let data = getServerData(id);
|
|
634
743
|
|
|
635
|
-
// Fallback: read data-props from child element if
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
744
|
+
// Fallback: read data-props from the island root or a child element if
|
|
745
|
+
// __MANDU_DATA__ is empty. Inline partials put their serialized props on
|
|
746
|
+
// the root marker itself.
|
|
747
|
+
if (!data || Object.keys(data).length === 0) {
|
|
748
|
+
const propsEl = element.hasAttribute('data-props')
|
|
749
|
+
? element
|
|
750
|
+
: element.querySelector('[data-props]');
|
|
751
|
+
if (propsEl) {
|
|
752
|
+
try {
|
|
753
|
+
data = JSON.parse(propsEl.getAttribute('data-props'));
|
|
641
754
|
} catch (e) {
|
|
642
755
|
console.warn('[Mandu] Failed to parse data-props fallback:', e);
|
|
643
756
|
}
|
|
@@ -1311,22 +1424,63 @@ async function buildRouterRuntime(
|
|
|
1311
1424
|
* - Runtime이 dynamic import로 로드
|
|
1312
1425
|
* - 등록/초기화 코드 없음
|
|
1313
1426
|
*/
|
|
1314
|
-
function generateIslandEntry(routeId: string, clientModulePath: string): string {
|
|
1315
|
-
// Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
|
|
1316
|
-
const normalizedPath = clientModulePath.replace(/\\/g, "/");
|
|
1317
|
-
return `
|
|
1427
|
+
function generateIslandEntry(routeId: string, clientModulePath: string): string {
|
|
1428
|
+
// Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
|
|
1429
|
+
const normalizedPath = clientModulePath.replace(/\\/g, "/");
|
|
1430
|
+
return `
|
|
1318
1431
|
/**
|
|
1319
1432
|
* Mandu Island: ${routeId} (Generated)
|
|
1320
1433
|
* Pure export - no side effects
|
|
1321
1434
|
*/
|
|
1322
1435
|
import island from "${normalizedPath}";
|
|
1323
|
-
export default island;
|
|
1324
|
-
`;
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1436
|
+
export default island;
|
|
1437
|
+
`;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function generatePartialEntry(partialId: string, partialModulePath: string): string {
|
|
1441
|
+
const normalizedPath = partialModulePath.replace(/\\/g, "/");
|
|
1442
|
+
return `
|
|
1443
|
+
/**
|
|
1444
|
+
* Mandu Partial: ${partialId} (Generated)
|
|
1445
|
+
* Exports a runtime-compatible island wrapper around a compiled partial.
|
|
1446
|
+
*/
|
|
1447
|
+
import React from "react";
|
|
1448
|
+
import * as partialModule from "${normalizedPath}";
|
|
1449
|
+
|
|
1450
|
+
function findPartial(mod) {
|
|
1451
|
+
if (mod.default && mod.default.__mandu_partial === true) return mod.default;
|
|
1452
|
+
for (const value of Object.values(mod)) {
|
|
1453
|
+
if (value && value.__mandu_partial === true) return value;
|
|
1454
|
+
}
|
|
1455
|
+
throw new Error("[Mandu Partial] ${partialId} must export a value returned by partial({ component })");
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
const partial = findPartial(partialModule);
|
|
1459
|
+
const definition = partial.definition;
|
|
1460
|
+
const component = definition.component;
|
|
1461
|
+
|
|
1462
|
+
export default {
|
|
1463
|
+
__mandu_island: true,
|
|
1464
|
+
definition: {
|
|
1465
|
+
setup(serverData) {
|
|
1466
|
+
if (serverData && typeof serverData === "object" && Object.keys(serverData).length > 0) {
|
|
1467
|
+
return serverData;
|
|
1468
|
+
}
|
|
1469
|
+
return definition.initialProps || {};
|
|
1470
|
+
},
|
|
1471
|
+
render(props) {
|
|
1472
|
+
return React.createElement(component, props);
|
|
1473
|
+
},
|
|
1474
|
+
errorBoundary: definition.errorBoundary,
|
|
1475
|
+
loading: definition.loading,
|
|
1476
|
+
},
|
|
1477
|
+
};
|
|
1478
|
+
`;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Runtime 번들 빌드
|
|
1483
|
+
*/
|
|
1330
1484
|
async function buildRuntime(
|
|
1331
1485
|
outDir: string,
|
|
1332
1486
|
options: BundlerOptions
|
|
@@ -1607,9 +1761,9 @@ async function buildVendorShims(
|
|
|
1607
1761
|
}
|
|
1608
1762
|
}
|
|
1609
1763
|
|
|
1610
|
-
const buildShim = async (
|
|
1611
|
-
shim: { name: string; source: string; key: VendorShimKey; cacheId: string }
|
|
1612
|
-
): Promise<{ key: VendorShimKey; cacheId: string; outputName?: string; outputPath?: string; error?: string }> => {
|
|
1764
|
+
const buildShim = async (
|
|
1765
|
+
shim: { name: string; source: string; key: VendorShimKey; cacheId: string }
|
|
1766
|
+
): Promise<{ key: VendorShimKey; cacheId: string; outputName?: string; outputPath?: string; error?: string }> => {
|
|
1613
1767
|
const srcPath = path.join(outDir, `${shim.name}.src.js`);
|
|
1614
1768
|
const outputName = `${shim.name}.js`;
|
|
1615
1769
|
|
|
@@ -1645,14 +1799,14 @@ async function buildVendorShims(
|
|
|
1645
1799
|
|
|
1646
1800
|
await fs.unlink(srcPath).catch(() => {});
|
|
1647
1801
|
|
|
1648
|
-
if (!result.success) {
|
|
1649
|
-
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
1650
|
-
return {
|
|
1651
|
-
key: shim.key,
|
|
1652
|
-
cacheId: shim.cacheId,
|
|
1653
|
-
error: `Vendor shim '${shim.name}' build failed (source: ${srcPath}):\n${grouped}\n
|
|
1654
|
-
};
|
|
1655
|
-
}
|
|
1802
|
+
if (!result.success) {
|
|
1803
|
+
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
1804
|
+
return {
|
|
1805
|
+
key: shim.key,
|
|
1806
|
+
cacheId: shim.cacheId,
|
|
1807
|
+
error: `Vendor shim '${shim.name}' build failed (source: ${srcPath}):\n${grouped}\n ${vendorShimFailureHint(shim.name)}`,
|
|
1808
|
+
};
|
|
1809
|
+
}
|
|
1656
1810
|
|
|
1657
1811
|
return {
|
|
1658
1812
|
key: shim.key,
|
|
@@ -1662,13 +1816,13 @@ async function buildVendorShims(
|
|
|
1662
1816
|
};
|
|
1663
1817
|
} catch (error) {
|
|
1664
1818
|
await fs.unlink(srcPath).catch(() => {});
|
|
1665
|
-
return {
|
|
1666
|
-
key: shim.key,
|
|
1667
|
-
cacheId: shim.cacheId,
|
|
1668
|
-
error: `[${shim.name}] ${String(error)}`,
|
|
1669
|
-
};
|
|
1670
|
-
}
|
|
1671
|
-
};
|
|
1819
|
+
return {
|
|
1820
|
+
key: shim.key,
|
|
1821
|
+
cacheId: shim.cacheId,
|
|
1822
|
+
error: `[${shim.name}] ${String(error)}\n ${vendorShimFailureHint(shim.name)}`,
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
};
|
|
1672
1826
|
|
|
1673
1827
|
const buildResults = await Promise.all(shims.map((shim) => buildShim(shim)));
|
|
1674
1828
|
const writeEntries: VendorCacheWriteEntry[] = [];
|
|
@@ -1711,7 +1865,14 @@ async function buildVendorShims(
|
|
|
1711
1865
|
fastRefreshRuntime: results.fastRefreshRuntime,
|
|
1712
1866
|
errors,
|
|
1713
1867
|
};
|
|
1714
|
-
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
function vendorShimFailureHint(shimName: string): string {
|
|
1871
|
+
if (shimName.includes("react-refresh")) {
|
|
1872
|
+
return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
|
|
1873
|
+
}
|
|
1874
|
+
return "Hint: check the import paths and ensure the vendor package is installed.";
|
|
1875
|
+
}
|
|
1715
1876
|
|
|
1716
1877
|
/**
|
|
1717
1878
|
* 단일 Island 번들 빌드
|
|
@@ -1803,15 +1964,16 @@ async function buildIsland(
|
|
|
1803
1964
|
/**
|
|
1804
1965
|
* 번들 매니페스트 생성
|
|
1805
1966
|
*/
|
|
1806
|
-
function createBundleManifest(
|
|
1807
|
-
outputs: BundleOutput[],
|
|
1808
|
-
routes: RouteSpec[],
|
|
1809
|
-
runtimePath: string,
|
|
1810
|
-
vendorResult: VendorBuildResult,
|
|
1811
|
-
routerPath: string,
|
|
1812
|
-
env: "development" | "production",
|
|
1813
|
-
islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }
|
|
1814
|
-
|
|
1967
|
+
function createBundleManifest(
|
|
1968
|
+
outputs: BundleOutput[],
|
|
1969
|
+
routes: RouteSpec[],
|
|
1970
|
+
runtimePath: string,
|
|
1971
|
+
vendorResult: VendorBuildResult,
|
|
1972
|
+
routerPath: string,
|
|
1973
|
+
env: "development" | "production",
|
|
1974
|
+
islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
|
|
1975
|
+
partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
|
|
1976
|
+
): BundleManifest {
|
|
1815
1977
|
const bundles: BundleManifest["bundles"] = {};
|
|
1816
1978
|
|
|
1817
1979
|
for (const output of outputs) {
|
|
@@ -1827,16 +1989,27 @@ function createBundleManifest(
|
|
|
1827
1989
|
|
|
1828
1990
|
// Per-island bundles (code splitting)
|
|
1829
1991
|
let islands: BundleManifest["islands"];
|
|
1830
|
-
if (islandBundles && islandBundles.length > 0) {
|
|
1831
|
-
islands = {};
|
|
1832
|
-
for (const ib of islandBundles) {
|
|
1833
|
-
islands[ib.name] = {
|
|
1834
|
-
js: ib.js,
|
|
1992
|
+
if (islandBundles && islandBundles.length > 0) {
|
|
1993
|
+
islands = {};
|
|
1994
|
+
for (const ib of islandBundles) {
|
|
1995
|
+
islands[ib.name] = {
|
|
1996
|
+
js: ib.js,
|
|
1835
1997
|
route: ib.route,
|
|
1836
1998
|
priority: ib.priority,
|
|
1837
|
-
};
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
let partials: BundleManifest["partials"];
|
|
2004
|
+
if (partialBundles && partialBundles.length > 0) {
|
|
2005
|
+
partials = {};
|
|
2006
|
+
for (const partial of partialBundles) {
|
|
2007
|
+
partials[partial.name] = {
|
|
2008
|
+
js: partial.js,
|
|
2009
|
+
priority: partial.priority,
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
1840
2013
|
|
|
1841
2014
|
// Phase 7.1 B-2: expose Fast Refresh dev bundles so the HTML
|
|
1842
2015
|
// preamble can inject a dynamic import pointing at them. Only
|
|
@@ -1852,10 +2025,11 @@ function createBundleManifest(
|
|
|
1852
2025
|
return {
|
|
1853
2026
|
version: 1,
|
|
1854
2027
|
buildTime: new Date().toISOString(),
|
|
1855
|
-
env,
|
|
1856
|
-
bundles,
|
|
1857
|
-
...(islands ? { islands } : {}),
|
|
1858
|
-
|
|
2028
|
+
env,
|
|
2029
|
+
bundles,
|
|
2030
|
+
...(islands ? { islands } : {}),
|
|
2031
|
+
...(partials ? { partials } : {}),
|
|
2032
|
+
shared: {
|
|
1859
2033
|
runtime: runtimePath,
|
|
1860
2034
|
vendor: vendorResult.react, // primary vendor for backwards compatibility
|
|
1861
2035
|
router: routerPath, // Client-side Router
|
|
@@ -1876,28 +2050,32 @@ function createBundleManifest(
|
|
|
1876
2050
|
/**
|
|
1877
2051
|
* 번들 통계 계산
|
|
1878
2052
|
*/
|
|
1879
|
-
function calculateStats(
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
2053
|
+
function calculateStats(
|
|
2054
|
+
outputs: BundleOutput[],
|
|
2055
|
+
startTime: number,
|
|
2056
|
+
extraOutputs: Array<{ routeId: string; size: number; gzipSize: number }> = [],
|
|
2057
|
+
): BundleStats {
|
|
2058
|
+
let totalSize = 0;
|
|
2059
|
+
let totalGzipSize = 0;
|
|
2060
|
+
let largestBundle = { routeId: "", size: 0 };
|
|
2061
|
+
|
|
2062
|
+
for (const output of [...outputs, ...extraOutputs]) {
|
|
2063
|
+
totalSize += output.size;
|
|
2064
|
+
totalGzipSize += output.gzipSize;
|
|
2065
|
+
|
|
2066
|
+
if (output.size > largestBundle.size) {
|
|
2067
|
+
largestBundle = { routeId: output.routeId, size: output.size };
|
|
1890
2068
|
}
|
|
1891
2069
|
}
|
|
1892
2070
|
|
|
1893
2071
|
return {
|
|
1894
2072
|
totalSize,
|
|
1895
|
-
totalGzipSize,
|
|
1896
|
-
largestBundle,
|
|
1897
|
-
buildTime: performance.now() - startTime,
|
|
1898
|
-
bundleCount: outputs.length,
|
|
1899
|
-
};
|
|
1900
|
-
}
|
|
2073
|
+
totalGzipSize,
|
|
2074
|
+
largestBundle,
|
|
2075
|
+
buildTime: performance.now() - startTime,
|
|
2076
|
+
bundleCount: outputs.length + extraOutputs.length,
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
1901
2079
|
|
|
1902
2080
|
/**
|
|
1903
2081
|
* 클라이언트 번들 빌드
|
|
@@ -1943,8 +2121,10 @@ export async function buildClientBundles(
|
|
|
1943
2121
|
};
|
|
1944
2122
|
const env = resolveBundlerMode(options);
|
|
1945
2123
|
|
|
1946
|
-
// 1. Hydration이 필요한 라우트 필터링
|
|
1947
|
-
const hydratedRoutes = getHydratedRoutes(manifest);
|
|
2124
|
+
// 1. Hydration이 필요한 라우트 필터링
|
|
2125
|
+
const hydratedRoutes = getHydratedRoutes(manifest);
|
|
2126
|
+
const runtimeRoutes = manifest.routes.filter((route) => route.kind === "page" && needsHydration(route));
|
|
2127
|
+
const partialFiles = runtimeRoutes.length > 0 ? await scanPartialFiles(rootDir) : [];
|
|
1948
2128
|
|
|
1949
2129
|
// 2. 출력 디렉토리 생성 (항상 필요 - 매니페스트 저장용)
|
|
1950
2130
|
const outDir = resolveClientOutDir(rootDir, options.outDir);
|
|
@@ -1952,7 +2132,7 @@ export async function buildClientBundles(
|
|
|
1952
2132
|
|
|
1953
2133
|
// Hydration 라우트가 없어도 빈 매니페스트를 저장해야 함
|
|
1954
2134
|
// (이전 빌드의 stale 매니페스트 참조 방지)
|
|
1955
|
-
if (hydratedRoutes.length === 0) {
|
|
2135
|
+
if (hydratedRoutes.length === 0 && partialFiles.length === 0) {
|
|
1956
2136
|
// #185: skipFrameworkBundles 모드에서는 기존 manifest를 그대로 유지 (devtools 재빌드도 스킵)
|
|
1957
2137
|
if (options.skipFrameworkBundles) {
|
|
1958
2138
|
const manifestPath = path.join(rootDir, ".mandu/manifest.json");
|
|
@@ -2171,25 +2351,62 @@ export async function buildClientBundles(
|
|
|
2171
2351
|
};
|
|
2172
2352
|
}
|
|
2173
2353
|
}
|
|
2174
|
-
if (perIslandBundles.length > 0) {
|
|
2175
|
-
existingManifest.islands = existingManifest.islands || {};
|
|
2176
|
-
for (const ib of perIslandBundles) {
|
|
2177
|
-
existingManifest.islands[ib.name] = {
|
|
2178
|
-
js: ib.js,
|
|
2354
|
+
if (perIslandBundles.length > 0) {
|
|
2355
|
+
existingManifest.islands = existingManifest.islands || {};
|
|
2356
|
+
for (const ib of perIslandBundles) {
|
|
2357
|
+
existingManifest.islands[ib.name] = {
|
|
2358
|
+
js: ib.js,
|
|
2179
2359
|
route: ib.route,
|
|
2180
2360
|
priority: ib.priority,
|
|
2181
|
-
};
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
const partialBundles: PartialBundleBuild[] = [];
|
|
2366
|
+
if (partialFiles.length > 0) {
|
|
2367
|
+
const partialResults = await Promise.all(
|
|
2368
|
+
partialFiles.map(async (entry) => {
|
|
2369
|
+
try {
|
|
2370
|
+
return await buildPartialBundle(entry, outDir, options);
|
|
2371
|
+
} catch (error) {
|
|
2372
|
+
errors.push(`[partial:${entry.name}] ${String(error)}`);
|
|
2373
|
+
return null;
|
|
2374
|
+
}
|
|
2375
|
+
}),
|
|
2376
|
+
);
|
|
2377
|
+
for (const result of partialResults) {
|
|
2378
|
+
if (result) partialBundles.push(result);
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
if (partialFiles.length > 0 || existingManifest.partials) {
|
|
2382
|
+
existingManifest.partials = {};
|
|
2383
|
+
for (const partial of partialBundles) {
|
|
2384
|
+
existingManifest.partials[partial.name] = {
|
|
2385
|
+
js: partial.js,
|
|
2386
|
+
priority: partial.priority,
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
if (Object.keys(existingManifest.partials).length === 0) {
|
|
2390
|
+
delete existingManifest.partials;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
await fs.writeFile(
|
|
2395
|
+
path.join(rootDir, ".mandu/manifest.json"),
|
|
2396
|
+
JSON.stringify(existingManifest, null, 2),
|
|
2397
|
+
);
|
|
2398
|
+
|
|
2399
|
+
const stats = calculateStats(
|
|
2400
|
+
outputs,
|
|
2401
|
+
startTime,
|
|
2402
|
+
partialBundles.map((partial) => ({
|
|
2403
|
+
routeId: `partial:${partial.name}`,
|
|
2404
|
+
size: partial.size,
|
|
2405
|
+
gzipSize: partial.gzipSize,
|
|
2406
|
+
})),
|
|
2407
|
+
);
|
|
2408
|
+
return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
|
|
2409
|
+
}
|
|
2193
2410
|
|
|
2194
2411
|
// 3-4. Runtime, Router, Vendor, DevTools 번들 병렬 빌드 (서로 독립적)
|
|
2195
2412
|
const isDev = env === "development";
|
|
@@ -2275,11 +2492,11 @@ export async function buildClientBundles(
|
|
|
2275
2492
|
const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
|
|
2276
2493
|
const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
|
|
2277
2494
|
|
|
2278
|
-
if (islandFiles.length > 0) {
|
|
2279
|
-
const islandResults = await Promise.all(
|
|
2280
|
-
islandFiles.map(async (entry) => {
|
|
2281
|
-
try {
|
|
2282
|
-
return await buildPerIslandBundle(entry, outDir, options);
|
|
2495
|
+
if (islandFiles.length > 0) {
|
|
2496
|
+
const islandResults = await Promise.all(
|
|
2497
|
+
islandFiles.map(async (entry) => {
|
|
2498
|
+
try {
|
|
2499
|
+
return await buildPerIslandBundle(entry, outDir, options);
|
|
2283
2500
|
} catch (error) {
|
|
2284
2501
|
errors.push(`[island:${entry.name}] ${String(error)}`);
|
|
2285
2502
|
return null;
|
|
@@ -2287,20 +2504,38 @@ export async function buildClientBundles(
|
|
|
2287
2504
|
})
|
|
2288
2505
|
);
|
|
2289
2506
|
for (const result of islandResults) {
|
|
2290
|
-
if (result) islandBundles.push(result);
|
|
2291
|
-
}
|
|
2292
|
-
}
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2507
|
+
if (result) islandBundles.push(result);
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
const partialBundles: PartialBundleBuild[] = [];
|
|
2512
|
+
if (partialFiles.length > 0) {
|
|
2513
|
+
const partialResults = await Promise.all(
|
|
2514
|
+
partialFiles.map(async (entry) => {
|
|
2515
|
+
try {
|
|
2516
|
+
return await buildPartialBundle(entry, outDir, options);
|
|
2517
|
+
} catch (error) {
|
|
2518
|
+
errors.push(`[partial:${entry.name}] ${String(error)}`);
|
|
2519
|
+
return null;
|
|
2520
|
+
}
|
|
2521
|
+
}),
|
|
2522
|
+
);
|
|
2523
|
+
for (const result of partialResults) {
|
|
2524
|
+
if (result) partialBundles.push(result);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// 6. 번들 매니페스트 생성
|
|
2529
|
+
const bundleManifest = createBundleManifest(
|
|
2530
|
+
outputs,
|
|
2531
|
+
hydratedRoutes,
|
|
2532
|
+
runtimeResult.outputPath,
|
|
2533
|
+
vendorResult,
|
|
2534
|
+
routerResult.outputPath,
|
|
2535
|
+
env,
|
|
2536
|
+
islandBundles,
|
|
2537
|
+
partialBundles,
|
|
2538
|
+
);
|
|
2304
2539
|
|
|
2305
2540
|
await fs.writeFile(
|
|
2306
2541
|
path.join(rootDir, ".mandu/manifest.json"),
|
|
@@ -2308,7 +2543,15 @@ export async function buildClientBundles(
|
|
|
2308
2543
|
);
|
|
2309
2544
|
|
|
2310
2545
|
// 7. 통계 계산
|
|
2311
|
-
const stats = calculateStats(
|
|
2546
|
+
const stats = calculateStats(
|
|
2547
|
+
outputs,
|
|
2548
|
+
startTime,
|
|
2549
|
+
partialBundles.map((partial) => ({
|
|
2550
|
+
routeId: `partial:${partial.name}`,
|
|
2551
|
+
size: partial.size,
|
|
2552
|
+
gzipSize: partial.gzipSize,
|
|
2553
|
+
})),
|
|
2554
|
+
);
|
|
2312
2555
|
|
|
2313
2556
|
// Phase 18.τ — fire onBundleComplete(stats) before return.
|
|
2314
2557
|
await fireOnBundleComplete(stats);
|
|
@@ -2336,13 +2579,14 @@ export function formatSize(bytes: number): string {
|
|
|
2336
2579
|
* 번들 결과 요약 출력
|
|
2337
2580
|
*/
|
|
2338
2581
|
export function printBundleStats(result: BundleResult): void {
|
|
2339
|
-
console.log("\n📦 Mandu Client Bundles");
|
|
2340
|
-
console.log("=".repeat(50));
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2582
|
+
console.log("\n📦 Mandu Client Bundles");
|
|
2583
|
+
console.log("=".repeat(50));
|
|
2584
|
+
|
|
2585
|
+
const partialCount = Object.keys(result.manifest.partials ?? {}).length;
|
|
2586
|
+
if (result.outputs.length === 0 && partialCount === 0) {
|
|
2587
|
+
console.log("No islands or partials to bundle (hydration: none or no client entry)");
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2346
2590
|
|
|
2347
2591
|
console.log(`Environment: ${result.manifest.env}`);
|
|
2348
2592
|
console.log(`Bundles: ${result.stats.bundleCount}`);
|
|
@@ -2351,12 +2595,15 @@ export function printBundleStats(result: BundleResult): void {
|
|
|
2351
2595
|
console.log(`Build Time: ${result.stats.buildTime.toFixed(0)}ms`);
|
|
2352
2596
|
console.log("");
|
|
2353
2597
|
|
|
2354
|
-
// 각 번들 정보
|
|
2355
|
-
for (const output of result.outputs) {
|
|
2356
|
-
console.log(
|
|
2357
|
-
` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
|
|
2358
|
-
);
|
|
2359
|
-
}
|
|
2598
|
+
// 각 번들 정보
|
|
2599
|
+
for (const output of result.outputs) {
|
|
2600
|
+
console.log(
|
|
2601
|
+
` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
if (partialCount > 0) {
|
|
2605
|
+
console.log(` Partials: ${partialCount}`);
|
|
2606
|
+
}
|
|
2360
2607
|
|
|
2361
2608
|
if (result.errors.length > 0) {
|
|
2362
2609
|
console.log("\n⚠️ Errors:");
|