@mandujs/core 0.54.17 → 0.54.19
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 +94 -25
- package/src/agent/context.ts +17 -0
- package/src/agent/types.ts +32 -12
- package/src/agent/verify.ts +55 -24
- 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 +478 -9
- package/src/bundler/build.ts +424 -746
- 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/client/__tests__/props-serialization.test.ts +37 -0
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/props-serialization.ts +233 -0
- package/src/client/runtime-entry.ts +567 -0
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -404
- package/src/diagnose/__tests__/checks.test.ts +132 -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 +154 -29
- package/src/router/client-entry.ts +111 -313
- package/src/router/fs-routes.test.ts +443 -1
- package/src/router/fs-routes.ts +16 -3
- package/src/router/fs-scanner.ts +176 -57
- package/src/router/fs-types.ts +11 -2
- package/src/router/route-source-analyzer.ts +521 -0
- package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
- package/src/runtime/__tests__/page-render-response.test.ts +218 -0
- package/src/runtime/handlers.ts +50 -26
- package/src/runtime/page-render-response.ts +24 -1
- package/src/runtime/server.ts +14 -0
- 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",
|
|
@@ -501,603 +514,15 @@ function formatShimBindings(names: readonly string[], indent = " "): string {
|
|
|
501
514
|
return names.map((name) => `${indent}${name},`).join("\n");
|
|
502
515
|
}
|
|
503
516
|
|
|
504
|
-
/**
|
|
505
|
-
* Runtime 번들 소스 생성 (v0.8.0 재설계)
|
|
506
|
-
*
|
|
507
|
-
* 설계 원칙:
|
|
508
|
-
* - 글로벌 레지스트리 없음 (Island가 스스로 등록 안함)
|
|
509
|
-
* - Runtime이 Island를 dynamic import()로 로드
|
|
510
|
-
* - HTML의 data-mandu-src 속성에서 번들 URL 읽기
|
|
511
|
-
* - 실행 순서 문제 완전 해결
|
|
512
|
-
*/
|
|
513
|
-
function generateRuntimeSource(): string {
|
|
514
|
-
return `
|
|
515
|
-
/**
|
|
516
|
-
* Mandu Hydration Runtime v0.9.0 (Generated)
|
|
517
|
-
* Fresh-style dynamic import architecture
|
|
518
|
-
* + Error Boundary & Loading fallback support
|
|
519
|
-
*/
|
|
520
|
-
|
|
521
|
-
// React 정적 import (Island와 같은 인스턴스 공유)
|
|
522
|
-
import React, { useState, useEffect, Component } from 'react';
|
|
523
|
-
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
524
|
-
|
|
525
|
-
// Hydrated roots 추적 (unmount용) - 전역 초기화
|
|
526
|
-
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
527
|
-
const hydratedRoots = window.__MANDU_ROOTS__;
|
|
528
|
-
|
|
529
|
-
const TYPE_MARKERS = {
|
|
530
|
-
UNDEFINED: "\\u0000_",
|
|
531
|
-
DATE: "\\u0000D",
|
|
532
|
-
URL: "\\u0000U",
|
|
533
|
-
REGEXP: "\\u0000R",
|
|
534
|
-
MAP: "\\u0000M",
|
|
535
|
-
SET: "\\u0000S",
|
|
536
|
-
REF: "\\u0000$",
|
|
537
|
-
BIGINT: "\\u0000B",
|
|
538
|
-
SYMBOL: "\\u0000Y",
|
|
539
|
-
ERROR: "\\u0000E",
|
|
540
|
-
};
|
|
541
|
-
|
|
542
|
-
function deserializeManduProps(json) {
|
|
543
|
-
const ctx = { refs: [] };
|
|
544
|
-
return deserializeManduValue(JSON.parse(json), ctx);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function deserializeManduValue(value, ctx) {
|
|
548
|
-
if (value === null) return null;
|
|
549
|
-
if (typeof value === 'string') {
|
|
550
|
-
if (value === TYPE_MARKERS.UNDEFINED) return undefined;
|
|
551
|
-
if (value.startsWith("\\u0000\\u0000")) return value.slice(2);
|
|
552
|
-
if (value.startsWith(TYPE_MARKERS.DATE)) return new Date(value.slice(2));
|
|
553
|
-
if (value.startsWith(TYPE_MARKERS.URL)) return new URL(value.slice(2));
|
|
554
|
-
if (value.startsWith(TYPE_MARKERS.REGEXP)) {
|
|
555
|
-
const str = value.slice(2);
|
|
556
|
-
const match = str.match(/^\\/(.*)\\/([gimsuy]*)$/);
|
|
557
|
-
return match ? new RegExp(match[1], match[2]) : str;
|
|
558
|
-
}
|
|
559
|
-
if (value.startsWith(TYPE_MARKERS.BIGINT)) return BigInt(value.slice(2));
|
|
560
|
-
if (value.startsWith(TYPE_MARKERS.SYMBOL)) return Symbol(value.slice(2));
|
|
561
|
-
if (value.startsWith(TYPE_MARKERS.REF)) return ctx.refs[parseInt(value.slice(2), 10)];
|
|
562
|
-
return value;
|
|
563
|
-
}
|
|
564
|
-
if (typeof value === 'boolean' || typeof value === 'number') return value;
|
|
565
|
-
if (Array.isArray(value)) {
|
|
566
|
-
const marker = value[0];
|
|
567
|
-
if (marker === TYPE_MARKERS.ERROR) {
|
|
568
|
-
const error = new Error(value[2]);
|
|
569
|
-
error.name = value[1];
|
|
570
|
-
if (value[3]) error.stack = value[3];
|
|
571
|
-
ctx.refs.push(error);
|
|
572
|
-
return error;
|
|
573
|
-
}
|
|
574
|
-
if (marker === TYPE_MARKERS.MAP) {
|
|
575
|
-
const map = new Map();
|
|
576
|
-
ctx.refs.push(map);
|
|
577
|
-
for (let i = 1; i < value.length; i++) {
|
|
578
|
-
const entry = value[i];
|
|
579
|
-
map.set(deserializeManduValue(entry[0], ctx), deserializeManduValue(entry[1], ctx));
|
|
580
|
-
}
|
|
581
|
-
return map;
|
|
582
|
-
}
|
|
583
|
-
if (marker === TYPE_MARKERS.SET) {
|
|
584
|
-
const set = new Set();
|
|
585
|
-
ctx.refs.push(set);
|
|
586
|
-
for (let i = 1; i < value.length; i++) {
|
|
587
|
-
set.add(deserializeManduValue(value[i], ctx));
|
|
588
|
-
}
|
|
589
|
-
return set;
|
|
590
|
-
}
|
|
591
|
-
const arr = [];
|
|
592
|
-
ctx.refs.push(arr);
|
|
593
|
-
for (const item of value) arr.push(deserializeManduValue(item, ctx));
|
|
594
|
-
return arr;
|
|
595
|
-
}
|
|
596
|
-
if (typeof value === 'object') {
|
|
597
|
-
const obj = {};
|
|
598
|
-
ctx.refs.push(obj);
|
|
599
|
-
for (const [key, nested] of Object.entries(value)) {
|
|
600
|
-
obj[key] = deserializeManduValue(nested, ctx);
|
|
601
|
-
}
|
|
602
|
-
return obj;
|
|
603
|
-
}
|
|
604
|
-
return value;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// 서버 데이터
|
|
608
|
-
function readManduData() {
|
|
609
|
-
if (window.__MANDU_DATA__) return window.__MANDU_DATA__;
|
|
610
|
-
|
|
611
|
-
const raw = window.__MANDU_DATA_RAW__ || document.getElementById('__MANDU_DATA__')?.textContent;
|
|
612
|
-
if (!raw) {
|
|
613
|
-
window.__MANDU_DATA__ = {};
|
|
614
|
-
return window.__MANDU_DATA__;
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
try {
|
|
618
|
-
window.__MANDU_DATA__ = deserializeManduProps(raw);
|
|
619
|
-
} catch (error) {
|
|
620
|
-
console.warn('[Mandu] Failed to parse server data:', error);
|
|
621
|
-
window.__MANDU_DATA__ = {};
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
return window.__MANDU_DATA__;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
const getServerData = (id) => readManduData()[id]?.serverData || {};
|
|
628
|
-
|
|
629
|
-
function findPropsScript(id) {
|
|
630
|
-
const scripts = document.querySelectorAll('script[data-mandu-props]');
|
|
631
|
-
for (const script of scripts) {
|
|
632
|
-
if (script.getAttribute('data-mandu-props') === id) {
|
|
633
|
-
return script;
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
return null;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
function parsePropsScript(id) {
|
|
640
|
-
const script = findPropsScript(id);
|
|
641
|
-
if (!script || !script.textContent) return null;
|
|
642
|
-
try {
|
|
643
|
-
return deserializeManduProps(script.textContent);
|
|
644
|
-
} catch (error) {
|
|
645
|
-
console.warn('[Mandu] Failed to parse data-mandu-props for island ' + id + ':', error);
|
|
646
|
-
return null;
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function readDataProps(element) {
|
|
651
|
-
const propsEl = element.hasAttribute('data-props')
|
|
652
|
-
? element
|
|
653
|
-
: element.querySelector('[data-props]');
|
|
654
|
-
if (!propsEl) return null;
|
|
655
|
-
try {
|
|
656
|
-
return deserializeManduProps(propsEl.getAttribute('data-props') || '{}');
|
|
657
|
-
} catch (error) {
|
|
658
|
-
console.warn('[Mandu] Failed to parse data-props fallback:', error);
|
|
659
|
-
return null;
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
function getIslandProps(id, element) {
|
|
664
|
-
return parsePropsScript(id) || readDataProps(element) || getServerData(id);
|
|
665
|
-
}
|
|
666
|
-
|
|
667
517
|
/**
|
|
668
|
-
*
|
|
669
|
-
*
|
|
518
|
+
* Runtime bundle entry point.
|
|
519
|
+
*
|
|
520
|
+
* The browser runtime lives in client/runtime-entry.ts so it is typechecked
|
|
521
|
+
* with the rest of core and imports the shared props deserializer directly.
|
|
670
522
|
*/
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
super(props);
|
|
674
|
-
this.state = { hasError: false, error: null };
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
static getDerivedStateFromError(error) {
|
|
678
|
-
return { hasError: true, error };
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
componentDidCatch(error, errorInfo) {
|
|
682
|
-
console.error('[Mandu] Island error:', this.props.islandId, error, errorInfo);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
reset = () => {
|
|
686
|
-
this.setState({ hasError: false, error: null });
|
|
687
|
-
};
|
|
688
|
-
|
|
689
|
-
render() {
|
|
690
|
-
if (this.state.hasError) {
|
|
691
|
-
// 커스텀 errorBoundary가 있으면 사용
|
|
692
|
-
if (this.props.errorBoundary) {
|
|
693
|
-
return this.props.errorBoundary(this.state.error, this.reset);
|
|
694
|
-
}
|
|
695
|
-
// 기본 에러 UI
|
|
696
|
-
return React.createElement('div', {
|
|
697
|
-
className: 'mandu-island-error',
|
|
698
|
-
style: {
|
|
699
|
-
padding: '16px',
|
|
700
|
-
background: '#fef2f2',
|
|
701
|
-
border: '1px solid #fecaca',
|
|
702
|
-
borderRadius: '8px',
|
|
703
|
-
color: '#dc2626',
|
|
704
|
-
}
|
|
705
|
-
}, [
|
|
706
|
-
React.createElement('strong', { key: 'title' }, '⚠️ 오류 발생'),
|
|
707
|
-
React.createElement('p', { key: 'msg', style: { margin: '8px 0', fontSize: '14px' } },
|
|
708
|
-
this.state.error?.message || '알 수 없는 오류'
|
|
709
|
-
),
|
|
710
|
-
React.createElement('button', {
|
|
711
|
-
key: 'btn',
|
|
712
|
-
onClick: this.reset,
|
|
713
|
-
style: {
|
|
714
|
-
padding: '6px 12px',
|
|
715
|
-
background: '#dc2626',
|
|
716
|
-
color: 'white',
|
|
717
|
-
border: 'none',
|
|
718
|
-
borderRadius: '4px',
|
|
719
|
-
cursor: 'pointer',
|
|
720
|
-
}
|
|
721
|
-
}, '다시 시도')
|
|
722
|
-
]);
|
|
723
|
-
}
|
|
724
|
-
return this.props.children;
|
|
725
|
-
}
|
|
523
|
+
function getRuntimeEntryPath(): string {
|
|
524
|
+
return path.resolve(import.meta.dir, "..", "client", "runtime-entry.ts");
|
|
726
525
|
}
|
|
727
|
-
|
|
728
|
-
/**
|
|
729
|
-
* Loading Wrapper 컴포넌트
|
|
730
|
-
* Island의 loading 옵션을 지원
|
|
731
|
-
*/
|
|
732
|
-
function IslandLoadingWrapper({ children, loading, isReady }) {
|
|
733
|
-
if (!isReady && loading) {
|
|
734
|
-
return loading();
|
|
735
|
-
}
|
|
736
|
-
return children;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
function resolveHydrationTarget(element) {
|
|
740
|
-
if (!(element instanceof HTMLElement)) {
|
|
741
|
-
return element;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
if (getComputedStyle(element).display !== 'contents') {
|
|
745
|
-
return element;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
const queue = Array.from(element.children);
|
|
749
|
-
while (queue.length > 0) {
|
|
750
|
-
const candidate = queue.shift();
|
|
751
|
-
if (candidate instanceof HTMLElement) {
|
|
752
|
-
return candidate;
|
|
753
|
-
}
|
|
754
|
-
if (candidate) {
|
|
755
|
-
queue.push(...candidate.children);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
return element.parentElement || element;
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
function hasHydratableMarkup(element) {
|
|
763
|
-
for (const node of element.childNodes) {
|
|
764
|
-
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
765
|
-
return true;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
if (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim() !== '') {
|
|
769
|
-
return true;
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
return false;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
function shouldHydrateCompiledIsland(element) {
|
|
777
|
-
return (
|
|
778
|
-
element.getAttribute('data-mandu-loading') !== 'true' &&
|
|
779
|
-
hasHydratableMarkup(element)
|
|
780
|
-
);
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
function createHydrationOptions(element, id, mode) {
|
|
784
|
-
return {
|
|
785
|
-
onRecoverableError(error) {
|
|
786
|
-
element.setAttribute('data-mandu-recoverable-error', 'true');
|
|
787
|
-
console.warn('[Mandu] Recoverable hydration error:', id, mode, error);
|
|
788
|
-
element.dispatchEvent(new CustomEvent('mandu:recoverable-hydration-error', {
|
|
789
|
-
bubbles: true,
|
|
790
|
-
detail: {
|
|
791
|
-
id,
|
|
792
|
-
mode,
|
|
793
|
-
error: error instanceof Error ? error.message : String(error),
|
|
794
|
-
},
|
|
795
|
-
}));
|
|
796
|
-
},
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
/**
|
|
801
|
-
* Hydration 스케줄러
|
|
802
|
-
*/
|
|
803
|
-
function priorityToHydrateStrategy(priority) {
|
|
804
|
-
return priority === 'immediate' ? 'load' : priority;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
function scheduleHydration(element, src, strategy) {
|
|
808
|
-
if (!strategy) strategy = 'load';
|
|
809
|
-
if (strategy === 'immediate') strategy = 'load';
|
|
810
|
-
|
|
811
|
-
if (strategy.startsWith('media(') && strategy.endsWith(')')) {
|
|
812
|
-
const query = strategy.slice('media('.length, -1).trim();
|
|
813
|
-
if (!query || !window.matchMedia) {
|
|
814
|
-
loadAndHydrate(element, src);
|
|
815
|
-
return;
|
|
816
|
-
}
|
|
817
|
-
const mql = window.matchMedia(query);
|
|
818
|
-
if (mql.matches) {
|
|
819
|
-
loadAndHydrate(element, src);
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
const onChange = (event) => {
|
|
823
|
-
if (!event.matches) return;
|
|
824
|
-
if (mql.removeEventListener) {
|
|
825
|
-
mql.removeEventListener('change', onChange);
|
|
826
|
-
} else if (mql.removeListener) {
|
|
827
|
-
mql.removeListener(onChange);
|
|
828
|
-
}
|
|
829
|
-
loadAndHydrate(element, src);
|
|
830
|
-
};
|
|
831
|
-
if (mql.addEventListener) {
|
|
832
|
-
mql.addEventListener('change', onChange);
|
|
833
|
-
} else if (mql.addListener) {
|
|
834
|
-
mql.addListener(onChange);
|
|
835
|
-
}
|
|
836
|
-
return;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
switch (strategy) {
|
|
840
|
-
case 'load':
|
|
841
|
-
case 'immediate':
|
|
842
|
-
loadAndHydrate(element, src);
|
|
843
|
-
break;
|
|
844
|
-
|
|
845
|
-
case 'visible':
|
|
846
|
-
if ('IntersectionObserver' in window) {
|
|
847
|
-
const observer = new IntersectionObserver((entries) => {
|
|
848
|
-
if (entries[0].isIntersecting) {
|
|
849
|
-
observer.disconnect();
|
|
850
|
-
loadAndHydrate(element, src);
|
|
851
|
-
}
|
|
852
|
-
}, { rootMargin: '200px' });
|
|
853
|
-
const target = resolveHydrationTarget(element);
|
|
854
|
-
observer.observe(target);
|
|
855
|
-
} else {
|
|
856
|
-
loadAndHydrate(element, src);
|
|
857
|
-
}
|
|
858
|
-
break;
|
|
859
|
-
|
|
860
|
-
case 'idle':
|
|
861
|
-
if ('requestIdleCallback' in window) {
|
|
862
|
-
requestIdleCallback(() => loadAndHydrate(element, src));
|
|
863
|
-
} else {
|
|
864
|
-
setTimeout(() => loadAndHydrate(element, src), 200);
|
|
865
|
-
}
|
|
866
|
-
break;
|
|
867
|
-
|
|
868
|
-
case 'interaction': {
|
|
869
|
-
const target = resolveHydrationTarget(element);
|
|
870
|
-
const hydrate = () => {
|
|
871
|
-
target.removeEventListener('touchstart', hydrate);
|
|
872
|
-
target.removeEventListener('click', hydrate);
|
|
873
|
-
target.removeEventListener('keydown', hydrate);
|
|
874
|
-
loadAndHydrate(element, src);
|
|
875
|
-
};
|
|
876
|
-
target.addEventListener('touchstart', hydrate, { once: true, passive: true });
|
|
877
|
-
target.addEventListener('click', hydrate, { once: true });
|
|
878
|
-
target.addEventListener('keydown', hydrate, { once: true });
|
|
879
|
-
break;
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
default:
|
|
883
|
-
console.warn('[Mandu] Unknown hydrate strategy "' + strategy + '", falling back to load.');
|
|
884
|
-
loadAndHydrate(element, src);
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
/**
|
|
889
|
-
* Island 로드 및 hydrate (핵심 함수)
|
|
890
|
-
* Dynamic import로 Island 모듈 로드 후 렌더링
|
|
891
|
-
* Error Boundary 및 Loading fallback 지원
|
|
892
|
-
*/
|
|
893
|
-
async function loadAndHydrate(element, src) {
|
|
894
|
-
const id = element.getAttribute('data-mandu-island');
|
|
895
|
-
if (!id) {
|
|
896
|
-
return;
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
if (
|
|
900
|
-
hydratedRoots.has(id) ||
|
|
901
|
-
element.hasAttribute('data-mandu-hydrated') ||
|
|
902
|
-
element.getAttribute('data-mandu-hydrating') === 'true'
|
|
903
|
-
) {
|
|
904
|
-
return;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
element.setAttribute('data-mandu-hydrating', 'true');
|
|
908
|
-
|
|
909
|
-
try {
|
|
910
|
-
// Dynamic import - 이 시점에 Island 모듈 로드
|
|
911
|
-
const module = await import(src);
|
|
912
|
-
const island = module.default;
|
|
913
|
-
const data = getIslandProps(id, element);
|
|
914
|
-
|
|
915
|
-
// Mandu Island (preferred)
|
|
916
|
-
if (island && island.__mandu_island === true) {
|
|
917
|
-
const { definition } = island;
|
|
918
|
-
const shouldHydrate = shouldHydrateCompiledIsland(element);
|
|
919
|
-
const renderMode = shouldHydrate ? 'hydrate' : 'mount';
|
|
920
|
-
|
|
921
|
-
// Island 컴포넌트 (Error Boundary + Loading 지원)
|
|
922
|
-
function IslandComponent({ initialReady }) {
|
|
923
|
-
const [isReady, setIsReady] = useState(initialReady);
|
|
924
|
-
|
|
925
|
-
useEffect(() => {
|
|
926
|
-
setIsReady(true);
|
|
927
|
-
}, []);
|
|
928
|
-
|
|
929
|
-
// setup 호출 및 render
|
|
930
|
-
const setupResult = definition.setup(data);
|
|
931
|
-
const content = definition.render(setupResult);
|
|
932
|
-
|
|
933
|
-
// Loading wrapper 적용
|
|
934
|
-
const wrappedContent = definition.loading
|
|
935
|
-
? React.createElement(IslandLoadingWrapper, {
|
|
936
|
-
loading: definition.loading,
|
|
937
|
-
isReady,
|
|
938
|
-
}, content)
|
|
939
|
-
: content;
|
|
940
|
-
|
|
941
|
-
// Error Boundary 적용
|
|
942
|
-
return React.createElement(IslandErrorBoundary, {
|
|
943
|
-
islandId: id,
|
|
944
|
-
errorBoundary: definition.errorBoundary,
|
|
945
|
-
}, wrappedContent);
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
const root = shouldHydrate
|
|
949
|
-
? hydrateRoot(
|
|
950
|
-
element,
|
|
951
|
-
React.createElement(IslandComponent, { initialReady: true }),
|
|
952
|
-
createHydrationOptions(element, id, renderMode)
|
|
953
|
-
)
|
|
954
|
-
: createRoot(element);
|
|
955
|
-
|
|
956
|
-
if (!shouldHydrate) {
|
|
957
|
-
root.render(React.createElement(IslandComponent, { initialReady: false }));
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
hydratedRoots.set(id, root);
|
|
961
|
-
|
|
962
|
-
// 완료 표시
|
|
963
|
-
element.setAttribute('data-mandu-render-mode', renderMode);
|
|
964
|
-
element.setAttribute('data-mandu-hydrated', 'true');
|
|
965
|
-
|
|
966
|
-
// 성능 마커
|
|
967
|
-
if (performance.mark) {
|
|
968
|
-
performance.mark('mandu-hydrated-' + id);
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
// 이벤트 발송
|
|
972
|
-
element.dispatchEvent(new CustomEvent('mandu:hydrated', {
|
|
973
|
-
bubbles: true,
|
|
974
|
-
detail: { id, data, mode: renderMode }
|
|
975
|
-
}));
|
|
976
|
-
|
|
977
|
-
// Kitchen DevTools에 island 등록
|
|
978
|
-
if (window.__MANDU_DEVTOOLS_HOOK__) {
|
|
979
|
-
const hydrateTime = performance.now ? performance.now() : Date.now();
|
|
980
|
-
window.__MANDU_DEVTOOLS_HOOK__.emit({
|
|
981
|
-
type: 'island:register',
|
|
982
|
-
timestamp: Date.now(),
|
|
983
|
-
data: {
|
|
984
|
-
id,
|
|
985
|
-
name: id,
|
|
986
|
-
strategy: element.getAttribute('data-mandu-priority') || 'visible',
|
|
987
|
-
status: 'hydrated',
|
|
988
|
-
renderMode,
|
|
989
|
-
hydrateStartTime: hydrateTime - 10,
|
|
990
|
-
hydrateEndTime: hydrateTime,
|
|
991
|
-
propsSize: JSON.stringify(data).length,
|
|
992
|
-
},
|
|
993
|
-
});
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
console.log('[Mandu] Hydrated:', id, '(' + renderMode + ')');
|
|
997
|
-
}
|
|
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 renderMode = 'hydrate';
|
|
1002
|
-
|
|
1003
|
-
const root = typeof island === 'function'
|
|
1004
|
-
? hydrateRoot(
|
|
1005
|
-
element,
|
|
1006
|
-
React.createElement(island, data),
|
|
1007
|
-
createHydrationOptions(element, id, renderMode)
|
|
1008
|
-
)
|
|
1009
|
-
: hydrateRoot(element, island, createHydrationOptions(element, id, renderMode));
|
|
1010
|
-
|
|
1011
|
-
hydratedRoots.set(id, root);
|
|
1012
|
-
|
|
1013
|
-
// 완료 표시
|
|
1014
|
-
element.setAttribute('data-mandu-render-mode', renderMode);
|
|
1015
|
-
element.setAttribute('data-mandu-hydrated', 'true');
|
|
1016
|
-
|
|
1017
|
-
// 성능 마커
|
|
1018
|
-
if (performance.mark) {
|
|
1019
|
-
performance.mark('mandu-hydrated-' + id);
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
// 이벤트 발송
|
|
1023
|
-
element.dispatchEvent(new CustomEvent('mandu:hydrated', {
|
|
1024
|
-
bubbles: true,
|
|
1025
|
-
detail: { id, data, mode: renderMode }
|
|
1026
|
-
}));
|
|
1027
|
-
|
|
1028
|
-
console.log('[Mandu] Plain component hydrated:', id, '(' + renderMode + ')');
|
|
1029
|
-
}
|
|
1030
|
-
else {
|
|
1031
|
-
throw new Error('[Mandu] Invalid module: expected Mandu island or React component: ' + id);
|
|
1032
|
-
}
|
|
1033
|
-
} catch (error) {
|
|
1034
|
-
console.error('[Mandu] Hydration failed for', id, error);
|
|
1035
|
-
element.setAttribute('data-mandu-error', 'true');
|
|
1036
|
-
|
|
1037
|
-
// 에러 이벤트 발송
|
|
1038
|
-
element.dispatchEvent(new CustomEvent('mandu:hydration-error', {
|
|
1039
|
-
bubbles: true,
|
|
1040
|
-
detail: { id, error: error.message }
|
|
1041
|
-
}));
|
|
1042
|
-
} finally {
|
|
1043
|
-
element.removeAttribute('data-mandu-hydrating');
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
/**
|
|
1048
|
-
* 모든 Island hydrate 시작
|
|
1049
|
-
*/
|
|
1050
|
-
function hydrateIslands() {
|
|
1051
|
-
const islands = document.querySelectorAll('[data-mandu-island]');
|
|
1052
|
-
const seenIds = new Set();
|
|
1053
|
-
|
|
1054
|
-
for (const el of islands) {
|
|
1055
|
-
const id = el.getAttribute('data-mandu-island');
|
|
1056
|
-
const src = el.getAttribute('data-mandu-src');
|
|
1057
|
-
const priority = el.getAttribute('data-mandu-priority') || '${HYDRATION.DEFAULT_PRIORITY}';
|
|
1058
|
-
const hydrateStrategy = el.getAttribute('data-hydrate') || priorityToHydrateStrategy(priority);
|
|
1059
|
-
|
|
1060
|
-
if (!id || !src) {
|
|
1061
|
-
console.warn('[Mandu] Island missing id or src:', el);
|
|
1062
|
-
continue;
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
// 중복 ID 경고
|
|
1066
|
-
if (seenIds.has(id)) {
|
|
1067
|
-
console.warn('[Mandu] Duplicate island id detected:', id, '- skipping');
|
|
1068
|
-
continue;
|
|
1069
|
-
}
|
|
1070
|
-
seenIds.add(id);
|
|
1071
|
-
|
|
1072
|
-
scheduleHydration(el, src, hydrateStrategy);
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
/**
|
|
1077
|
-
* Island unmount
|
|
1078
|
-
*/
|
|
1079
|
-
function unmountIsland(id) {
|
|
1080
|
-
const root = hydratedRoots.get(id);
|
|
1081
|
-
if (root) {
|
|
1082
|
-
root.unmount();
|
|
1083
|
-
hydratedRoots.delete(id);
|
|
1084
|
-
return true;
|
|
1085
|
-
}
|
|
1086
|
-
return false;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// 자동 초기화
|
|
1090
|
-
if (document.readyState === 'loading') {
|
|
1091
|
-
document.addEventListener('DOMContentLoaded', hydrateIslands);
|
|
1092
|
-
} else {
|
|
1093
|
-
hydrateIslands();
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// Export for external use
|
|
1097
|
-
export { hydrateIslands, unmountIsland, hydratedRoots };
|
|
1098
|
-
`;
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
526
|
/**
|
|
1102
527
|
* React shim 소스 생성 (import map용)
|
|
1103
528
|
* 주의: export *는 Bun bundler에서 제대로 작동하지 않으므로 명시적 export 필요
|
|
@@ -1589,6 +1014,9 @@ function generateIslandEntry(routeId: string, clientModulePath: string, exportNa
|
|
|
1589
1014
|
// Windows 경로의 백슬래시를 슬래시로 변환 (JS escape 문제 방지)
|
|
1590
1015
|
const normalizedPath = clientModulePath.replace(/\\/g, "/");
|
|
1591
1016
|
const normalizedExportName = exportName && exportName !== "default" ? exportName : undefined;
|
|
1017
|
+
const namedExport = normalizedExportName && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(normalizedExportName)
|
|
1018
|
+
? `export const ${normalizedExportName} = exportedIsland;`
|
|
1019
|
+
: "";
|
|
1592
1020
|
const candidates = [
|
|
1593
1021
|
normalizedExportName,
|
|
1594
1022
|
inferClientExportNameFromPath(clientModulePath),
|
|
@@ -1608,8 +1036,10 @@ import React from "react";
|
|
|
1608
1036
|
import * as islandModule from ${importSpecifier};
|
|
1609
1037
|
|
|
1610
1038
|
const candidateExportNames = ${JSON.stringify(candidates)};
|
|
1039
|
+
const explicitExportName = ${JSON.stringify(normalizedExportName ?? null)};
|
|
1611
1040
|
|
|
1612
1041
|
function resolveIslandExport(mod) {
|
|
1042
|
+
if (explicitExportName && mod[explicitExportName]) return mod[explicitExportName];
|
|
1613
1043
|
if (mod.default) return mod.default;
|
|
1614
1044
|
for (const name of candidateExportNames) {
|
|
1615
1045
|
if (mod[name]) return mod[name];
|
|
@@ -1630,6 +1060,7 @@ const exportedIsland = island && island.__mandu_island === true
|
|
|
1630
1060
|
};
|
|
1631
1061
|
|
|
1632
1062
|
export default exportedIsland;
|
|
1063
|
+
${namedExport}
|
|
1633
1064
|
`;
|
|
1634
1065
|
}
|
|
1635
1066
|
|
|
@@ -1692,21 +1123,17 @@ export default {
|
|
|
1692
1123
|
/**
|
|
1693
1124
|
* Runtime 번들 빌드
|
|
1694
1125
|
*/
|
|
1695
|
-
async function buildRuntime(
|
|
1696
|
-
outDir: string,
|
|
1697
|
-
options: BundlerOptions
|
|
1698
|
-
): Promise<{ success: boolean; outputPath: string; errors: string[] }> {
|
|
1699
|
-
const runtimePath =
|
|
1700
|
-
const outputName = "_runtime.js";
|
|
1701
|
-
|
|
1702
|
-
try {
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
// 빌드
|
|
1707
|
-
const result = await safeBuild({
|
|
1708
|
-
entrypoints: [runtimePath],
|
|
1709
|
-
outdir: outDir,
|
|
1126
|
+
async function buildRuntime(
|
|
1127
|
+
outDir: string,
|
|
1128
|
+
options: BundlerOptions
|
|
1129
|
+
): Promise<{ success: boolean; outputPath: string; errors: string[] }> {
|
|
1130
|
+
const runtimePath = getRuntimeEntryPath();
|
|
1131
|
+
const outputName = "_runtime.js";
|
|
1132
|
+
|
|
1133
|
+
try {
|
|
1134
|
+
const result = await safeBuild({
|
|
1135
|
+
entrypoints: [runtimePath],
|
|
1136
|
+
outdir: outDir,
|
|
1710
1137
|
naming: outputName,
|
|
1711
1138
|
minify: shouldMinify(options),
|
|
1712
1139
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
@@ -1717,29 +1144,24 @@ async function buildRuntime(
|
|
|
1717
1144
|
"process.env.NODE_ENV": nodeEnvDefine(options),
|
|
1718
1145
|
...options.define,
|
|
1719
1146
|
},
|
|
1720
|
-
});
|
|
1721
|
-
|
|
1722
|
-
if (!result.success) {
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
errors: [],
|
|
1739
|
-
};
|
|
1740
|
-
} catch (error: unknown) {
|
|
1741
|
-
// 예외 발생 시에도 디버깅을 위해 소스 파일을 남겨둠
|
|
1742
|
-
const extra: string[] = [];
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
if (!result.success) {
|
|
1150
|
+
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
1151
|
+
return {
|
|
1152
|
+
success: false,
|
|
1153
|
+
outputPath: "",
|
|
1154
|
+
errors: [`Runtime bundle build failed (source: ${runtimePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types.`],
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
return {
|
|
1159
|
+
success: true,
|
|
1160
|
+
outputPath: `/.mandu/client/${outputName}`,
|
|
1161
|
+
errors: [],
|
|
1162
|
+
};
|
|
1163
|
+
} catch (error: unknown) {
|
|
1164
|
+
const extra: string[] = [];
|
|
1743
1165
|
const errObj = error as Record<string, unknown> | null;
|
|
1744
1166
|
if (errObj && Array.isArray(errObj.errors)) {
|
|
1745
1167
|
extra.push(...errObj.errors.map((e: unknown) => String((e as Record<string, unknown>)?.message || e)));
|
|
@@ -2095,7 +1517,7 @@ function routeIdToAssetStem(routeId: string): string {
|
|
|
2095
1517
|
/**
|
|
2096
1518
|
* 단일 Island 번들 빌드
|
|
2097
1519
|
*/
|
|
2098
|
-
async function buildIsland(
|
|
1520
|
+
async function buildIsland(
|
|
2099
1521
|
route: RouteSpec,
|
|
2100
1522
|
rootDir: string,
|
|
2101
1523
|
outDir: string,
|
|
@@ -2178,8 +1600,171 @@ async function buildIsland(
|
|
|
2178
1600
|
} catch (error) {
|
|
2179
1601
|
await fs.unlink(entryPath).catch(() => {});
|
|
2180
1602
|
throw error;
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
async function buildBoundaryBundle(
|
|
1607
|
+
boundary: RouteClientBoundary,
|
|
1608
|
+
rootDir: string,
|
|
1609
|
+
outDir: string,
|
|
1610
|
+
options: BundlerOptions,
|
|
1611
|
+
): Promise<BoundaryBundleBuild> {
|
|
1612
|
+
const clientModulePath = path.join(rootDir, boundary.module);
|
|
1613
|
+
const assetStem = routeIdToAssetStem(boundary.id);
|
|
1614
|
+
const entryStem = `_entry_boundary_${assetStem}`;
|
|
1615
|
+
const entryPath = path.join(outDir, `${entryStem}.js`);
|
|
1616
|
+
const outputName = `${assetStem}.boundary.js`;
|
|
1617
|
+
const isDev = isDevelopmentBuild(options);
|
|
1618
|
+
|
|
1619
|
+
try {
|
|
1620
|
+
await Bun.write(entryPath, generateIslandEntry(boundary.id, clientModulePath, boundary.exportName));
|
|
1621
|
+
|
|
1622
|
+
const result = await safeBuild({
|
|
1623
|
+
entrypoints: [entryPath],
|
|
1624
|
+
outdir: outDir,
|
|
1625
|
+
naming: options.splitting ? "[name]-[hash].js" : outputName,
|
|
1626
|
+
minify: shouldMinify(options),
|
|
1627
|
+
sourcemap: options.sourcemap ? "external" : "none",
|
|
1628
|
+
target: "browser",
|
|
1629
|
+
splitting: shouldSplitChunks(options),
|
|
1630
|
+
...(isDev ? { reactFastRefresh: true } : {}),
|
|
1631
|
+
plugins: [...manduClientPlugins(options), ...(isDev ? [fastRefreshPlugin()] : [])],
|
|
1632
|
+
external: ["react", "react-dom", "react-dom/client", ...(options.external || [])],
|
|
1633
|
+
define: {
|
|
1634
|
+
"process.env.NODE_ENV": nodeEnvDefine(options),
|
|
1635
|
+
...options.define,
|
|
1636
|
+
},
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
await fs.unlink(entryPath).catch(() => {});
|
|
1640
|
+
|
|
1641
|
+
if (!result.success) {
|
|
1642
|
+
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
1643
|
+
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.`);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
let actualOutputPath: string;
|
|
1647
|
+
let actualOutputName: string;
|
|
1648
|
+
if (options.splitting && result.outputs.length > 0) {
|
|
1649
|
+
const entryOutput = result.outputs.find(
|
|
1650
|
+
(o) => o.kind === "entry-point" || o.path.includes(entryStem) || o.path.includes(assetStem),
|
|
1651
|
+
);
|
|
1652
|
+
actualOutputPath = entryOutput?.path ?? result.outputs[0].path;
|
|
1653
|
+
actualOutputName = path.basename(actualOutputPath);
|
|
1654
|
+
} else {
|
|
1655
|
+
actualOutputPath = path.join(outDir, outputName);
|
|
1656
|
+
actualOutputName = outputName;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const outputFile = Bun.file(actualOutputPath);
|
|
1660
|
+
const content = await sanitizeGeneratedClientBundle(actualOutputPath, isDev);
|
|
1661
|
+
const gzipped = Bun.gzipSync(Buffer.from(content));
|
|
1662
|
+
const priority = boundaryPriorityToLegacyPriority(boundary.hydrate);
|
|
1663
|
+
|
|
1664
|
+
return {
|
|
1665
|
+
id: boundary.id,
|
|
1666
|
+
route: boundary.routeId,
|
|
1667
|
+
js: `/.mandu/client/${actualOutputName}`,
|
|
1668
|
+
module: boundary.module,
|
|
1669
|
+
exportName: boundary.exportName,
|
|
1670
|
+
priority,
|
|
1671
|
+
hydrate: boundary.hydrate,
|
|
1672
|
+
size: outputFile.size,
|
|
1673
|
+
gzipSize: gzipped.length,
|
|
1674
|
+
};
|
|
1675
|
+
} finally {
|
|
1676
|
+
await fs.unlink(entryPath).catch(() => {});
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
async function buildBoundaryBundlesForRecords(
|
|
1681
|
+
boundaries: RouteClientBoundary[],
|
|
1682
|
+
rootDir: string,
|
|
1683
|
+
outDir: string,
|
|
1684
|
+
options: BundlerOptions,
|
|
1685
|
+
errors: string[],
|
|
1686
|
+
): Promise<BoundaryBundleBuild[]> {
|
|
1687
|
+
if (boundaries.length === 0) return [];
|
|
1688
|
+
if (pushDuplicateBoundaryIdErrors(boundaries, errors)) return [];
|
|
1689
|
+
|
|
1690
|
+
const results = await Promise.all(
|
|
1691
|
+
boundaries.map(async (boundary) => {
|
|
1692
|
+
try {
|
|
1693
|
+
return await buildBoundaryBundle(boundary, rootDir, outDir, options);
|
|
1694
|
+
} catch (error) {
|
|
1695
|
+
errors.push(`[boundary:${boundary.id}] ${String(error)}`);
|
|
1696
|
+
return null;
|
|
1697
|
+
}
|
|
1698
|
+
}),
|
|
1699
|
+
);
|
|
1700
|
+
|
|
1701
|
+
return results.filter((result): result is BoundaryBundleBuild => result !== null);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function pushDuplicateBoundaryIdErrors(boundaries: RouteClientBoundary[], errors: string[]): boolean {
|
|
1705
|
+
const firstById = new Map<string, RouteClientBoundary>();
|
|
1706
|
+
let hasDuplicate = false;
|
|
1707
|
+
|
|
1708
|
+
for (const boundary of boundaries) {
|
|
1709
|
+
const first = firstById.get(boundary.id);
|
|
1710
|
+
if (!first) {
|
|
1711
|
+
firstById.set(boundary.id, boundary);
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
hasDuplicate = true;
|
|
1716
|
+
errors.push(
|
|
1717
|
+
`[boundary:${boundary.id}] MANDU_BOUNDARY_DUPLICATE_ID Duplicate client boundary id. ` +
|
|
1718
|
+
`First route="${first.routeId}" source="${first.source.file}", duplicate route="${boundary.routeId}" source="${boundary.source.file}". ` +
|
|
1719
|
+
"Boundary ids must be unique before bundle manifest generation.",
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
return hasDuplicate;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function mergeBoundaryBundlesIntoManifest(
|
|
1727
|
+
manifest: BundleManifest,
|
|
1728
|
+
routeIds: Iterable<string>,
|
|
1729
|
+
boundaryBundles: BoundaryBundleBuild[],
|
|
1730
|
+
): void {
|
|
1731
|
+
const rebuiltRouteIds = new Set(routeIds);
|
|
1732
|
+
if (rebuiltRouteIds.size === 0 && boundaryBundles.length === 0) return;
|
|
1733
|
+
|
|
1734
|
+
if (manifest.boundaries) {
|
|
1735
|
+
for (const [id, boundary] of Object.entries(manifest.boundaries)) {
|
|
1736
|
+
if (rebuiltRouteIds.has(boundary.route)) {
|
|
1737
|
+
delete manifest.boundaries[id];
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (boundaryBundles.length > 0) {
|
|
1743
|
+
manifest.boundaries = manifest.boundaries || {};
|
|
1744
|
+
for (const boundary of boundaryBundles) {
|
|
1745
|
+
manifest.boundaries[boundary.id] = {
|
|
1746
|
+
route: boundary.route,
|
|
1747
|
+
js: boundary.js,
|
|
1748
|
+
module: boundary.module,
|
|
1749
|
+
exportName: boundary.exportName,
|
|
1750
|
+
priority: boundary.priority,
|
|
1751
|
+
hydrate: boundary.hydrate,
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
if (manifest.boundaries && Object.keys(manifest.boundaries).length === 0) {
|
|
1757
|
+
delete manifest.boundaries;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function boundaryPriorityToLegacyPriority(value: string): BoundaryBundleBuild["priority"] {
|
|
1762
|
+
if (value === "load") return "immediate";
|
|
1763
|
+
if (value === "immediate" || value === "visible" || value === "idle" || value === "interaction") {
|
|
1764
|
+
return value;
|
|
1765
|
+
}
|
|
1766
|
+
return "visible";
|
|
1767
|
+
}
|
|
2183
1768
|
|
|
2184
1769
|
async function sanitizeGeneratedClientBundle(outputPath: string, isDev: boolean): Promise<string> {
|
|
2185
1770
|
const source = await Bun.file(outputPath).text();
|
|
@@ -2232,10 +1817,11 @@ function createBundleManifest(
|
|
|
2232
1817
|
runtimePath: string,
|
|
2233
1818
|
vendorResult: VendorBuildResult,
|
|
2234
1819
|
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
|
-
|
|
1820
|
+
env: "development" | "production",
|
|
1821
|
+
islandBundles?: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }>,
|
|
1822
|
+
partialBundles?: Array<{ name: string; js: string; priority: PartialFileEntry["priority"] }>,
|
|
1823
|
+
boundaryBundles?: BoundaryBundleBuild[],
|
|
1824
|
+
): BundleManifest {
|
|
2239
1825
|
const bundles: BundleManifest["bundles"] = {};
|
|
2240
1826
|
|
|
2241
1827
|
for (const output of outputs) {
|
|
@@ -2262,7 +1848,7 @@ function createBundleManifest(
|
|
|
2262
1848
|
}
|
|
2263
1849
|
}
|
|
2264
1850
|
|
|
2265
|
-
let partials: BundleManifest["partials"];
|
|
1851
|
+
let partials: BundleManifest["partials"];
|
|
2266
1852
|
if (partialBundles && partialBundles.length > 0) {
|
|
2267
1853
|
partials = {};
|
|
2268
1854
|
for (const partial of partialBundles) {
|
|
@@ -2271,7 +1857,22 @@ function createBundleManifest(
|
|
|
2271
1857
|
priority: partial.priority,
|
|
2272
1858
|
};
|
|
2273
1859
|
}
|
|
2274
|
-
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
let boundaries: BundleManifest["boundaries"];
|
|
1863
|
+
if (boundaryBundles && boundaryBundles.length > 0) {
|
|
1864
|
+
boundaries = {};
|
|
1865
|
+
for (const boundary of boundaryBundles) {
|
|
1866
|
+
boundaries[boundary.id] = {
|
|
1867
|
+
route: boundary.route,
|
|
1868
|
+
js: boundary.js,
|
|
1869
|
+
module: boundary.module,
|
|
1870
|
+
exportName: boundary.exportName,
|
|
1871
|
+
priority: boundary.priority,
|
|
1872
|
+
hydrate: boundary.hydrate,
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
2275
1876
|
|
|
2276
1877
|
// Phase 7.1 B-2: expose Fast Refresh dev bundles so the HTML
|
|
2277
1878
|
// preamble can inject a dynamic import pointing at them. Only
|
|
@@ -2288,10 +1889,11 @@ function createBundleManifest(
|
|
|
2288
1889
|
version: 1,
|
|
2289
1890
|
buildTime: new Date().toISOString(),
|
|
2290
1891
|
env,
|
|
2291
|
-
bundles,
|
|
2292
|
-
...(islands ? { islands } : {}),
|
|
2293
|
-
...(partials ? { partials } : {}),
|
|
2294
|
-
|
|
1892
|
+
bundles,
|
|
1893
|
+
...(islands ? { islands } : {}),
|
|
1894
|
+
...(partials ? { partials } : {}),
|
|
1895
|
+
...(boundaries ? { boundaries } : {}),
|
|
1896
|
+
shared: {
|
|
2295
1897
|
runtime: runtimePath,
|
|
2296
1898
|
vendor: vendorResult.react, // primary vendor for backwards compatibility
|
|
2297
1899
|
router: routerPath, // Client-side Router
|
|
@@ -2487,26 +2089,37 @@ export async function buildClientBundles(
|
|
|
2487
2089
|
};
|
|
2488
2090
|
}
|
|
2489
2091
|
|
|
2490
|
-
// 부분 빌드 모드: targetRouteIds가 지정되면 해당 Island만 재빌드 (#122)
|
|
2491
|
-
if (options.targetRouteIds && options.targetRouteIds.length > 0) {
|
|
2492
|
-
const
|
|
2493
|
-
|
|
2494
|
-
const
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2092
|
+
// 부분 빌드 모드: targetRouteIds가 지정되면 해당 Island만 재빌드 (#122)
|
|
2093
|
+
if (options.targetRouteIds && options.targetRouteIds.length > 0) {
|
|
2094
|
+
const targetRouteIds = new Set(options.targetRouteIds);
|
|
2095
|
+
const targetRoutes = hydratedRoutes.filter((r) => targetRouteIds.has(r.id));
|
|
2096
|
+
const targetIslandRoutes = targetRoutes.filter((route) => !!route.clientModule);
|
|
2097
|
+
|
|
2098
|
+
const targetResults = await Promise.all(
|
|
2099
|
+
targetIslandRoutes.map(async (route) => {
|
|
2100
|
+
try {
|
|
2101
|
+
return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
|
|
2102
|
+
} catch (error) {
|
|
2499
2103
|
return { ok: false as const, routeId: route.id, error: String(error) };
|
|
2500
2104
|
}
|
|
2501
2105
|
}),
|
|
2502
2106
|
);
|
|
2503
2107
|
for (const r of targetResults) {
|
|
2504
2108
|
if (r.ok) outputs.push(r.result);
|
|
2505
|
-
else errors.push(`[${r.routeId}] ${r.error}`);
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2109
|
+
else errors.push(`[${r.routeId}] ${r.error}`);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
const boundaryRecords = targetRoutes.flatMap((route) => route.boundaries ?? []);
|
|
2113
|
+
const boundaryBundles = await buildBoundaryBundlesForRecords(
|
|
2114
|
+
boundaryRecords,
|
|
2115
|
+
rootDir,
|
|
2116
|
+
outDir,
|
|
2117
|
+
options,
|
|
2118
|
+
errors,
|
|
2119
|
+
);
|
|
2120
|
+
|
|
2121
|
+
// 기존 매니페스트를 읽어 변경된 Island만 갱신
|
|
2122
|
+
let existingManifest: BundleManifest;
|
|
2510
2123
|
try {
|
|
2511
2124
|
const manifestData = await fs.readFile(path.join(rootDir, ".mandu/manifest.json"), "utf-8");
|
|
2512
2125
|
existingManifest = JSON.parse(manifestData) as BundleManifest;
|
|
@@ -2519,31 +2132,45 @@ export async function buildClientBundles(
|
|
|
2519
2132
|
for (const routeId of invalidClientRouteIds) {
|
|
2520
2133
|
delete existingManifest.bundles[routeId];
|
|
2521
2134
|
}
|
|
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,
|
|
2135
|
+
if (outputs.length > 0 || invalidClientRouteIds.size > 0 || boundaryRecords.length > 0) {
|
|
2136
|
+
for (const output of outputs) {
|
|
2137
|
+
if (existingManifest.bundles[output.routeId]) {
|
|
2138
|
+
existingManifest.bundles[output.routeId].js = output.outputPath;
|
|
2139
|
+
} else {
|
|
2140
|
+
const route = targetIslandRoutes.find((r) => r.id === output.routeId);
|
|
2141
|
+
const hydration = route ? getRouteHydration(route) : null;
|
|
2142
|
+
existingManifest.bundles[output.routeId] = {
|
|
2143
|
+
js: output.outputPath,
|
|
2531
2144
|
dependencies: ["_runtime", "_react"],
|
|
2532
2145
|
priority: hydration?.priority || HYDRATION.DEFAULT_PRIORITY,
|
|
2533
|
-
};
|
|
2534
|
-
}
|
|
2535
|
-
}
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
mergeBoundaryBundlesIntoManifest(
|
|
2151
|
+
existingManifest,
|
|
2152
|
+
targetRoutes.map((route) => route.id),
|
|
2153
|
+
boundaryBundles,
|
|
2154
|
+
);
|
|
2155
|
+
|
|
2156
|
+
await fs.writeFile(
|
|
2157
|
+
path.join(rootDir, ".mandu/manifest.json"),
|
|
2539
2158
|
JSON.stringify(existingManifest, null, 2)
|
|
2540
2159
|
);
|
|
2541
2160
|
}
|
|
2542
|
-
// When all builds failed, do NOT overwrite manifest — keep previous good state
|
|
2543
|
-
|
|
2544
|
-
const stats = calculateStats(
|
|
2545
|
-
|
|
2546
|
-
|
|
2161
|
+
// When all builds failed, do NOT overwrite manifest — keep previous good state
|
|
2162
|
+
|
|
2163
|
+
const stats = calculateStats(
|
|
2164
|
+
outputs,
|
|
2165
|
+
startTime,
|
|
2166
|
+
boundaryBundles.map((boundary) => ({
|
|
2167
|
+
routeId: `boundary:${boundary.id}`,
|
|
2168
|
+
size: boundary.size,
|
|
2169
|
+
gzipSize: boundary.gzipSize,
|
|
2170
|
+
})),
|
|
2171
|
+
);
|
|
2172
|
+
return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
|
|
2173
|
+
}
|
|
2547
2174
|
|
|
2548
2175
|
// #185: Framework-internal 번들 스킵 모드
|
|
2549
2176
|
// 사용자 코드(src/shared 등) 변경 시 runtime/router/vendor/devtools 재빌드는 낭비.
|
|
@@ -2599,7 +2226,7 @@ export async function buildClientBundles(
|
|
|
2599
2226
|
}
|
|
2600
2227
|
|
|
2601
2228
|
const islandResults = await Promise.all(
|
|
2602
|
-
hydratedRoutes.map(async (route) => {
|
|
2229
|
+
hydratedRoutes.filter((route) => !!route.clientModule).map(async (route) => {
|
|
2603
2230
|
try {
|
|
2604
2231
|
return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
|
|
2605
2232
|
} catch (error) {
|
|
@@ -2645,18 +2272,32 @@ export async function buildClientBundles(
|
|
|
2645
2272
|
};
|
|
2646
2273
|
}
|
|
2647
2274
|
}
|
|
2648
|
-
if (perIslandBundles.length > 0) {
|
|
2649
|
-
existingManifest.islands = existingManifest.islands || {};
|
|
2650
|
-
for (const ib of perIslandBundles) {
|
|
2651
|
-
existingManifest.islands[ib.name] = {
|
|
2275
|
+
if (perIslandBundles.length > 0) {
|
|
2276
|
+
existingManifest.islands = existingManifest.islands || {};
|
|
2277
|
+
for (const ib of perIslandBundles) {
|
|
2278
|
+
existingManifest.islands[ib.name] = {
|
|
2652
2279
|
js: ib.js,
|
|
2653
2280
|
route: ib.route,
|
|
2654
2281
|
priority: ib.priority,
|
|
2655
|
-
};
|
|
2656
|
-
}
|
|
2657
|
-
}
|
|
2658
|
-
|
|
2659
|
-
const
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
const boundaryRecords = hydratedRoutes.flatMap((route) => route.boundaries ?? []);
|
|
2287
|
+
const boundaryBundles = await buildBoundaryBundlesForRecords(
|
|
2288
|
+
boundaryRecords,
|
|
2289
|
+
rootDir,
|
|
2290
|
+
outDir,
|
|
2291
|
+
options,
|
|
2292
|
+
errors,
|
|
2293
|
+
);
|
|
2294
|
+
mergeBoundaryBundlesIntoManifest(
|
|
2295
|
+
existingManifest,
|
|
2296
|
+
hydratedRoutes.map((route) => route.id),
|
|
2297
|
+
boundaryBundles,
|
|
2298
|
+
);
|
|
2299
|
+
|
|
2300
|
+
const partialBundles: PartialBundleBuild[] = [];
|
|
2660
2301
|
if (partialFiles.length > 0) {
|
|
2661
2302
|
const partialResults = await Promise.all(
|
|
2662
2303
|
partialFiles.map(async (entry) => {
|
|
@@ -2690,15 +2331,22 @@ export async function buildClientBundles(
|
|
|
2690
2331
|
JSON.stringify(existingManifest, null, 2),
|
|
2691
2332
|
);
|
|
2692
2333
|
|
|
2693
|
-
const stats = calculateStats(
|
|
2694
|
-
outputs,
|
|
2695
|
-
startTime,
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2334
|
+
const stats = calculateStats(
|
|
2335
|
+
outputs,
|
|
2336
|
+
startTime,
|
|
2337
|
+
[
|
|
2338
|
+
...partialBundles.map((partial) => ({
|
|
2339
|
+
routeId: `partial:${partial.name}`,
|
|
2340
|
+
size: partial.size,
|
|
2341
|
+
gzipSize: partial.gzipSize,
|
|
2342
|
+
})),
|
|
2343
|
+
...boundaryBundles.map((boundary) => ({
|
|
2344
|
+
routeId: `boundary:${boundary.id}`,
|
|
2345
|
+
size: boundary.size,
|
|
2346
|
+
gzipSize: boundary.gzipSize,
|
|
2347
|
+
})),
|
|
2348
|
+
],
|
|
2349
|
+
);
|
|
2702
2350
|
return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
|
|
2703
2351
|
}
|
|
2704
2352
|
|
|
@@ -2756,7 +2404,7 @@ export async function buildClientBundles(
|
|
|
2756
2404
|
|
|
2757
2405
|
// 5. 각 Island 번들 병렬 빌드 (#185: L1631의 per-island와 일관성 확보)
|
|
2758
2406
|
const fullIslandResults = await Promise.all(
|
|
2759
|
-
hydratedRoutes.map(async (route) => {
|
|
2407
|
+
hydratedRoutes.filter((route) => !!route.clientModule).map(async (route) => {
|
|
2760
2408
|
try {
|
|
2761
2409
|
return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
|
|
2762
2410
|
} catch (error) {
|
|
@@ -2783,9 +2431,9 @@ export async function buildClientBundles(
|
|
|
2783
2431
|
|
|
2784
2432
|
// 5.5. Per-island code splitting: scan and build individual island bundles
|
|
2785
2433
|
const islandFiles = await scanIslandFiles(hydratedRoutes, rootDir);
|
|
2786
|
-
const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
|
|
2434
|
+
const islandBundles: Array<{ name: string; js: string; route: string; priority: IslandFileEntry["priority"] }> = [];
|
|
2787
2435
|
|
|
2788
|
-
if (islandFiles.length > 0) {
|
|
2436
|
+
if (islandFiles.length > 0) {
|
|
2789
2437
|
const islandResults = await Promise.all(
|
|
2790
2438
|
islandFiles.map(async (entry) => {
|
|
2791
2439
|
try {
|
|
@@ -2799,9 +2447,27 @@ export async function buildClientBundles(
|
|
|
2799
2447
|
for (const result of islandResults) {
|
|
2800
2448
|
if (result) islandBundles.push(result);
|
|
2801
2449
|
}
|
|
2802
|
-
}
|
|
2803
|
-
|
|
2804
|
-
const
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
const boundaryRecords = hydratedRoutes.flatMap((route) => route.boundaries ?? []);
|
|
2453
|
+
const boundaryBundles: BoundaryBundleBuild[] = [];
|
|
2454
|
+
if (boundaryRecords.length > 0 && !pushDuplicateBoundaryIdErrors(boundaryRecords, errors)) {
|
|
2455
|
+
const boundaryResults = await Promise.all(
|
|
2456
|
+
boundaryRecords.map(async (boundary) => {
|
|
2457
|
+
try {
|
|
2458
|
+
return await buildBoundaryBundle(boundary, rootDir, outDir, options);
|
|
2459
|
+
} catch (error) {
|
|
2460
|
+
errors.push(`[boundary:${boundary.id}] ${String(error)}`);
|
|
2461
|
+
return null;
|
|
2462
|
+
}
|
|
2463
|
+
}),
|
|
2464
|
+
);
|
|
2465
|
+
for (const result of boundaryResults) {
|
|
2466
|
+
if (result) boundaryBundles.push(result);
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
const partialBundles: PartialBundleBuild[] = [];
|
|
2805
2471
|
if (partialFiles.length > 0) {
|
|
2806
2472
|
const partialResults = await Promise.all(
|
|
2807
2473
|
partialFiles.map(async (entry) => {
|
|
@@ -2825,10 +2491,11 @@ export async function buildClientBundles(
|
|
|
2825
2491
|
runtimeResult.outputPath,
|
|
2826
2492
|
vendorResult,
|
|
2827
2493
|
routerResult.outputPath,
|
|
2828
|
-
env,
|
|
2829
|
-
islandBundles,
|
|
2830
|
-
partialBundles,
|
|
2831
|
-
|
|
2494
|
+
env,
|
|
2495
|
+
islandBundles,
|
|
2496
|
+
partialBundles,
|
|
2497
|
+
boundaryBundles,
|
|
2498
|
+
);
|
|
2832
2499
|
|
|
2833
2500
|
await fs.writeFile(
|
|
2834
2501
|
path.join(rootDir, ".mandu/manifest.json"),
|
|
@@ -2836,15 +2503,22 @@ export async function buildClientBundles(
|
|
|
2836
2503
|
);
|
|
2837
2504
|
|
|
2838
2505
|
// 7. 통계 계산
|
|
2839
|
-
const stats = calculateStats(
|
|
2840
|
-
outputs,
|
|
2841
|
-
startTime,
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2506
|
+
const stats = calculateStats(
|
|
2507
|
+
outputs,
|
|
2508
|
+
startTime,
|
|
2509
|
+
[
|
|
2510
|
+
...partialBundles.map((partial) => ({
|
|
2511
|
+
routeId: `partial:${partial.name}`,
|
|
2512
|
+
size: partial.size,
|
|
2513
|
+
gzipSize: partial.gzipSize,
|
|
2514
|
+
})),
|
|
2515
|
+
...boundaryBundles.map((boundary) => ({
|
|
2516
|
+
routeId: `boundary:${boundary.id}`,
|
|
2517
|
+
size: boundary.size,
|
|
2518
|
+
gzipSize: boundary.gzipSize,
|
|
2519
|
+
})),
|
|
2520
|
+
],
|
|
2521
|
+
);
|
|
2848
2522
|
|
|
2849
2523
|
// Phase 18.τ — fire onBundleComplete(stats) before return.
|
|
2850
2524
|
await fireOnBundleComplete(stats);
|
|
@@ -2873,14 +2547,15 @@ export function formatSize(bytes: number): string {
|
|
|
2873
2547
|
*/
|
|
2874
2548
|
export function printBundleStats(result: BundleResult): void {
|
|
2875
2549
|
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
|
-
|
|
2550
|
+
console.log("=".repeat(50));
|
|
2551
|
+
|
|
2552
|
+
const partialCount = Object.keys(result.manifest.partials ?? {}).length;
|
|
2553
|
+
const boundaryCount = Object.keys(result.manifest.boundaries ?? {}).length;
|
|
2554
|
+
if (result.outputs.length === 0 && partialCount === 0 && boundaryCount === 0) {
|
|
2555
|
+
console.log("No islands, partials, or boundaries to bundle (hydration: none or no client entry)");
|
|
2556
|
+
if (result.errors.length > 0) {
|
|
2557
|
+
console.log("\n⚠️ Errors:");
|
|
2558
|
+
for (const error of result.errors) {
|
|
2884
2559
|
console.log(` ${error}`);
|
|
2885
2560
|
}
|
|
2886
2561
|
}
|
|
@@ -2900,11 +2575,14 @@ export function printBundleStats(result: BundleResult): void {
|
|
|
2900
2575
|
` ${output.routeId}: ${formatSize(output.size)} (gzip: ${formatSize(output.gzipSize)})`
|
|
2901
2576
|
);
|
|
2902
2577
|
}
|
|
2903
|
-
if (partialCount > 0) {
|
|
2904
|
-
console.log(` Partials: ${partialCount}`);
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
|
|
2578
|
+
if (partialCount > 0) {
|
|
2579
|
+
console.log(` Partials: ${partialCount}`);
|
|
2580
|
+
}
|
|
2581
|
+
if (boundaryCount > 0) {
|
|
2582
|
+
console.log(` Boundaries: ${boundaryCount}`);
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
if (result.errors.length > 0) {
|
|
2908
2586
|
console.log("\n⚠️ Errors:");
|
|
2909
2587
|
for (const error of result.errors) {
|
|
2910
2588
|
console.log(` ${error}`);
|