@mandujs/core 0.54.18 → 0.54.20
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 +1 -1
- package/src/agent/__tests__/context.test.ts +40 -9
- package/src/agent/verify.ts +55 -24
- package/src/bundler/build.test.ts +287 -246
- package/src/bundler/build.ts +35 -680
- 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 +15 -0
- package/src/diagnose/checks.ts +1 -1
- package/src/router/client-entry.test.ts +111 -23
- package/src/router/client-entry.ts +78 -301
- package/src/router/fs-routes.test.ts +55 -0
- package/src/router/fs-scanner.ts +11 -40
- package/src/router/fs-types.ts +7 -1
- 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 +6 -0
- package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -0
- package/src/runtime/page-render-response.ts +23 -1
- package/src/runtime/server.ts +179 -157
package/src/bundler/build.ts
CHANGED
|
@@ -514,651 +514,15 @@ function formatShimBindings(names: readonly string[], indent = " "): string {
|
|
|
514
514
|
return names.map((name) => `${indent}${name},`).join("\n");
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
-
/**
|
|
518
|
-
* Runtime 번들 소스 생성 (v0.8.0 재설계)
|
|
519
|
-
*
|
|
520
|
-
* 설계 원칙:
|
|
521
|
-
* - 글로벌 레지스트리 없음 (Island가 스스로 등록 안함)
|
|
522
|
-
* - Runtime이 Island를 dynamic import()로 로드
|
|
523
|
-
* - HTML의 data-mandu-src 속성에서 번들 URL 읽기
|
|
524
|
-
* - 실행 순서 문제 완전 해결
|
|
525
|
-
*/
|
|
526
|
-
function generateRuntimeSource(): string {
|
|
527
|
-
return `
|
|
528
|
-
/**
|
|
529
|
-
* Mandu Hydration Runtime v0.9.0 (Generated)
|
|
530
|
-
* Fresh-style dynamic import architecture
|
|
531
|
-
* + Error Boundary & Loading fallback support
|
|
532
|
-
*/
|
|
533
|
-
|
|
534
|
-
// React 정적 import (Island와 같은 인스턴스 공유)
|
|
535
|
-
import React, { useState, useEffect, Component } from 'react';
|
|
536
|
-
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
537
|
-
|
|
538
|
-
// Hydrated roots 추적 (unmount용) - 전역 초기화
|
|
539
|
-
window.__MANDU_ROOTS__ = window.__MANDU_ROOTS__ || new Map();
|
|
540
|
-
const hydratedRoots = window.__MANDU_ROOTS__;
|
|
541
|
-
const warnedBoundaryPropFallbacks = new Set();
|
|
542
|
-
|
|
543
|
-
const TYPE_MARKERS = {
|
|
544
|
-
UNDEFINED: "\\u0000_",
|
|
545
|
-
DATE: "\\u0000D",
|
|
546
|
-
URL: "\\u0000U",
|
|
547
|
-
REGEXP: "\\u0000R",
|
|
548
|
-
MAP: "\\u0000M",
|
|
549
|
-
SET: "\\u0000S",
|
|
550
|
-
REF: "\\u0000$",
|
|
551
|
-
BIGINT: "\\u0000B",
|
|
552
|
-
SYMBOL: "\\u0000Y",
|
|
553
|
-
ERROR: "\\u0000E",
|
|
554
|
-
};
|
|
555
|
-
|
|
556
|
-
function deserializeManduProps(json) {
|
|
557
|
-
const ctx = { refs: [] };
|
|
558
|
-
return deserializeManduValue(JSON.parse(json), ctx);
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
function deserializeManduValue(value, ctx) {
|
|
562
|
-
if (value === null) return null;
|
|
563
|
-
if (typeof value === 'string') {
|
|
564
|
-
if (value === TYPE_MARKERS.UNDEFINED) return undefined;
|
|
565
|
-
if (value.startsWith("\\u0000\\u0000")) return value.slice(2);
|
|
566
|
-
if (value.startsWith(TYPE_MARKERS.DATE)) return new Date(value.slice(2));
|
|
567
|
-
if (value.startsWith(TYPE_MARKERS.URL)) return new URL(value.slice(2));
|
|
568
|
-
if (value.startsWith(TYPE_MARKERS.REGEXP)) {
|
|
569
|
-
const str = value.slice(2);
|
|
570
|
-
const match = str.match(/^\\/(.*)\\/([gimsuy]*)$/);
|
|
571
|
-
return match ? new RegExp(match[1], match[2]) : str;
|
|
572
|
-
}
|
|
573
|
-
if (value.startsWith(TYPE_MARKERS.BIGINT)) return BigInt(value.slice(2));
|
|
574
|
-
if (value.startsWith(TYPE_MARKERS.SYMBOL)) return Symbol(value.slice(2));
|
|
575
|
-
if (value.startsWith(TYPE_MARKERS.REF)) return ctx.refs[parseInt(value.slice(2), 10)];
|
|
576
|
-
return value;
|
|
577
|
-
}
|
|
578
|
-
if (typeof value === 'boolean' || typeof value === 'number') return value;
|
|
579
|
-
if (Array.isArray(value)) {
|
|
580
|
-
const marker = value[0];
|
|
581
|
-
if (marker === TYPE_MARKERS.ERROR) {
|
|
582
|
-
const error = new Error(value[2]);
|
|
583
|
-
error.name = value[1];
|
|
584
|
-
if (value[3]) error.stack = value[3];
|
|
585
|
-
ctx.refs.push(error);
|
|
586
|
-
return error;
|
|
587
|
-
}
|
|
588
|
-
if (marker === TYPE_MARKERS.MAP) {
|
|
589
|
-
const map = new Map();
|
|
590
|
-
ctx.refs.push(map);
|
|
591
|
-
for (let i = 1; i < value.length; i++) {
|
|
592
|
-
const entry = value[i];
|
|
593
|
-
map.set(deserializeManduValue(entry[0], ctx), deserializeManduValue(entry[1], ctx));
|
|
594
|
-
}
|
|
595
|
-
return map;
|
|
596
|
-
}
|
|
597
|
-
if (marker === TYPE_MARKERS.SET) {
|
|
598
|
-
const set = new Set();
|
|
599
|
-
ctx.refs.push(set);
|
|
600
|
-
for (let i = 1; i < value.length; i++) {
|
|
601
|
-
set.add(deserializeManduValue(value[i], ctx));
|
|
602
|
-
}
|
|
603
|
-
return set;
|
|
604
|
-
}
|
|
605
|
-
const arr = [];
|
|
606
|
-
ctx.refs.push(arr);
|
|
607
|
-
for (const item of value) arr.push(deserializeManduValue(item, ctx));
|
|
608
|
-
return arr;
|
|
609
|
-
}
|
|
610
|
-
if (typeof value === 'object') {
|
|
611
|
-
const obj = {};
|
|
612
|
-
ctx.refs.push(obj);
|
|
613
|
-
for (const [key, nested] of Object.entries(value)) {
|
|
614
|
-
obj[key] = deserializeManduValue(nested, ctx);
|
|
615
|
-
}
|
|
616
|
-
return obj;
|
|
617
|
-
}
|
|
618
|
-
return value;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// 서버 데이터
|
|
622
|
-
function readManduData() {
|
|
623
|
-
if (window.__MANDU_DATA__) return window.__MANDU_DATA__;
|
|
624
|
-
|
|
625
|
-
const raw = window.__MANDU_DATA_RAW__ || document.getElementById('__MANDU_DATA__')?.textContent;
|
|
626
|
-
if (!raw) {
|
|
627
|
-
window.__MANDU_DATA__ = {};
|
|
628
|
-
return window.__MANDU_DATA__;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
try {
|
|
632
|
-
window.__MANDU_DATA__ = deserializeManduProps(raw);
|
|
633
|
-
} catch (error) {
|
|
634
|
-
console.warn('[Mandu] Failed to parse server data:', error);
|
|
635
|
-
window.__MANDU_DATA__ = {};
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
return window.__MANDU_DATA__;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
const getServerData = (id, element) => {
|
|
642
|
-
const data = readManduData();
|
|
643
|
-
if (data[id] && Object.prototype.hasOwnProperty.call(data[id], 'serverData')) {
|
|
644
|
-
return data[id].serverData;
|
|
645
|
-
}
|
|
646
|
-
const routeId = element?.getAttribute?.('data-mandu-route-id');
|
|
647
|
-
if (
|
|
648
|
-
routeId &&
|
|
649
|
-
data[routeId] &&
|
|
650
|
-
Object.prototype.hasOwnProperty.call(data[routeId], 'serverData')
|
|
651
|
-
) {
|
|
652
|
-
return data[routeId].serverData;
|
|
653
|
-
}
|
|
654
|
-
return {};
|
|
655
|
-
};
|
|
656
|
-
|
|
657
|
-
function findPropsScript(id) {
|
|
658
|
-
const scripts = document.querySelectorAll('script[data-mandu-props]');
|
|
659
|
-
for (const script of scripts) {
|
|
660
|
-
if (script.getAttribute('data-mandu-props') === id) {
|
|
661
|
-
return script;
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
return null;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
function parsePropsScript(id) {
|
|
668
|
-
const script = findPropsScript(id);
|
|
669
|
-
if (!script || !script.textContent) return null;
|
|
670
|
-
try {
|
|
671
|
-
return deserializeManduProps(script.textContent);
|
|
672
|
-
} catch (error) {
|
|
673
|
-
console.warn('[Mandu] Failed to parse data-mandu-props for island ' + id + ':', error);
|
|
674
|
-
return null;
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
function readDataProps(element) {
|
|
679
|
-
const propsEl = element.hasAttribute('data-props')
|
|
680
|
-
? element
|
|
681
|
-
: element.querySelector('[data-props]');
|
|
682
|
-
if (!propsEl) return null;
|
|
683
|
-
try {
|
|
684
|
-
return deserializeManduProps(propsEl.getAttribute('data-props') || '{}');
|
|
685
|
-
} catch (error) {
|
|
686
|
-
console.warn('[Mandu] Failed to parse data-props fallback:', error);
|
|
687
|
-
return null;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
function getIslandProps(id, element) {
|
|
692
|
-
const inlineProps = parsePropsScript(id);
|
|
693
|
-
if (inlineProps) return inlineProps;
|
|
694
|
-
|
|
695
|
-
const dataProps = readDataProps(element);
|
|
696
|
-
if (dataProps) return dataProps;
|
|
697
|
-
|
|
698
|
-
const boundaryId = element?.getAttribute?.('data-mandu-boundary-id');
|
|
699
|
-
if (boundaryId && !warnedBoundaryPropFallbacks.has(id)) {
|
|
700
|
-
warnedBoundaryPropFallbacks.add(id);
|
|
701
|
-
console.warn(
|
|
702
|
-
'[Mandu] Missing boundary-local props for transformed client boundary ' +
|
|
703
|
-
boundaryId +
|
|
704
|
-
'; falling back to route server data.'
|
|
705
|
-
);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
return getServerData(id, element);
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
function resolveIslandExport(module, element) {
|
|
712
|
-
const exportName = element.getAttribute('data-mandu-client-export');
|
|
713
|
-
if (exportName) {
|
|
714
|
-
if (exportName === 'default' && module.default) return module.default;
|
|
715
|
-
if (exportName !== 'default' && module[exportName]) return module[exportName];
|
|
716
|
-
console.warn('[Mandu] Client boundary export "' + exportName + '" was not found; falling back to default export.');
|
|
717
|
-
}
|
|
718
|
-
return module.default;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
517
|
/**
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
constructor(props) {
|
|
727
|
-
super(props);
|
|
728
|
-
this.state = { hasError: false, error: null };
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
static getDerivedStateFromError(error) {
|
|
732
|
-
return { hasError: true, error };
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
componentDidCatch(error, errorInfo) {
|
|
736
|
-
console.error('[Mandu] Island error:', this.props.islandId, error, errorInfo);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
reset = () => {
|
|
740
|
-
this.setState({ hasError: false, error: null });
|
|
741
|
-
};
|
|
742
|
-
|
|
743
|
-
render() {
|
|
744
|
-
if (this.state.hasError) {
|
|
745
|
-
// 커스텀 errorBoundary가 있으면 사용
|
|
746
|
-
if (this.props.errorBoundary) {
|
|
747
|
-
return this.props.errorBoundary(this.state.error, this.reset);
|
|
748
|
-
}
|
|
749
|
-
// 기본 에러 UI
|
|
750
|
-
return React.createElement('div', {
|
|
751
|
-
className: 'mandu-island-error',
|
|
752
|
-
style: {
|
|
753
|
-
padding: '16px',
|
|
754
|
-
background: '#fef2f2',
|
|
755
|
-
border: '1px solid #fecaca',
|
|
756
|
-
borderRadius: '8px',
|
|
757
|
-
color: '#dc2626',
|
|
758
|
-
}
|
|
759
|
-
}, [
|
|
760
|
-
React.createElement('strong', { key: 'title' }, '⚠️ 오류 발생'),
|
|
761
|
-
React.createElement('p', { key: 'msg', style: { margin: '8px 0', fontSize: '14px' } },
|
|
762
|
-
this.state.error?.message || '알 수 없는 오류'
|
|
763
|
-
),
|
|
764
|
-
React.createElement('button', {
|
|
765
|
-
key: 'btn',
|
|
766
|
-
onClick: this.reset,
|
|
767
|
-
style: {
|
|
768
|
-
padding: '6px 12px',
|
|
769
|
-
background: '#dc2626',
|
|
770
|
-
color: 'white',
|
|
771
|
-
border: 'none',
|
|
772
|
-
borderRadius: '4px',
|
|
773
|
-
cursor: 'pointer',
|
|
774
|
-
}
|
|
775
|
-
}, '다시 시도')
|
|
776
|
-
]);
|
|
777
|
-
}
|
|
778
|
-
return this.props.children;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* Loading Wrapper 컴포넌트
|
|
784
|
-
* Island의 loading 옵션을 지원
|
|
785
|
-
*/
|
|
786
|
-
function IslandLoadingWrapper({ children, loading, isReady }) {
|
|
787
|
-
if (!isReady && loading) {
|
|
788
|
-
return loading();
|
|
789
|
-
}
|
|
790
|
-
return children;
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
function resolveHydrationTarget(element) {
|
|
794
|
-
if (!(element instanceof HTMLElement)) {
|
|
795
|
-
return element;
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
if (getComputedStyle(element).display !== 'contents') {
|
|
799
|
-
return element;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
const queue = Array.from(element.children);
|
|
803
|
-
while (queue.length > 0) {
|
|
804
|
-
const candidate = queue.shift();
|
|
805
|
-
if (candidate instanceof HTMLElement) {
|
|
806
|
-
return candidate;
|
|
807
|
-
}
|
|
808
|
-
if (candidate) {
|
|
809
|
-
queue.push(...candidate.children);
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
return element.parentElement || element;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
function hasHydratableMarkup(element) {
|
|
817
|
-
for (const node of element.childNodes) {
|
|
818
|
-
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
819
|
-
return true;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
if (node.nodeType === Node.TEXT_NODE && node.textContent && node.textContent.trim() !== '') {
|
|
823
|
-
return true;
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
return false;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
function shouldHydrateCompiledIsland(element) {
|
|
831
|
-
return (
|
|
832
|
-
element.getAttribute('data-mandu-loading') !== 'true' &&
|
|
833
|
-
hasHydratableMarkup(element)
|
|
834
|
-
);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
function createHydrationOptions(element, id, mode) {
|
|
838
|
-
return {
|
|
839
|
-
onRecoverableError(error) {
|
|
840
|
-
element.setAttribute('data-mandu-recoverable-error', 'true');
|
|
841
|
-
console.warn('[Mandu] Recoverable hydration error:', id, mode, error);
|
|
842
|
-
element.dispatchEvent(new CustomEvent('mandu:recoverable-hydration-error', {
|
|
843
|
-
bubbles: true,
|
|
844
|
-
detail: {
|
|
845
|
-
id,
|
|
846
|
-
mode,
|
|
847
|
-
error: error instanceof Error ? error.message : String(error),
|
|
848
|
-
},
|
|
849
|
-
}));
|
|
850
|
-
},
|
|
851
|
-
};
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
/**
|
|
855
|
-
* Hydration 스케줄러
|
|
856
|
-
*/
|
|
857
|
-
function priorityToHydrateStrategy(priority) {
|
|
858
|
-
return priority === 'immediate' ? 'load' : priority;
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
function scheduleHydration(element, src, strategy) {
|
|
862
|
-
if (!strategy) strategy = 'load';
|
|
863
|
-
if (strategy === 'immediate') strategy = 'load';
|
|
864
|
-
|
|
865
|
-
if (strategy.startsWith('media(') && strategy.endsWith(')')) {
|
|
866
|
-
const query = strategy.slice('media('.length, -1).trim();
|
|
867
|
-
if (!query || !window.matchMedia) {
|
|
868
|
-
loadAndHydrate(element, src);
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
const mql = window.matchMedia(query);
|
|
872
|
-
if (mql.matches) {
|
|
873
|
-
loadAndHydrate(element, src);
|
|
874
|
-
return;
|
|
875
|
-
}
|
|
876
|
-
const onChange = (event) => {
|
|
877
|
-
if (!event.matches) return;
|
|
878
|
-
if (mql.removeEventListener) {
|
|
879
|
-
mql.removeEventListener('change', onChange);
|
|
880
|
-
} else if (mql.removeListener) {
|
|
881
|
-
mql.removeListener(onChange);
|
|
882
|
-
}
|
|
883
|
-
loadAndHydrate(element, src);
|
|
884
|
-
};
|
|
885
|
-
if (mql.addEventListener) {
|
|
886
|
-
mql.addEventListener('change', onChange);
|
|
887
|
-
} else if (mql.addListener) {
|
|
888
|
-
mql.addListener(onChange);
|
|
889
|
-
}
|
|
890
|
-
return;
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
switch (strategy) {
|
|
894
|
-
case 'load':
|
|
895
|
-
case 'immediate':
|
|
896
|
-
loadAndHydrate(element, src);
|
|
897
|
-
break;
|
|
898
|
-
|
|
899
|
-
case 'visible':
|
|
900
|
-
if ('IntersectionObserver' in window) {
|
|
901
|
-
const observer = new IntersectionObserver((entries) => {
|
|
902
|
-
if (entries[0].isIntersecting) {
|
|
903
|
-
observer.disconnect();
|
|
904
|
-
loadAndHydrate(element, src);
|
|
905
|
-
}
|
|
906
|
-
}, { rootMargin: '200px' });
|
|
907
|
-
const target = resolveHydrationTarget(element);
|
|
908
|
-
observer.observe(target);
|
|
909
|
-
} else {
|
|
910
|
-
loadAndHydrate(element, src);
|
|
911
|
-
}
|
|
912
|
-
break;
|
|
913
|
-
|
|
914
|
-
case 'idle':
|
|
915
|
-
if ('requestIdleCallback' in window) {
|
|
916
|
-
requestIdleCallback(() => loadAndHydrate(element, src));
|
|
917
|
-
} else {
|
|
918
|
-
setTimeout(() => loadAndHydrate(element, src), 200);
|
|
919
|
-
}
|
|
920
|
-
break;
|
|
921
|
-
|
|
922
|
-
case 'interaction': {
|
|
923
|
-
const target = resolveHydrationTarget(element);
|
|
924
|
-
const hydrate = () => {
|
|
925
|
-
target.removeEventListener('touchstart', hydrate);
|
|
926
|
-
target.removeEventListener('click', hydrate);
|
|
927
|
-
target.removeEventListener('keydown', hydrate);
|
|
928
|
-
loadAndHydrate(element, src);
|
|
929
|
-
};
|
|
930
|
-
target.addEventListener('touchstart', hydrate, { once: true, passive: true });
|
|
931
|
-
target.addEventListener('click', hydrate, { once: true });
|
|
932
|
-
target.addEventListener('keydown', hydrate, { once: true });
|
|
933
|
-
break;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
default:
|
|
937
|
-
console.warn('[Mandu] Unknown hydrate strategy "' + strategy + '", falling back to load.');
|
|
938
|
-
loadAndHydrate(element, src);
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
/**
|
|
943
|
-
* Island 로드 및 hydrate (핵심 함수)
|
|
944
|
-
* Dynamic import로 Island 모듈 로드 후 렌더링
|
|
945
|
-
* Error Boundary 및 Loading fallback 지원
|
|
946
|
-
*/
|
|
947
|
-
async function loadAndHydrate(element, src) {
|
|
948
|
-
const id = element.getAttribute('data-mandu-island');
|
|
949
|
-
if (!id) {
|
|
950
|
-
return;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
if (
|
|
954
|
-
hydratedRoots.has(id) ||
|
|
955
|
-
element.hasAttribute('data-mandu-hydrated') ||
|
|
956
|
-
element.getAttribute('data-mandu-hydrating') === 'true'
|
|
957
|
-
) {
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
element.setAttribute('data-mandu-hydrating', 'true');
|
|
962
|
-
|
|
963
|
-
try {
|
|
964
|
-
// Dynamic import - 이 시점에 Island 모듈 로드
|
|
965
|
-
const module = await import(src);
|
|
966
|
-
const island = resolveIslandExport(module, element);
|
|
967
|
-
const data = getIslandProps(id, element);
|
|
968
|
-
|
|
969
|
-
// Mandu Island (preferred)
|
|
970
|
-
if (island && island.__mandu_island === true) {
|
|
971
|
-
const { definition } = island;
|
|
972
|
-
const shouldHydrate = shouldHydrateCompiledIsland(element);
|
|
973
|
-
const renderMode = shouldHydrate ? 'hydrate' : 'mount';
|
|
974
|
-
|
|
975
|
-
// Island 컴포넌트 (Error Boundary + Loading 지원)
|
|
976
|
-
function IslandComponent({ initialReady }) {
|
|
977
|
-
const [isReady, setIsReady] = useState(initialReady);
|
|
978
|
-
|
|
979
|
-
useEffect(() => {
|
|
980
|
-
setIsReady(true);
|
|
981
|
-
}, []);
|
|
982
|
-
|
|
983
|
-
// setup 호출 및 render
|
|
984
|
-
const setupResult = definition.setup(data);
|
|
985
|
-
const content = definition.render(setupResult);
|
|
986
|
-
|
|
987
|
-
// Loading wrapper 적용
|
|
988
|
-
const wrappedContent = definition.loading
|
|
989
|
-
? React.createElement(IslandLoadingWrapper, {
|
|
990
|
-
loading: definition.loading,
|
|
991
|
-
isReady,
|
|
992
|
-
}, content)
|
|
993
|
-
: content;
|
|
994
|
-
|
|
995
|
-
// Error Boundary 적용
|
|
996
|
-
return React.createElement(IslandErrorBoundary, {
|
|
997
|
-
islandId: id,
|
|
998
|
-
errorBoundary: definition.errorBoundary,
|
|
999
|
-
}, wrappedContent);
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
const root = shouldHydrate
|
|
1003
|
-
? hydrateRoot(
|
|
1004
|
-
element,
|
|
1005
|
-
React.createElement(IslandComponent, { initialReady: true }),
|
|
1006
|
-
createHydrationOptions(element, id, renderMode)
|
|
1007
|
-
)
|
|
1008
|
-
: createRoot(element);
|
|
1009
|
-
|
|
1010
|
-
if (!shouldHydrate) {
|
|
1011
|
-
root.render(React.createElement(IslandComponent, { initialReady: false }));
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
hydratedRoots.set(id, root);
|
|
1015
|
-
|
|
1016
|
-
// 완료 표시
|
|
1017
|
-
element.setAttribute('data-mandu-render-mode', renderMode);
|
|
1018
|
-
element.setAttribute('data-mandu-hydrated', 'true');
|
|
1019
|
-
|
|
1020
|
-
// 성능 마커
|
|
1021
|
-
if (performance.mark) {
|
|
1022
|
-
performance.mark('mandu-hydrated-' + id);
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// 이벤트 발송
|
|
1026
|
-
element.dispatchEvent(new CustomEvent('mandu:hydrated', {
|
|
1027
|
-
bubbles: true,
|
|
1028
|
-
detail: { id, data, mode: renderMode }
|
|
1029
|
-
}));
|
|
1030
|
-
|
|
1031
|
-
// Kitchen DevTools에 island 등록
|
|
1032
|
-
if (window.__MANDU_DEVTOOLS_HOOK__) {
|
|
1033
|
-
const hydrateTime = performance.now ? performance.now() : Date.now();
|
|
1034
|
-
window.__MANDU_DEVTOOLS_HOOK__.emit({
|
|
1035
|
-
type: 'island:register',
|
|
1036
|
-
timestamp: Date.now(),
|
|
1037
|
-
data: {
|
|
1038
|
-
id,
|
|
1039
|
-
name: id,
|
|
1040
|
-
strategy: element.getAttribute('data-mandu-priority') || 'visible',
|
|
1041
|
-
status: 'hydrated',
|
|
1042
|
-
renderMode,
|
|
1043
|
-
hydrateStartTime: hydrateTime - 10,
|
|
1044
|
-
hydrateEndTime: hydrateTime,
|
|
1045
|
-
propsSize: JSON.stringify(data).length,
|
|
1046
|
-
},
|
|
1047
|
-
});
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
console.log('[Mandu] Hydrated:', id, '(' + renderMode + ')');
|
|
1051
|
-
}
|
|
1052
|
-
// Plain React component fallback (e.g. "use client" pages)
|
|
1053
|
-
else if (typeof island === 'function' || React.isValidElement(island)) {
|
|
1054
|
-
console.warn('[Mandu] Plain component hydration:', id);
|
|
1055
|
-
const shouldHydrate = hasHydratableMarkup(element);
|
|
1056
|
-
const renderMode = shouldHydrate ? 'hydrate' : 'mount';
|
|
1057
|
-
|
|
1058
|
-
const root = shouldHydrate
|
|
1059
|
-
? (typeof island === 'function'
|
|
1060
|
-
? hydrateRoot(
|
|
1061
|
-
element,
|
|
1062
|
-
React.createElement(island, data),
|
|
1063
|
-
createHydrationOptions(element, id, renderMode)
|
|
1064
|
-
)
|
|
1065
|
-
: hydrateRoot(element, island, createHydrationOptions(element, id, renderMode)))
|
|
1066
|
-
: createRoot(element);
|
|
1067
|
-
|
|
1068
|
-
if (!shouldHydrate) {
|
|
1069
|
-
root.render(typeof island === 'function' ? React.createElement(island, data) : island);
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
hydratedRoots.set(id, root);
|
|
1073
|
-
|
|
1074
|
-
// 완료 표시
|
|
1075
|
-
element.setAttribute('data-mandu-render-mode', renderMode);
|
|
1076
|
-
element.setAttribute('data-mandu-hydrated', 'true');
|
|
1077
|
-
|
|
1078
|
-
// 성능 마커
|
|
1079
|
-
if (performance.mark) {
|
|
1080
|
-
performance.mark('mandu-hydrated-' + id);
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
// 이벤트 발송
|
|
1084
|
-
element.dispatchEvent(new CustomEvent('mandu:hydrated', {
|
|
1085
|
-
bubbles: true,
|
|
1086
|
-
detail: { id, data, mode: renderMode }
|
|
1087
|
-
}));
|
|
1088
|
-
|
|
1089
|
-
console.log('[Mandu] Plain component hydrated:', id, '(' + renderMode + ')');
|
|
1090
|
-
}
|
|
1091
|
-
else {
|
|
1092
|
-
throw new Error('[Mandu] Invalid module: expected Mandu island or React component: ' + id);
|
|
1093
|
-
}
|
|
1094
|
-
} catch (error) {
|
|
1095
|
-
console.error('[Mandu] Hydration failed for', id, error);
|
|
1096
|
-
element.setAttribute('data-mandu-error', 'true');
|
|
1097
|
-
|
|
1098
|
-
// 에러 이벤트 발송
|
|
1099
|
-
element.dispatchEvent(new CustomEvent('mandu:hydration-error', {
|
|
1100
|
-
bubbles: true,
|
|
1101
|
-
detail: { id, error: error.message }
|
|
1102
|
-
}));
|
|
1103
|
-
} finally {
|
|
1104
|
-
element.removeAttribute('data-mandu-hydrating');
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
/**
|
|
1109
|
-
* 모든 Island hydrate 시작
|
|
1110
|
-
*/
|
|
1111
|
-
function hydrateIslands() {
|
|
1112
|
-
const islands = document.querySelectorAll('[data-mandu-island]');
|
|
1113
|
-
const seenIds = new Set();
|
|
1114
|
-
|
|
1115
|
-
for (const el of islands) {
|
|
1116
|
-
const id = el.getAttribute('data-mandu-island');
|
|
1117
|
-
const src = el.getAttribute('data-mandu-src');
|
|
1118
|
-
const priority = el.getAttribute('data-mandu-priority') || '${HYDRATION.DEFAULT_PRIORITY}';
|
|
1119
|
-
const hydrateStrategy = el.getAttribute('data-hydrate') || priorityToHydrateStrategy(priority);
|
|
1120
|
-
|
|
1121
|
-
if (!id || !src) {
|
|
1122
|
-
console.warn('[Mandu] Island missing id or src:', el);
|
|
1123
|
-
continue;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
// 중복 ID 경고
|
|
1127
|
-
if (seenIds.has(id)) {
|
|
1128
|
-
console.warn('[Mandu] Duplicate island id detected:', id, '- skipping');
|
|
1129
|
-
continue;
|
|
1130
|
-
}
|
|
1131
|
-
seenIds.add(id);
|
|
1132
|
-
|
|
1133
|
-
scheduleHydration(el, src, hydrateStrategy);
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
/**
|
|
1138
|
-
* Island unmount
|
|
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.
|
|
1139
522
|
*/
|
|
1140
|
-
function
|
|
1141
|
-
|
|
1142
|
-
if (root) {
|
|
1143
|
-
root.unmount();
|
|
1144
|
-
hydratedRoots.delete(id);
|
|
1145
|
-
return true;
|
|
1146
|
-
}
|
|
1147
|
-
return false;
|
|
523
|
+
function getRuntimeEntryPath(): string {
|
|
524
|
+
return path.resolve(import.meta.dir, "..", "client", "runtime-entry.ts");
|
|
1148
525
|
}
|
|
1149
|
-
|
|
1150
|
-
// 자동 초기화
|
|
1151
|
-
if (document.readyState === 'loading') {
|
|
1152
|
-
document.addEventListener('DOMContentLoaded', hydrateIslands);
|
|
1153
|
-
} else {
|
|
1154
|
-
hydrateIslands();
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// Export for external use
|
|
1158
|
-
export { hydrateIslands, unmountIsland, hydratedRoots };
|
|
1159
|
-
`;
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
526
|
/**
|
|
1163
527
|
* React shim 소스 생성 (import map용)
|
|
1164
528
|
* 주의: export *는 Bun bundler에서 제대로 작동하지 않으므로 명시적 export 필요
|
|
@@ -1759,21 +1123,17 @@ export default {
|
|
|
1759
1123
|
/**
|
|
1760
1124
|
* Runtime 번들 빌드
|
|
1761
1125
|
*/
|
|
1762
|
-
async function buildRuntime(
|
|
1763
|
-
outDir: string,
|
|
1764
|
-
options: BundlerOptions
|
|
1765
|
-
): Promise<{ success: boolean; outputPath: string; errors: string[] }> {
|
|
1766
|
-
const runtimePath =
|
|
1767
|
-
const outputName = "_runtime.js";
|
|
1768
|
-
|
|
1769
|
-
try {
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
// 빌드
|
|
1774
|
-
const result = await safeBuild({
|
|
1775
|
-
entrypoints: [runtimePath],
|
|
1776
|
-
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,
|
|
1777
1137
|
naming: outputName,
|
|
1778
1138
|
minify: shouldMinify(options),
|
|
1779
1139
|
sourcemap: options.sourcemap ? "external" : "none",
|
|
@@ -1784,29 +1144,24 @@ async function buildRuntime(
|
|
|
1784
1144
|
"process.env.NODE_ENV": nodeEnvDefine(options),
|
|
1785
1145
|
...options.define,
|
|
1786
1146
|
},
|
|
1787
|
-
});
|
|
1788
|
-
|
|
1789
|
-
if (!result.success) {
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
errors: [],
|
|
1806
|
-
};
|
|
1807
|
-
} catch (error: unknown) {
|
|
1808
|
-
// 예외 발생 시에도 디버깅을 위해 소스 파일을 남겨둠
|
|
1809
|
-
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[] = [];
|
|
1810
1165
|
const errObj = error as Record<string, unknown> | null;
|
|
1811
1166
|
if (errObj && Array.isArray(errObj.errors)) {
|
|
1812
1167
|
extra.push(...errObj.errors.map((e: unknown) => String((e as Record<string, unknown>)?.message || e)));
|