@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.
Files changed (46) hide show
  1. package/package.json +3 -1
  2. package/src/agent/__tests__/context.test.ts +94 -25
  3. package/src/agent/context.ts +17 -0
  4. package/src/agent/types.ts +32 -12
  5. package/src/agent/verify.ts +55 -24
  6. package/src/bundler/__snapshots__/build.test.ts.snap +5 -0
  7. package/src/bundler/__tests__/build-runner.ts +130 -17
  8. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -0
  9. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -33
  10. package/src/bundler/build.test.ts +478 -9
  11. package/src/bundler/build.ts +424 -746
  12. package/src/bundler/client-boundary-transform.ts +977 -0
  13. package/src/bundler/dev.ts +39 -112
  14. package/src/bundler/fast-refresh-preamble.ts +47 -0
  15. package/src/bundler/index.ts +3 -2
  16. package/src/bundler/manifest-schema.ts +10 -0
  17. package/src/bundler/types.ts +20 -2
  18. package/src/client/__tests__/props-serialization.test.ts +37 -0
  19. package/src/client/hydrate.ts +2 -2
  20. package/src/client/index.ts +1 -1
  21. package/src/client/props-serialization.ts +233 -0
  22. package/src/client/runtime-entry.ts +567 -0
  23. package/src/client/runtime.ts +1 -1
  24. package/src/client/serialize.ts +50 -404
  25. package/src/diagnose/__tests__/checks.test.ts +132 -17
  26. package/src/diagnose/checks.ts +184 -3
  27. package/src/diagnose/run.ts +10 -8
  28. package/src/generator/templates.test.ts +48 -5
  29. package/src/generator/templates.ts +10 -1
  30. package/src/internal/client-boundary.ts +266 -0
  31. package/src/internal/index.ts +2 -1
  32. package/src/router/client-entry.test.ts +154 -29
  33. package/src/router/client-entry.ts +111 -313
  34. package/src/router/fs-routes.test.ts +443 -1
  35. package/src/router/fs-routes.ts +16 -3
  36. package/src/router/fs-scanner.ts +176 -57
  37. package/src/router/fs-types.ts +11 -2
  38. package/src/router/route-source-analyzer.ts +521 -0
  39. package/src/runtime/__tests__/inline-client-hydration.test.ts +104 -1
  40. package/src/runtime/__tests__/page-render-response.test.ts +218 -0
  41. package/src/runtime/handlers.ts +50 -26
  42. package/src/runtime/page-render-response.ts +24 -1
  43. package/src/runtime/server.ts +14 -0
  44. package/src/runtime/ssr.ts +16 -5
  45. package/src/runtime/streaming-ssr.ts +119 -76
  46. package/src/spec/schema.ts +31 -5
@@ -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
- needsHydration(route)
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
- * Error Boundary 컴포넌트 (Class Component)
669
- * Island의 errorBoundary 옵션을 지원
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
- class IslandErrorBoundary extends Component {
672
- constructor(props) {
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 = path.join(outDir, "_runtime.src.js");
1700
- const outputName = "_runtime.js";
1701
-
1702
- try {
1703
- // 런타임 소스 작성
1704
- await Bun.write(runtimePath, generateRuntimeSource());
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
- // 실패 디버깅을 위해 소스 파일을 남겨둠 (_runtime.src.js)
1724
- const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
1725
- return {
1726
- success: false,
1727
- outputPath: "",
1728
- errors: [`Runtime bundle build failed (source: ${runtimePath}):\n${grouped}\n Hint: Check the import paths and TypeScript types. The source file has been kept for debugging.`],
1729
- };
1730
- }
1731
-
1732
- // 성공 시에만 소스 파일 정리
1733
- await fs.unlink(runtimePath).catch(() => {});
1734
-
1735
- return {
1736
- success: true,
1737
- outputPath: `/.mandu/client/${outputName}`,
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
- ): BundleManifest {
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
- shared: {
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 targetRoutes = hydratedRoutes.filter((r) => options.targetRouteIds!.includes(r.id));
2493
-
2494
- const targetResults = await Promise.all(
2495
- targetRoutes.map(async (route) => {
2496
- try {
2497
- return { ok: true as const, result: await buildIsland(route, rootDir, outDir, options) };
2498
- } catch (error) {
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
- // 기존 매니페스트를 읽어 변경된 Island만 갱신
2509
- let existingManifest: BundleManifest;
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 = targetRoutes.find((r) => r.id === output.routeId);
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
- await fs.writeFile(
2538
- path.join(rootDir, ".mandu/manifest.json"),
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(outputs, startTime);
2545
- return { success: errors.length === 0, outputs, errors, manifest: existingManifest, stats };
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 partialBundles: PartialBundleBuild[] = [];
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
- partialBundles.map((partial) => ({
2697
- routeId: `partial:${partial.name}`,
2698
- size: partial.size,
2699
- gzipSize: partial.gzipSize,
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 partialBundles: PartialBundleBuild[] = [];
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
- partialBundles.map((partial) => ({
2843
- routeId: `partial:${partial.name}`,
2844
- size: partial.size,
2845
- gzipSize: partial.gzipSize,
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
- if (result.outputs.length === 0 && partialCount === 0) {
2880
- console.log("No islands or partials to bundle (hydration: none or no client entry)");
2881
- if (result.errors.length > 0) {
2882
- console.log("\n⚠️ Errors:");
2883
- for (const error of result.errors) {
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
- if (result.errors.length > 0) {
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}`);