@mandujs/core 0.3.3 → 0.4.0
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 +2 -1
- package/src/bundler/build.ts +609 -0
- package/src/bundler/index.ts +7 -0
- package/src/bundler/types.ts +100 -0
- package/src/client/index.ts +68 -0
- package/src/client/island.ts +197 -0
- package/src/client/runtime.ts +335 -0
- package/src/filling/filling.ts +76 -5
- package/src/index.ts +2 -0
- package/src/runtime/ssr.ts +142 -2
- package/src/slot/corrector.ts +282 -0
- package/src/slot/index.ts +18 -0
- package/src/slot/validator.ts +241 -0
- package/src/spec/schema.ts +132 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Bundler Types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 번들 빌드 결과
|
|
7
|
+
*/
|
|
8
|
+
export interface BundleResult {
|
|
9
|
+
success: boolean;
|
|
10
|
+
outputs: BundleOutput[];
|
|
11
|
+
errors: string[];
|
|
12
|
+
manifest: BundleManifest;
|
|
13
|
+
stats: BundleStats;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 개별 번들 출력
|
|
18
|
+
*/
|
|
19
|
+
export interface BundleOutput {
|
|
20
|
+
/** 라우트 ID */
|
|
21
|
+
routeId: string;
|
|
22
|
+
/** 원본 엔트리포인트 */
|
|
23
|
+
entrypoint: string;
|
|
24
|
+
/** 출력 경로 (서버 기준) */
|
|
25
|
+
outputPath: string;
|
|
26
|
+
/** 파일 크기 (bytes) */
|
|
27
|
+
size: number;
|
|
28
|
+
/** gzip 압축 크기 (bytes) */
|
|
29
|
+
gzipSize: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 번들 매니페스트
|
|
34
|
+
*/
|
|
35
|
+
export interface BundleManifest {
|
|
36
|
+
/** 매니페스트 버전 */
|
|
37
|
+
version: number;
|
|
38
|
+
/** 빌드 시간 */
|
|
39
|
+
buildTime: string;
|
|
40
|
+
/** 환경 */
|
|
41
|
+
env: "development" | "production";
|
|
42
|
+
/** 라우트별 번들 정보 */
|
|
43
|
+
bundles: Record<
|
|
44
|
+
string,
|
|
45
|
+
{
|
|
46
|
+
/** JavaScript 번들 경로 */
|
|
47
|
+
js: string;
|
|
48
|
+
/** CSS 번들 경로 (있는 경우) */
|
|
49
|
+
css?: string;
|
|
50
|
+
/** 의존하는 공유 청크 */
|
|
51
|
+
dependencies: string[];
|
|
52
|
+
/** Hydration 우선순위 */
|
|
53
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
54
|
+
}
|
|
55
|
+
>;
|
|
56
|
+
/** 공유 청크 */
|
|
57
|
+
shared: {
|
|
58
|
+
/** Hydration 런타임 */
|
|
59
|
+
runtime: string;
|
|
60
|
+
/** 벤더 번들 (React 등) */
|
|
61
|
+
vendor: string;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 번들 통계
|
|
67
|
+
*/
|
|
68
|
+
export interface BundleStats {
|
|
69
|
+
/** 전체 크기 */
|
|
70
|
+
totalSize: number;
|
|
71
|
+
/** 전체 gzip 크기 */
|
|
72
|
+
totalGzipSize: number;
|
|
73
|
+
/** 가장 큰 번들 */
|
|
74
|
+
largestBundle: {
|
|
75
|
+
routeId: string;
|
|
76
|
+
size: number;
|
|
77
|
+
};
|
|
78
|
+
/** 빌드 시간 (ms) */
|
|
79
|
+
buildTime: number;
|
|
80
|
+
/** 번들 수 */
|
|
81
|
+
bundleCount: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 번들러 옵션
|
|
86
|
+
*/
|
|
87
|
+
export interface BundlerOptions {
|
|
88
|
+
/** 코드 압축 여부 (기본: production에서 true) */
|
|
89
|
+
minify?: boolean;
|
|
90
|
+
/** 소스맵 생성 여부 */
|
|
91
|
+
sourcemap?: boolean;
|
|
92
|
+
/** 파일 감시 모드 */
|
|
93
|
+
watch?: boolean;
|
|
94
|
+
/** 출력 디렉토리 (기본: .mandu/client) */
|
|
95
|
+
outDir?: string;
|
|
96
|
+
/** 외부 모듈 (번들에서 제외) */
|
|
97
|
+
external?: string[];
|
|
98
|
+
/** 환경 변수 주입 */
|
|
99
|
+
define?: Record<string, string>;
|
|
100
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Client Module 🏝️
|
|
3
|
+
* 클라이언트 사이드 hydration을 위한 API
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* // spec/slots/todos.client.ts
|
|
8
|
+
* import { Mandu } from "@mandujs/core/client";
|
|
9
|
+
*
|
|
10
|
+
* export default Mandu.island<TodosData>({
|
|
11
|
+
* setup: (data) => { ... },
|
|
12
|
+
* render: (props) => <TodoList {...props} />
|
|
13
|
+
* });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Island API
|
|
18
|
+
export {
|
|
19
|
+
island,
|
|
20
|
+
useServerData,
|
|
21
|
+
useHydrated,
|
|
22
|
+
useIslandEvent,
|
|
23
|
+
fetchApi,
|
|
24
|
+
type IslandDefinition,
|
|
25
|
+
type IslandMetadata,
|
|
26
|
+
type CompiledIsland,
|
|
27
|
+
type FetchOptions,
|
|
28
|
+
} from "./island";
|
|
29
|
+
|
|
30
|
+
// Runtime API
|
|
31
|
+
export {
|
|
32
|
+
registerIsland,
|
|
33
|
+
getRegisteredIslands,
|
|
34
|
+
getServerData,
|
|
35
|
+
hydrateIslands,
|
|
36
|
+
getHydrationState,
|
|
37
|
+
unmountIsland,
|
|
38
|
+
unmountAllIslands,
|
|
39
|
+
initializeRuntime,
|
|
40
|
+
type IslandLoader,
|
|
41
|
+
} from "./runtime";
|
|
42
|
+
|
|
43
|
+
// Re-export as Mandu namespace for consistent API
|
|
44
|
+
import { island } from "./island";
|
|
45
|
+
import { hydrateIslands, initializeRuntime } from "./runtime";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Mandu Client namespace
|
|
49
|
+
*/
|
|
50
|
+
export const Mandu = {
|
|
51
|
+
/**
|
|
52
|
+
* Create an island component
|
|
53
|
+
* @see island
|
|
54
|
+
*/
|
|
55
|
+
island,
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hydrate all islands on the page
|
|
59
|
+
* @see hydrateIslands
|
|
60
|
+
*/
|
|
61
|
+
hydrate: hydrateIslands,
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Initialize the hydration runtime
|
|
65
|
+
* @see initializeRuntime
|
|
66
|
+
*/
|
|
67
|
+
init: initializeRuntime,
|
|
68
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Island - Client Slot API 🏝️
|
|
3
|
+
* Hydration을 위한 클라이언트 사이드 컴포넌트 정의
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Island 정의 타입
|
|
10
|
+
* @template TServerData - SSR에서 전달받는 서버 데이터 타입
|
|
11
|
+
* @template TSetupResult - setup 함수가 반환하는 결과 타입
|
|
12
|
+
*/
|
|
13
|
+
export interface IslandDefinition<TServerData, TSetupResult> {
|
|
14
|
+
/**
|
|
15
|
+
* Setup Phase
|
|
16
|
+
* - 서버 데이터를 받아 클라이언트 상태 초기화
|
|
17
|
+
* - React hooks 사용 가능
|
|
18
|
+
* - 반환값이 render 함수에 전달됨
|
|
19
|
+
*/
|
|
20
|
+
setup: (serverData: TServerData) => TSetupResult;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Render Phase
|
|
24
|
+
* - setup에서 반환된 값을 props로 받음
|
|
25
|
+
* - 순수 렌더링 로직만 포함
|
|
26
|
+
*/
|
|
27
|
+
render: (props: TSetupResult) => ReactNode;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Optional: 에러 발생 시 표시할 fallback UI
|
|
31
|
+
*/
|
|
32
|
+
errorBoundary?: (error: Error, reset: () => void) => ReactNode;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Optional: 로딩 중 표시할 UI (progressive hydration용)
|
|
36
|
+
*/
|
|
37
|
+
loading?: () => ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Island 컴포넌트의 메타데이터
|
|
42
|
+
*/
|
|
43
|
+
export interface IslandMetadata {
|
|
44
|
+
/** Island 고유 식별자 */
|
|
45
|
+
id: string;
|
|
46
|
+
/** SSR 데이터 키 */
|
|
47
|
+
dataKey: string;
|
|
48
|
+
/** Hydration 우선순위 */
|
|
49
|
+
priority: "immediate" | "visible" | "idle" | "interaction";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 컴파일된 Island 컴포넌트 타입
|
|
54
|
+
*/
|
|
55
|
+
export interface CompiledIsland<TServerData, TSetupResult> {
|
|
56
|
+
/** Island 정의 */
|
|
57
|
+
definition: IslandDefinition<TServerData, TSetupResult>;
|
|
58
|
+
/** Island 메타데이터 (빌드 시 주입) */
|
|
59
|
+
__mandu_island: true;
|
|
60
|
+
/** Island ID (빌드 시 주입) */
|
|
61
|
+
__mandu_island_id?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Island 컴포넌트 생성
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* // spec/slots/todos.client.ts
|
|
70
|
+
* import { Mandu } from "@mandujs/core/client";
|
|
71
|
+
* import { useState, useCallback } from "react";
|
|
72
|
+
*
|
|
73
|
+
* interface TodosData {
|
|
74
|
+
* todos: Todo[];
|
|
75
|
+
* user: User | null;
|
|
76
|
+
* }
|
|
77
|
+
*
|
|
78
|
+
* export default Mandu.island<TodosData>({
|
|
79
|
+
* setup: (serverData) => {
|
|
80
|
+
* const [todos, setTodos] = useState(serverData.todos);
|
|
81
|
+
* const addTodo = useCallback(async (text: string) => {
|
|
82
|
+
* // ...
|
|
83
|
+
* }, []);
|
|
84
|
+
* return { todos, addTodo, user: serverData.user };
|
|
85
|
+
* },
|
|
86
|
+
* render: ({ todos, addTodo, user }) => (
|
|
87
|
+
* <div>
|
|
88
|
+
* {user && <span>Hello, {user.name}!</span>}
|
|
89
|
+
* <TodoList todos={todos} onAdd={addTodo} />
|
|
90
|
+
* </div>
|
|
91
|
+
* )
|
|
92
|
+
* });
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export function island<TServerData, TSetupResult = TServerData>(
|
|
96
|
+
definition: IslandDefinition<TServerData, TSetupResult>
|
|
97
|
+
): CompiledIsland<TServerData, TSetupResult> {
|
|
98
|
+
// Validate definition
|
|
99
|
+
if (typeof definition.setup !== "function") {
|
|
100
|
+
throw new Error("[Mandu Island] setup must be a function");
|
|
101
|
+
}
|
|
102
|
+
if (typeof definition.render !== "function") {
|
|
103
|
+
throw new Error("[Mandu Island] render must be a function");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
definition,
|
|
108
|
+
__mandu_island: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Island에서 사용할 수 있는 헬퍼 훅들
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* SSR 데이터에 안전하게 접근하는 훅
|
|
118
|
+
* 서버 데이터가 없는 경우 fallback 반환
|
|
119
|
+
*/
|
|
120
|
+
export function useServerData<T>(key: string, fallback: T): T {
|
|
121
|
+
if (typeof window === "undefined") {
|
|
122
|
+
return fallback;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const manduData = (window as any).__MANDU_DATA__;
|
|
126
|
+
if (!manduData || !(key in manduData)) {
|
|
127
|
+
return fallback;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return manduData[key] as T;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Hydration 상태를 추적하는 훅
|
|
135
|
+
*/
|
|
136
|
+
export function useHydrated(): boolean {
|
|
137
|
+
if (typeof window === "undefined") {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Island 간 통신을 위한 이벤트 훅
|
|
145
|
+
*/
|
|
146
|
+
export function useIslandEvent<T = unknown>(
|
|
147
|
+
eventName: string,
|
|
148
|
+
handler: (data: T) => void
|
|
149
|
+
): (data: T) => void {
|
|
150
|
+
if (typeof window === "undefined") {
|
|
151
|
+
return () => {};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 이벤트 리스너 등록
|
|
155
|
+
const customEventName = `mandu:island:${eventName}`;
|
|
156
|
+
|
|
157
|
+
const listener = (event: CustomEvent<T>) => {
|
|
158
|
+
handler(event.detail);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
window.addEventListener(customEventName, listener as EventListener);
|
|
162
|
+
|
|
163
|
+
// 이벤트 발송 함수 반환
|
|
164
|
+
return (data: T) => {
|
|
165
|
+
window.dispatchEvent(new CustomEvent(customEventName, { detail: data }));
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* API 호출 헬퍼
|
|
171
|
+
*/
|
|
172
|
+
export interface FetchOptions extends Omit<RequestInit, "body"> {
|
|
173
|
+
body?: unknown;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function fetchApi<T>(
|
|
177
|
+
url: string,
|
|
178
|
+
options: FetchOptions = {}
|
|
179
|
+
): Promise<T> {
|
|
180
|
+
const { body, headers = {}, ...rest } = options;
|
|
181
|
+
|
|
182
|
+
const response = await fetch(url, {
|
|
183
|
+
headers: {
|
|
184
|
+
"Content-Type": "application/json",
|
|
185
|
+
...headers,
|
|
186
|
+
},
|
|
187
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
188
|
+
...rest,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
193
|
+
throw new Error(error.message || `API Error: ${response.status}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return response.json();
|
|
197
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Hydration Runtime 🌊
|
|
3
|
+
* 브라우저에서 Island를 hydrate하는 런타임
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { hydrateRoot } from "react-dom/client";
|
|
7
|
+
import type { Root } from "react-dom/client";
|
|
8
|
+
import type { CompiledIsland } from "./island";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import React from "react";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Island 로더 타입
|
|
14
|
+
*/
|
|
15
|
+
export type IslandLoader = () => Promise<CompiledIsland<any, any>> | CompiledIsland<any, any>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Island 레지스트리
|
|
19
|
+
*/
|
|
20
|
+
const islandRegistry = new Map<string, IslandLoader>();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hydrated roots 추적
|
|
24
|
+
*/
|
|
25
|
+
const hydratedRoots = new Map<string, Root>();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hydration 상태 추적
|
|
29
|
+
*/
|
|
30
|
+
interface HydrationState {
|
|
31
|
+
total: number;
|
|
32
|
+
hydrated: number;
|
|
33
|
+
failed: number;
|
|
34
|
+
pending: Set<string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const hydrationState: HydrationState = {
|
|
38
|
+
total: 0,
|
|
39
|
+
hydrated: 0,
|
|
40
|
+
failed: 0,
|
|
41
|
+
pending: new Set(),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Island 등록
|
|
46
|
+
*/
|
|
47
|
+
export function registerIsland(id: string, loader: IslandLoader): void {
|
|
48
|
+
islandRegistry.set(id, loader);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 등록된 모든 Island 가져오기
|
|
53
|
+
*/
|
|
54
|
+
export function getRegisteredIslands(): string[] {
|
|
55
|
+
return Array.from(islandRegistry.keys());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 서버 데이터 가져오기
|
|
60
|
+
*/
|
|
61
|
+
export function getServerData<T = unknown>(islandId: string): T | undefined {
|
|
62
|
+
if (typeof window === "undefined") {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const manduData = (window as any).__MANDU_DATA__;
|
|
67
|
+
if (!manduData) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return manduData[islandId]?.serverData as T;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Priority-based hydration 스케줄러
|
|
76
|
+
*/
|
|
77
|
+
type HydrationPriority = "immediate" | "visible" | "idle" | "interaction";
|
|
78
|
+
|
|
79
|
+
function scheduleHydration(
|
|
80
|
+
element: HTMLElement,
|
|
81
|
+
id: string,
|
|
82
|
+
priority: HydrationPriority,
|
|
83
|
+
serverData: unknown
|
|
84
|
+
): void {
|
|
85
|
+
switch (priority) {
|
|
86
|
+
case "immediate":
|
|
87
|
+
hydrateIsland(element, id, serverData);
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case "visible":
|
|
91
|
+
if ("IntersectionObserver" in window) {
|
|
92
|
+
const observer = new IntersectionObserver(
|
|
93
|
+
(entries) => {
|
|
94
|
+
if (entries[0].isIntersecting) {
|
|
95
|
+
observer.disconnect();
|
|
96
|
+
hydrateIsland(element, id, serverData);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
{ rootMargin: "50px" }
|
|
100
|
+
);
|
|
101
|
+
observer.observe(element);
|
|
102
|
+
} else {
|
|
103
|
+
// Fallback for older browsers
|
|
104
|
+
hydrateIsland(element, id, serverData);
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
case "idle":
|
|
109
|
+
if ("requestIdleCallback" in window) {
|
|
110
|
+
(window as any).requestIdleCallback(() => {
|
|
111
|
+
hydrateIsland(element, id, serverData);
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
// Fallback
|
|
115
|
+
setTimeout(() => hydrateIsland(element, id, serverData), 200);
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
|
|
119
|
+
case "interaction": {
|
|
120
|
+
const hydrate = () => {
|
|
121
|
+
element.removeEventListener("mouseenter", hydrate);
|
|
122
|
+
element.removeEventListener("focusin", hydrate);
|
|
123
|
+
element.removeEventListener("touchstart", hydrate);
|
|
124
|
+
hydrateIsland(element, id, serverData);
|
|
125
|
+
};
|
|
126
|
+
element.addEventListener("mouseenter", hydrate, { once: true, passive: true });
|
|
127
|
+
element.addEventListener("focusin", hydrate, { once: true });
|
|
128
|
+
element.addEventListener("touchstart", hydrate, { once: true, passive: true });
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 단일 Island hydrate
|
|
136
|
+
*/
|
|
137
|
+
async function hydrateIsland(
|
|
138
|
+
element: HTMLElement,
|
|
139
|
+
id: string,
|
|
140
|
+
serverData: unknown
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
const loader = islandRegistry.get(id);
|
|
143
|
+
if (!loader) {
|
|
144
|
+
console.warn(`[Mandu] Island not registered: ${id}`);
|
|
145
|
+
hydrationState.failed++;
|
|
146
|
+
hydrationState.pending.delete(id);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
// 로더 실행 (dynamic import 또는 직접 참조)
|
|
152
|
+
const island = await Promise.resolve(loader());
|
|
153
|
+
|
|
154
|
+
if (!island.__mandu_island) {
|
|
155
|
+
throw new Error(`[Mandu] Invalid island: ${id}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { definition } = island;
|
|
159
|
+
|
|
160
|
+
// Island 컴포넌트 생성
|
|
161
|
+
function IslandComponent(): ReactNode {
|
|
162
|
+
const setupResult = definition.setup(serverData);
|
|
163
|
+
return definition.render(setupResult);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ErrorBoundary가 있으면 감싸기
|
|
167
|
+
let content: ReactNode;
|
|
168
|
+
if (definition.errorBoundary) {
|
|
169
|
+
content = React.createElement(
|
|
170
|
+
ManduErrorBoundary,
|
|
171
|
+
{
|
|
172
|
+
fallback: (error: Error, reset: () => void) => definition.errorBoundary!(error, reset),
|
|
173
|
+
},
|
|
174
|
+
React.createElement(IslandComponent)
|
|
175
|
+
);
|
|
176
|
+
} else {
|
|
177
|
+
content = React.createElement(IslandComponent);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Hydrate
|
|
181
|
+
const root = hydrateRoot(element, content);
|
|
182
|
+
hydratedRoots.set(id, root);
|
|
183
|
+
|
|
184
|
+
// 상태 업데이트
|
|
185
|
+
element.setAttribute("data-mandu-hydrated", "true");
|
|
186
|
+
hydrationState.hydrated++;
|
|
187
|
+
hydrationState.pending.delete(id);
|
|
188
|
+
|
|
189
|
+
// 성능 마커
|
|
190
|
+
if (typeof performance !== "undefined" && performance.mark) {
|
|
191
|
+
performance.mark(`mandu-hydrated-${id}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Hydration 완료 이벤트
|
|
195
|
+
element.dispatchEvent(
|
|
196
|
+
new CustomEvent("mandu:hydrated", {
|
|
197
|
+
bubbles: true,
|
|
198
|
+
detail: { id, serverData },
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
console.error(`[Mandu] Hydration failed for island ${id}:`, error);
|
|
203
|
+
hydrationState.failed++;
|
|
204
|
+
hydrationState.pending.delete(id);
|
|
205
|
+
|
|
206
|
+
// 에러 상태 표시
|
|
207
|
+
element.setAttribute("data-mandu-error", "true");
|
|
208
|
+
|
|
209
|
+
// 에러 이벤트
|
|
210
|
+
element.dispatchEvent(
|
|
211
|
+
new CustomEvent("mandu:hydration-error", {
|
|
212
|
+
bubbles: true,
|
|
213
|
+
detail: { id, error },
|
|
214
|
+
})
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 모든 Island hydrate 시작
|
|
221
|
+
*/
|
|
222
|
+
export async function hydrateIslands(): Promise<void> {
|
|
223
|
+
if (typeof document === "undefined") {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const islands = document.querySelectorAll<HTMLElement>("[data-mandu-island]");
|
|
228
|
+
const manduData = (window as any).__MANDU_DATA__ || {};
|
|
229
|
+
|
|
230
|
+
hydrationState.total = islands.length;
|
|
231
|
+
|
|
232
|
+
for (const element of islands) {
|
|
233
|
+
const id = element.getAttribute("data-mandu-island");
|
|
234
|
+
if (!id) continue;
|
|
235
|
+
|
|
236
|
+
const priority = (element.getAttribute("data-mandu-priority") || "visible") as HydrationPriority;
|
|
237
|
+
const data = manduData[id]?.serverData || {};
|
|
238
|
+
|
|
239
|
+
hydrationState.pending.add(id);
|
|
240
|
+
scheduleHydration(element, id, priority, data);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Hydration 상태 조회
|
|
246
|
+
*/
|
|
247
|
+
export function getHydrationState(): Readonly<HydrationState> {
|
|
248
|
+
return { ...hydrationState };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 특정 Island unmount
|
|
253
|
+
*/
|
|
254
|
+
export function unmountIsland(id: string): boolean {
|
|
255
|
+
const root = hydratedRoots.get(id);
|
|
256
|
+
if (!root) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
root.unmount();
|
|
261
|
+
hydratedRoots.delete(id);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 모든 Island unmount
|
|
267
|
+
*/
|
|
268
|
+
export function unmountAllIslands(): void {
|
|
269
|
+
for (const [id, root] of hydratedRoots) {
|
|
270
|
+
root.unmount();
|
|
271
|
+
hydratedRoots.delete(id);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 간단한 ErrorBoundary 컴포넌트
|
|
277
|
+
*/
|
|
278
|
+
interface ErrorBoundaryProps {
|
|
279
|
+
children: ReactNode;
|
|
280
|
+
fallback: (error: Error, reset: () => void) => ReactNode;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
interface ErrorBoundaryState {
|
|
284
|
+
error: Error | null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
class ManduErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
288
|
+
constructor(props: ErrorBoundaryProps) {
|
|
289
|
+
super(props);
|
|
290
|
+
this.state = { error: null };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
294
|
+
return { error };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
|
298
|
+
console.error("[Mandu] Island error:", error, errorInfo);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
reset = (): void => {
|
|
302
|
+
this.setState({ error: null });
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
render(): ReactNode {
|
|
306
|
+
if (this.state.error) {
|
|
307
|
+
return this.props.fallback(this.state.error, this.reset);
|
|
308
|
+
}
|
|
309
|
+
return this.props.children;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 자동 초기화 (스크립트 로드 시)
|
|
315
|
+
*/
|
|
316
|
+
export function initializeRuntime(): void {
|
|
317
|
+
if (typeof document === "undefined") {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// DOM이 준비되면 hydration 시작
|
|
322
|
+
if (document.readyState === "loading") {
|
|
323
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
324
|
+
hydrateIslands();
|
|
325
|
+
});
|
|
326
|
+
} else {
|
|
327
|
+
// 이미 DOM이 준비된 경우
|
|
328
|
+
hydrateIslands();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 자동 초기화 여부 (번들 시 설정)
|
|
333
|
+
if (typeof window !== "undefined" && (window as any).__MANDU_AUTO_INIT__ !== false) {
|
|
334
|
+
initializeRuntime();
|
|
335
|
+
}
|