@mandujs/core 0.3.4 β 0.4.1
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 +41 -40
- package/src/bundler/build.ts +609 -0
- package/src/bundler/dev.ts +362 -0
- package/src/bundler/index.ts +8 -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 +1 -0
- package/src/runtime/server.ts +24 -3
- package/src/runtime/ssr.ts +199 -2
- package/src/spec/schema.ts +132 -0
|
@@ -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
|
+
}
|
package/src/filling/filling.ts
CHANGED
|
@@ -15,10 +15,14 @@ export type Guard = (ctx: ManduContext) => symbol | Response | Promise<symbol |
|
|
|
15
15
|
/** HTTP methods */
|
|
16
16
|
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
/** Loader function type - SSR λ°μ΄ν° λ‘λ© */
|
|
19
|
+
export type Loader<T = unknown> = (ctx: ManduContext) => T | Promise<T>;
|
|
20
|
+
|
|
21
|
+
interface FillingConfig<TLoaderData = unknown> {
|
|
19
22
|
handlers: Map<HttpMethod, Handler>;
|
|
20
23
|
guards: Guard[];
|
|
21
24
|
methodGuards: Map<HttpMethod, Guard[]>;
|
|
25
|
+
loader?: Loader<TLoaderData>;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
/**
|
|
@@ -30,14 +34,64 @@ interface FillingConfig {
|
|
|
30
34
|
* .get(ctx => ctx.ok({ message: 'Hello!' }))
|
|
31
35
|
* .post(ctx => ctx.created({ id: 1 }))
|
|
32
36
|
* ```
|
|
37
|
+
*
|
|
38
|
+
* @example with loader
|
|
39
|
+
* ```typescript
|
|
40
|
+
* export default Mandu.filling<{ todos: Todo[] }>()
|
|
41
|
+
* .loader(async (ctx) => {
|
|
42
|
+
* const todos = await db.todos.findMany();
|
|
43
|
+
* return { todos };
|
|
44
|
+
* })
|
|
45
|
+
* .get(ctx => ctx.ok(ctx.get('loaderData')))
|
|
46
|
+
* ```
|
|
33
47
|
*/
|
|
34
|
-
export class ManduFilling {
|
|
35
|
-
private config: FillingConfig = {
|
|
48
|
+
export class ManduFilling<TLoaderData = unknown> {
|
|
49
|
+
private config: FillingConfig<TLoaderData> = {
|
|
36
50
|
handlers: new Map(),
|
|
37
51
|
guards: [],
|
|
38
52
|
methodGuards: new Map(),
|
|
39
53
|
};
|
|
40
54
|
|
|
55
|
+
// ============================================
|
|
56
|
+
// π₯ SSR Loader
|
|
57
|
+
// ============================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Define SSR data loader
|
|
61
|
+
* νμ΄μ§ λ λλ§ μ μλ²μμ λ°μ΄ν°λ₯Ό λ‘λν©λλ€.
|
|
62
|
+
* λ‘λλ λ°μ΄ν°λ ν΄λΌμ΄μΈνΈλ‘ μ λ¬λμ΄ hydrationμ μ¬μ©λ©λλ€.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* .loader(async (ctx) => {
|
|
67
|
+
* const todos = await db.todos.findMany();
|
|
68
|
+
* return { todos, user: ctx.get('user') };
|
|
69
|
+
* })
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
loader(loaderFn: Loader<TLoaderData>): this {
|
|
73
|
+
this.config.loader = loaderFn;
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Execute loader and return data
|
|
79
|
+
* @internal Used by SSR runtime
|
|
80
|
+
*/
|
|
81
|
+
async executeLoader(ctx: ManduContext): Promise<TLoaderData | undefined> {
|
|
82
|
+
if (!this.config.loader) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return await this.config.loader(ctx);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Check if loader is defined
|
|
90
|
+
*/
|
|
91
|
+
hasLoader(): boolean {
|
|
92
|
+
return !!this.config.loader;
|
|
93
|
+
}
|
|
94
|
+
|
|
41
95
|
// ============================================
|
|
42
96
|
// π₯ HTTP Method Handlers
|
|
43
97
|
// ============================================
|
|
@@ -242,9 +296,26 @@ export const Mandu = {
|
|
|
242
296
|
* export default Mandu.filling()
|
|
243
297
|
* .get(ctx => ctx.ok({ message: 'Hello!' }))
|
|
244
298
|
* ```
|
|
299
|
+
*
|
|
300
|
+
* @example with loader data type
|
|
301
|
+
* ```typescript
|
|
302
|
+
* import { Mandu } from '@mandujs/core'
|
|
303
|
+
*
|
|
304
|
+
* interface LoaderData {
|
|
305
|
+
* todos: Todo[];
|
|
306
|
+
* user: User | null;
|
|
307
|
+
* }
|
|
308
|
+
*
|
|
309
|
+
* export default Mandu.filling<LoaderData>()
|
|
310
|
+
* .loader(async (ctx) => {
|
|
311
|
+
* const todos = await db.todos.findMany();
|
|
312
|
+
* return { todos, user: null };
|
|
313
|
+
* })
|
|
314
|
+
* .get(ctx => ctx.ok(ctx.get('loaderData')))
|
|
315
|
+
* ```
|
|
245
316
|
*/
|
|
246
|
-
filling(): ManduFilling {
|
|
247
|
-
return new ManduFilling();
|
|
317
|
+
filling<TLoaderData = unknown>(): ManduFilling<TLoaderData> {
|
|
318
|
+
return new ManduFilling<TLoaderData>();
|
|
248
319
|
},
|
|
249
320
|
|
|
250
321
|
/**
|
package/src/index.ts
CHANGED
package/src/runtime/server.ts
CHANGED
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
export interface ServerOptions {
|
|
15
15
|
port?: number;
|
|
16
16
|
hostname?: string;
|
|
17
|
+
/** κ°λ° λͺ¨λ μ¬λΆ */
|
|
18
|
+
isDev?: boolean;
|
|
19
|
+
/** HMR ν¬νΈ (κ°λ° λͺ¨λμμ μ¬μ©) */
|
|
20
|
+
hmrPort?: number;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export interface ManduServer {
|
|
@@ -40,6 +44,9 @@ const pageLoaders: Map<string, PageLoader> = new Map();
|
|
|
40
44
|
const routeComponents: Map<string, RouteComponent> = new Map();
|
|
41
45
|
let createAppFn: CreateAppFn | null = null;
|
|
42
46
|
|
|
47
|
+
// Dev mode settings (module-level for handleRequest access)
|
|
48
|
+
let devModeSettings: { isDev: boolean; hmrPort?: number } = { isDev: false };
|
|
49
|
+
|
|
43
50
|
export function registerApiHandler(routeId: string, handler: ApiHandler): void {
|
|
44
51
|
apiHandlers.set(routeId, handler);
|
|
45
52
|
}
|
|
@@ -126,7 +133,11 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
|
126
133
|
params,
|
|
127
134
|
});
|
|
128
135
|
|
|
129
|
-
return renderSSR(app, {
|
|
136
|
+
return renderSSR(app, {
|
|
137
|
+
title: `${route.id} - Mandu`,
|
|
138
|
+
isDev: devModeSettings.isDev,
|
|
139
|
+
hmrPort: devModeSettings.hmrPort,
|
|
140
|
+
});
|
|
130
141
|
} catch (err) {
|
|
131
142
|
const ssrError = createSSRErrorResponse(
|
|
132
143
|
route.id,
|
|
@@ -159,7 +170,10 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
export function startServer(manifest: RoutesManifest, options: ServerOptions = {}): ManduServer {
|
|
162
|
-
const { port = 3000, hostname = "localhost" } = options;
|
|
173
|
+
const { port = 3000, hostname = "localhost", isDev = false, hmrPort } = options;
|
|
174
|
+
|
|
175
|
+
// Dev mode settings μ μ₯
|
|
176
|
+
devModeSettings = { isDev, hmrPort };
|
|
163
177
|
|
|
164
178
|
const router = new Router(manifest.routes);
|
|
165
179
|
|
|
@@ -169,7 +183,14 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
169
183
|
fetch: (req) => handleRequest(req, router),
|
|
170
184
|
});
|
|
171
185
|
|
|
172
|
-
|
|
186
|
+
if (isDev) {
|
|
187
|
+
console.log(`π₯ Mandu Dev Server running at http://${hostname}:${port}`);
|
|
188
|
+
if (hmrPort) {
|
|
189
|
+
console.log(`π₯ HMR enabled on port ${hmrPort + 1}`);
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
console.log(`π₯ Mandu server running at http://${hostname}:${port}`);
|
|
193
|
+
}
|
|
173
194
|
|
|
174
195
|
return {
|
|
175
196
|
server,
|