@mandujs/core 0.3.4 → 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.
@@ -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
+ }