@mandujs/core 0.5.6 โ 0.6.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 +1 -1
- package/src/bundler/build.ts +266 -0
- package/src/bundler/types.ts +2 -0
- package/src/client/Link.tsx +209 -0
- package/src/client/hooks.ts +267 -0
- package/src/client/index.ts +81 -2
- package/src/client/router.ts +387 -0
- package/src/generator/templates.ts +47 -0
- package/src/runtime/server.ts +96 -7
- package/src/runtime/ssr.ts +65 -0
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Client-side Router ๐งญ
|
|
3
|
+
* SPA ์คํ์ผ ๋ค๋น๊ฒ์ด์
์ ์ํ ํด๋ผ์ด์ธํธ ๋ผ์ฐํฐ
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
|
|
8
|
+
// ========== Types ==========
|
|
9
|
+
|
|
10
|
+
export interface RouteInfo {
|
|
11
|
+
id: string;
|
|
12
|
+
pattern: string;
|
|
13
|
+
params: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface NavigationState {
|
|
17
|
+
state: "idle" | "loading";
|
|
18
|
+
location?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RouterState {
|
|
22
|
+
currentRoute: RouteInfo | null;
|
|
23
|
+
loaderData: unknown;
|
|
24
|
+
navigation: NavigationState;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NavigateOptions {
|
|
28
|
+
/** history.replaceState ์ฌ์ฉ ์ฌ๋ถ */
|
|
29
|
+
replace?: boolean;
|
|
30
|
+
/** ์คํฌ๋กค ์์น ๋ณต์ ์ฌ๋ถ */
|
|
31
|
+
scroll?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type RouterListener = (state: RouterState) => void;
|
|
35
|
+
|
|
36
|
+
// ========== Router State ==========
|
|
37
|
+
|
|
38
|
+
let routerState: RouterState = {
|
|
39
|
+
currentRoute: null,
|
|
40
|
+
loaderData: undefined,
|
|
41
|
+
navigation: { state: "idle" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const listeners = new Set<RouterListener>();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* ์ด๊ธฐํ: ์๋ฒ์์ ์ ๋ฌ๋ ๋ผ์ฐํธ ์ ๋ณด๋ก ์ํ ์ค์
|
|
48
|
+
*/
|
|
49
|
+
function initializeFromServer(): void {
|
|
50
|
+
if (typeof window === "undefined") return;
|
|
51
|
+
|
|
52
|
+
const route = (window as any).__MANDU_ROUTE__;
|
|
53
|
+
const data = (window as any).__MANDU_DATA__;
|
|
54
|
+
|
|
55
|
+
if (route) {
|
|
56
|
+
// URL์์ ์ค์ params ์ถ์ถ
|
|
57
|
+
const params = extractParamsFromPath(route.pattern, window.location.pathname);
|
|
58
|
+
|
|
59
|
+
routerState = {
|
|
60
|
+
currentRoute: {
|
|
61
|
+
id: route.id,
|
|
62
|
+
pattern: route.pattern,
|
|
63
|
+
params,
|
|
64
|
+
},
|
|
65
|
+
loaderData: data?.[route.id]?.serverData,
|
|
66
|
+
navigation: { state: "idle" },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ========== Pattern Matching ==========
|
|
72
|
+
|
|
73
|
+
interface CompiledPattern {
|
|
74
|
+
regex: RegExp;
|
|
75
|
+
paramNames: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const patternCache = new Map<string, CompiledPattern>();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* ํจํด์ ์ ๊ท์์ผ๋ก ์ปดํ์ผ
|
|
82
|
+
*/
|
|
83
|
+
function compilePattern(pattern: string): CompiledPattern {
|
|
84
|
+
const cached = patternCache.get(pattern);
|
|
85
|
+
if (cached) return cached;
|
|
86
|
+
|
|
87
|
+
const paramNames: string[] = [];
|
|
88
|
+
const PARAM_PLACEHOLDER = "\x00PARAM\x00";
|
|
89
|
+
const paramMatches: string[] = [];
|
|
90
|
+
|
|
91
|
+
const withPlaceholders = pattern.replace(
|
|
92
|
+
/:([a-zA-Z_][a-zA-Z0-9_]*)/g,
|
|
93
|
+
(_, paramName) => {
|
|
94
|
+
paramMatches.push(paramName);
|
|
95
|
+
return PARAM_PLACEHOLDER;
|
|
96
|
+
}
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const escaped = withPlaceholders.replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&");
|
|
100
|
+
|
|
101
|
+
let paramIndex = 0;
|
|
102
|
+
const regexStr = escaped.replace(
|
|
103
|
+
new RegExp(PARAM_PLACEHOLDER.replace(/\x00/g, "\\x00"), "g"),
|
|
104
|
+
() => {
|
|
105
|
+
paramNames.push(paramMatches[paramIndex++]);
|
|
106
|
+
return "([^/]+)";
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const compiled = {
|
|
111
|
+
regex: new RegExp(`^${regexStr}$`),
|
|
112
|
+
paramNames,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
patternCache.set(pattern, compiled);
|
|
116
|
+
return compiled;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* ํจํด์์ ํ๋ผ๋ฏธํฐ ์ถ์ถ
|
|
121
|
+
*/
|
|
122
|
+
function extractParamsFromPath(
|
|
123
|
+
pattern: string,
|
|
124
|
+
pathname: string
|
|
125
|
+
): Record<string, string> {
|
|
126
|
+
const compiled = compilePattern(pattern);
|
|
127
|
+
const match = pathname.match(compiled.regex);
|
|
128
|
+
|
|
129
|
+
if (!match) return {};
|
|
130
|
+
|
|
131
|
+
const params: Record<string, string> = {};
|
|
132
|
+
compiled.paramNames.forEach((name, index) => {
|
|
133
|
+
params[name] = match[index + 1];
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return params;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ========== Navigation ==========
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* ํ์ด์ง ๋ค๋น๊ฒ์ด์
|
|
143
|
+
*/
|
|
144
|
+
export async function navigate(
|
|
145
|
+
to: string,
|
|
146
|
+
options: NavigateOptions = {}
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
const { replace = false, scroll = true } = options;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const url = new URL(to, window.location.origin);
|
|
152
|
+
|
|
153
|
+
// ์ธ๋ถ URL์ ์ผ๋ฐ ๋ค๋น๊ฒ์ด์
|
|
154
|
+
if (url.origin !== window.location.origin) {
|
|
155
|
+
window.location.href = to;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ๋ก๋ฉ ์ํ ์์
|
|
160
|
+
routerState = {
|
|
161
|
+
...routerState,
|
|
162
|
+
navigation: { state: "loading", location: to },
|
|
163
|
+
};
|
|
164
|
+
notifyListeners();
|
|
165
|
+
|
|
166
|
+
// ๋ฐ์ดํฐ fetch
|
|
167
|
+
const dataUrl = `${url.pathname}${url.search ? url.search + "&" : "?"}_data=1`;
|
|
168
|
+
const response = await fetch(dataUrl);
|
|
169
|
+
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
// ์๋ฌ ์ full navigation fallback
|
|
172
|
+
window.location.href = to;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const data = await response.json();
|
|
177
|
+
|
|
178
|
+
// History ์
๋ฐ์ดํธ
|
|
179
|
+
const historyState = { routeId: data.routeId, params: data.params };
|
|
180
|
+
if (replace) {
|
|
181
|
+
history.replaceState(historyState, "", to);
|
|
182
|
+
} else {
|
|
183
|
+
history.pushState(historyState, "", to);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ์ํ ์
๋ฐ์ดํธ
|
|
187
|
+
routerState = {
|
|
188
|
+
currentRoute: {
|
|
189
|
+
id: data.routeId,
|
|
190
|
+
pattern: data.pattern,
|
|
191
|
+
params: data.params,
|
|
192
|
+
},
|
|
193
|
+
loaderData: data.loaderData,
|
|
194
|
+
navigation: { state: "idle" },
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// __MANDU_DATA__ ์
๋ฐ์ดํธ
|
|
198
|
+
if (typeof window !== "undefined") {
|
|
199
|
+
(window as any).__MANDU_DATA__ = {
|
|
200
|
+
...(window as any).__MANDU_DATA__,
|
|
201
|
+
[data.routeId]: { serverData: data.loaderData },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
notifyListeners();
|
|
206
|
+
|
|
207
|
+
// ์คํฌ๋กค ๋ณต์
|
|
208
|
+
if (scroll) {
|
|
209
|
+
window.scrollTo(0, 0);
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
console.error("[Mandu Router] Navigation failed:", error);
|
|
213
|
+
// ์๋ฌ ์ full navigation fallback
|
|
214
|
+
window.location.href = to;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* ๋ค๋ก๊ฐ๊ธฐ/์์ผ๋ก๊ฐ๊ธฐ ์ฒ๋ฆฌ
|
|
220
|
+
*/
|
|
221
|
+
function handlePopState(event: PopStateEvent): void {
|
|
222
|
+
const state = event.state;
|
|
223
|
+
|
|
224
|
+
if (state?.routeId) {
|
|
225
|
+
// ์ด๋ฏธ ๋ฐฉ๋ฌธํ ํ์ด์ง - ๋ฐ์ดํฐ ๋ค์ fetch
|
|
226
|
+
navigate(window.location.pathname + window.location.search, {
|
|
227
|
+
replace: true,
|
|
228
|
+
scroll: false,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ========== State Management ==========
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* ๋ฆฌ์ค๋์๊ฒ ์ํ ๋ณ๊ฒฝ ์๋ฆผ
|
|
237
|
+
*/
|
|
238
|
+
function notifyListeners(): void {
|
|
239
|
+
for (const listener of listeners) {
|
|
240
|
+
try {
|
|
241
|
+
listener(routerState);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error("[Mandu Router] Listener error:", error);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* ์ํ ๋ณ๊ฒฝ ๊ตฌ๋
|
|
250
|
+
*/
|
|
251
|
+
export function subscribe(listener: RouterListener): () => void {
|
|
252
|
+
listeners.add(listener);
|
|
253
|
+
return () => listeners.delete(listener);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* ํ์ฌ ๋ผ์ฐํฐ ์ํ ๊ฐ์ ธ์ค๊ธฐ
|
|
258
|
+
*/
|
|
259
|
+
export function getRouterState(): RouterState {
|
|
260
|
+
return routerState;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* ํ์ฌ ๋ผ์ฐํธ ์ ๋ณด ๊ฐ์ ธ์ค๊ธฐ
|
|
265
|
+
*/
|
|
266
|
+
export function getCurrentRoute(): RouteInfo | null {
|
|
267
|
+
return routerState.currentRoute;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* ํ์ฌ loader ๋ฐ์ดํฐ ๊ฐ์ ธ์ค๊ธฐ
|
|
272
|
+
*/
|
|
273
|
+
export function getLoaderData<T = unknown>(): T | undefined {
|
|
274
|
+
return routerState.loaderData as T | undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* ๋ค๋น๊ฒ์ด์
์ํ ๊ฐ์ ธ์ค๊ธฐ
|
|
279
|
+
*/
|
|
280
|
+
export function getNavigationState(): NavigationState {
|
|
281
|
+
return routerState.navigation;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ========== Link Click Handler ==========
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* ๋งํฌ ํด๋ฆญ ์ด๋ฒคํธ ํธ๋ค๋ฌ (์ด๋ฒคํธ ์์์ฉ)
|
|
288
|
+
*/
|
|
289
|
+
function handleLinkClick(event: MouseEvent): void {
|
|
290
|
+
// ๊ธฐ๋ณธ ๋์ ์กฐ๊ฑด ์ฒดํฌ
|
|
291
|
+
if (
|
|
292
|
+
event.defaultPrevented ||
|
|
293
|
+
event.button !== 0 ||
|
|
294
|
+
event.metaKey ||
|
|
295
|
+
event.altKey ||
|
|
296
|
+
event.ctrlKey ||
|
|
297
|
+
event.shiftKey
|
|
298
|
+
) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ๊ฐ์ฅ ๊ฐ๊น์ด ์ต์ปค ํ๊ทธ ์ฐพ๊ธฐ
|
|
303
|
+
const anchor = (event.target as HTMLElement).closest("a");
|
|
304
|
+
if (!anchor) return;
|
|
305
|
+
|
|
306
|
+
// data-mandu-link ์์ฑ์ด ์๋ ๋งํฌ๋ง ์ฒ๋ฆฌ
|
|
307
|
+
if (!anchor.hasAttribute("data-mandu-link")) return;
|
|
308
|
+
|
|
309
|
+
const href = anchor.getAttribute("href");
|
|
310
|
+
if (!href) return;
|
|
311
|
+
|
|
312
|
+
// ์ธ๋ถ ๋งํฌ ์ฒดํฌ
|
|
313
|
+
try {
|
|
314
|
+
const url = new URL(href, window.location.origin);
|
|
315
|
+
if (url.origin !== window.location.origin) return;
|
|
316
|
+
} catch {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ๊ธฐ๋ณธ ๋์ ๋ฐฉ์ง ๋ฐ Client-side ๋ค๋น๊ฒ์ด์
|
|
321
|
+
event.preventDefault();
|
|
322
|
+
navigate(href);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ========== Prefetch ==========
|
|
326
|
+
|
|
327
|
+
const prefetchedUrls = new Set<string>();
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* ํ์ด์ง ๋ฐ์ดํฐ ๋ฏธ๋ฆฌ ๋ก๋
|
|
331
|
+
*/
|
|
332
|
+
export async function prefetch(url: string): Promise<void> {
|
|
333
|
+
if (prefetchedUrls.has(url)) return;
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const dataUrl = `${url}${url.includes("?") ? "&" : "?"}_data=1`;
|
|
337
|
+
await fetch(dataUrl, { priority: "low" } as RequestInit);
|
|
338
|
+
prefetchedUrls.add(url);
|
|
339
|
+
} catch {
|
|
340
|
+
// Prefetch ์คํจ๋ ๋ฌด์
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ========== Initialization ==========
|
|
345
|
+
|
|
346
|
+
let initialized = false;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* ๋ผ์ฐํฐ ์ด๊ธฐํ
|
|
350
|
+
*/
|
|
351
|
+
export function initializeRouter(): void {
|
|
352
|
+
if (typeof window === "undefined" || initialized) return;
|
|
353
|
+
|
|
354
|
+
initialized = true;
|
|
355
|
+
|
|
356
|
+
// ์๋ฒ ๋ฐ์ดํฐ๋ก ์ด๊ธฐํ
|
|
357
|
+
initializeFromServer();
|
|
358
|
+
|
|
359
|
+
// popstate ์ด๋ฒคํธ ๋ฆฌ์ค๋
|
|
360
|
+
window.addEventListener("popstate", handlePopState);
|
|
361
|
+
|
|
362
|
+
// ๋งํฌ ํด๋ฆญ ์ด๋ฒคํธ ์์
|
|
363
|
+
document.addEventListener("click", handleLinkClick);
|
|
364
|
+
|
|
365
|
+
console.log("[Mandu Router] Initialized");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* ๋ผ์ฐํฐ ์ ๋ฆฌ
|
|
370
|
+
*/
|
|
371
|
+
export function cleanupRouter(): void {
|
|
372
|
+
if (typeof window === "undefined" || !initialized) return;
|
|
373
|
+
|
|
374
|
+
window.removeEventListener("popstate", handlePopState);
|
|
375
|
+
document.removeEventListener("click", handleLinkClick);
|
|
376
|
+
listeners.clear();
|
|
377
|
+
initialized = false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ์๋ ์ด๊ธฐํ (DOM ์ค๋น ์)
|
|
381
|
+
if (typeof window !== "undefined") {
|
|
382
|
+
if (document.readyState === "loading") {
|
|
383
|
+
document.addEventListener("DOMContentLoaded", initializeRouter);
|
|
384
|
+
} else {
|
|
385
|
+
initializeRouter();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
@@ -190,6 +190,13 @@ function computeSlotImportPath(slotModule: string, fromDir: string): string {
|
|
|
190
190
|
|
|
191
191
|
export function generatePageComponent(route: RouteSpec): string {
|
|
192
192
|
const pageName = toPascalCase(route.id);
|
|
193
|
+
|
|
194
|
+
// slotModule์ด ์์ผ๋ฉด PageHandler ํ์์ผ๋ก ์์ฑ (filling ํฌํจ)
|
|
195
|
+
if (route.slotModule) {
|
|
196
|
+
return generatePageHandlerWithSlot(route);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// slotModule์ด ์์ผ๋ฉด ๊ธฐ์กด ๋ฐฉ์
|
|
193
200
|
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
194
201
|
// Route ID: ${route.id}
|
|
195
202
|
// Pattern: ${route.pattern}
|
|
@@ -198,6 +205,7 @@ import React from "react";
|
|
|
198
205
|
|
|
199
206
|
interface Props {
|
|
200
207
|
params: Record<string, string>;
|
|
208
|
+
loaderData?: unknown;
|
|
201
209
|
}
|
|
202
210
|
|
|
203
211
|
export default function ${pageName}Page({ params }: Props): React.ReactElement {
|
|
@@ -210,6 +218,45 @@ export default function ${pageName}Page({ params }: Props): React.ReactElement {
|
|
|
210
218
|
`;
|
|
211
219
|
}
|
|
212
220
|
|
|
221
|
+
/**
|
|
222
|
+
* slotModule์ด ์๋ Page Route์ฉ Handler ์์ฑ
|
|
223
|
+
* - component์ filling์ ํจ๊ป export
|
|
224
|
+
* - server.ts์์ filling.executeLoader() ํธ์ถ ๊ฐ๋ฅ
|
|
225
|
+
*/
|
|
226
|
+
export function generatePageHandlerWithSlot(route: RouteSpec): string {
|
|
227
|
+
const pageName = toPascalCase(route.id);
|
|
228
|
+
const slotImportPath = computeSlotImportPath(route.slotModule!, "apps/server/generated/routes");
|
|
229
|
+
|
|
230
|
+
return `// Generated by Mandu - DO NOT EDIT DIRECTLY
|
|
231
|
+
// Route ID: ${route.id}
|
|
232
|
+
// Pattern: ${route.pattern}
|
|
233
|
+
// Slot Module: ${route.slotModule}
|
|
234
|
+
|
|
235
|
+
import React from "react";
|
|
236
|
+
import filling from "${slotImportPath}";
|
|
237
|
+
|
|
238
|
+
interface Props {
|
|
239
|
+
params: Record<string, string>;
|
|
240
|
+
loaderData?: unknown;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function ${pageName}Page({ params, loaderData }: Props): React.ReactElement {
|
|
244
|
+
return React.createElement("div", null,
|
|
245
|
+
React.createElement("h1", null, "${pageName} Page"),
|
|
246
|
+
React.createElement("p", null, "Route ID: ${route.id}"),
|
|
247
|
+
React.createElement("p", null, "Pattern: ${route.pattern}"),
|
|
248
|
+
loaderData ? React.createElement("pre", null, JSON.stringify(loaderData, null, 2)) : null
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// PageRegistration ํ์์ผ๋ก export (server.ts์ registerPageHandler์ฉ)
|
|
253
|
+
export default {
|
|
254
|
+
component: ${pageName}Page,
|
|
255
|
+
filling: filling,
|
|
256
|
+
};
|
|
257
|
+
`;
|
|
258
|
+
}
|
|
259
|
+
|
|
213
260
|
/**
|
|
214
261
|
* Convert string to PascalCase (handles kebab-case, snake_case)
|
|
215
262
|
* "todo-page" โ "TodoPage"
|
package/src/runtime/server.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
import type { RoutesManifest } from "../spec/schema";
|
|
3
3
|
import type { BundleManifest } from "../bundler/types";
|
|
4
|
+
import type { ManduFilling } from "../filling/filling";
|
|
5
|
+
import { ManduContext } from "../filling/context";
|
|
4
6
|
import { Router } from "./router";
|
|
5
7
|
import { renderSSR } from "./ssr";
|
|
6
8
|
import React from "react";
|
|
@@ -103,18 +105,36 @@ export interface ManduServer {
|
|
|
103
105
|
export type ApiHandler = (req: Request, params: Record<string, string>) => Response | Promise<Response>;
|
|
104
106
|
export type PageLoader = () => Promise<{ default: React.ComponentType<{ params: Record<string, string> }> }>;
|
|
105
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Page ๋ฑ๋ก ์ ๋ณด
|
|
110
|
+
* - component: React ์ปดํฌ๋ํธ
|
|
111
|
+
* - filling: Slot์ ManduFilling ์ธ์คํด์ค (loader ํฌํจ)
|
|
112
|
+
*/
|
|
113
|
+
export interface PageRegistration {
|
|
114
|
+
component: React.ComponentType<{ params: Record<string, string>; loaderData?: unknown }>;
|
|
115
|
+
filling?: ManduFilling<unknown>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Page Handler - ์ปดํฌ๋ํธ์ filling์ ํจ๊ป ๋ฐํ
|
|
120
|
+
*/
|
|
121
|
+
export type PageHandler = () => Promise<PageRegistration>;
|
|
122
|
+
|
|
106
123
|
export interface AppContext {
|
|
107
124
|
routeId: string;
|
|
108
125
|
url: string;
|
|
109
126
|
params: Record<string, string>;
|
|
127
|
+
/** SSR loader์์ ๋ก๋ํ ๋ฐ์ดํฐ */
|
|
128
|
+
loaderData?: unknown;
|
|
110
129
|
}
|
|
111
130
|
|
|
112
|
-
type RouteComponent = (props: { params: Record<string, string
|
|
131
|
+
type RouteComponent = (props: { params: Record<string, string>; loaderData?: unknown }) => React.ReactElement;
|
|
113
132
|
type CreateAppFn = (context: AppContext) => React.ReactElement;
|
|
114
133
|
|
|
115
134
|
// Registry
|
|
116
135
|
const apiHandlers: Map<string, ApiHandler> = new Map();
|
|
117
136
|
const pageLoaders: Map<string, PageLoader> = new Map();
|
|
137
|
+
const pageHandlers: Map<string, PageHandler> = new Map();
|
|
118
138
|
const routeComponents: Map<string, RouteComponent> = new Map();
|
|
119
139
|
let createAppFn: CreateAppFn | null = null;
|
|
120
140
|
|
|
@@ -141,6 +161,14 @@ export function registerPageLoader(routeId: string, loader: PageLoader): void {
|
|
|
141
161
|
pageLoaders.set(routeId, loader);
|
|
142
162
|
}
|
|
143
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Page Handler ๋ฑ๋ก (์ปดํฌ๋ํธ + filling)
|
|
166
|
+
* filling์ด ์์ผ๋ฉด loader๋ฅผ ์คํํ์ฌ serverData ์ ๋ฌ
|
|
167
|
+
*/
|
|
168
|
+
export function registerPageHandler(routeId: string, handler: PageHandler): void {
|
|
169
|
+
pageHandlers.set(routeId, handler);
|
|
170
|
+
}
|
|
171
|
+
|
|
144
172
|
export function registerRouteComponent(routeId: string, component: RouteComponent): void {
|
|
145
173
|
routeComponents.set(routeId, component);
|
|
146
174
|
}
|
|
@@ -160,7 +188,10 @@ function defaultCreateApp(context: AppContext): React.ReactElement {
|
|
|
160
188
|
);
|
|
161
189
|
}
|
|
162
190
|
|
|
163
|
-
return React.createElement(Component, {
|
|
191
|
+
return React.createElement(Component, {
|
|
192
|
+
params: context.params,
|
|
193
|
+
loaderData: context.loaderData,
|
|
194
|
+
});
|
|
164
195
|
}
|
|
165
196
|
|
|
166
197
|
// ========== Static File Serving ==========
|
|
@@ -281,11 +312,25 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
|
281
312
|
}
|
|
282
313
|
|
|
283
314
|
if (route.kind === "page") {
|
|
284
|
-
|
|
285
|
-
|
|
315
|
+
let loaderData: unknown;
|
|
316
|
+
let component: RouteComponent | undefined;
|
|
317
|
+
|
|
318
|
+
// Client-side Routing: ๋ฐ์ดํฐ ์์ฒญ ๊ฐ์ง
|
|
319
|
+
const isDataRequest = url.searchParams.has("_data");
|
|
320
|
+
|
|
321
|
+
// 1. PageHandler ๋ฐฉ์ (์ ๊ท - filling ํฌํจ)
|
|
322
|
+
const pageHandler = pageHandlers.get(route.id);
|
|
323
|
+
if (pageHandler) {
|
|
286
324
|
try {
|
|
287
|
-
const
|
|
288
|
-
|
|
325
|
+
const registration = await pageHandler();
|
|
326
|
+
component = registration.component as RouteComponent;
|
|
327
|
+
registerRouteComponent(route.id, component);
|
|
328
|
+
|
|
329
|
+
// Filling์ loader ์คํ
|
|
330
|
+
if (registration.filling?.hasLoader()) {
|
|
331
|
+
const ctx = new ManduContext(req, params);
|
|
332
|
+
loaderData = await registration.filling.executeLoader(ctx);
|
|
333
|
+
}
|
|
289
334
|
} catch (err) {
|
|
290
335
|
const pageError = createPageLoadErrorResponse(
|
|
291
336
|
route.id,
|
|
@@ -299,15 +344,54 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
|
299
344
|
return Response.json(response, { status: 500 });
|
|
300
345
|
}
|
|
301
346
|
}
|
|
347
|
+
// 2. PageLoader ๋ฐฉ์ (๋ ๊ฑฐ์ ํธํ)
|
|
348
|
+
else {
|
|
349
|
+
const loader = pageLoaders.get(route.id);
|
|
350
|
+
if (loader) {
|
|
351
|
+
try {
|
|
352
|
+
const module = await loader();
|
|
353
|
+
registerRouteComponent(route.id, module.default);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
const pageError = createPageLoadErrorResponse(
|
|
356
|
+
route.id,
|
|
357
|
+
route.pattern,
|
|
358
|
+
err instanceof Error ? err : new Error(String(err))
|
|
359
|
+
);
|
|
360
|
+
console.error(`[Mandu] ${pageError.errorType}:`, pageError.message);
|
|
361
|
+
const response = formatErrorResponse(pageError, {
|
|
362
|
+
isDev: process.env.NODE_ENV !== "production",
|
|
363
|
+
});
|
|
364
|
+
return Response.json(response, { status: 500 });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
302
368
|
|
|
369
|
+
// Client-side Routing: ๋ฐ์ดํฐ๋ง ๋ฐํ (JSON)
|
|
370
|
+
if (isDataRequest) {
|
|
371
|
+
return Response.json({
|
|
372
|
+
routeId: route.id,
|
|
373
|
+
pattern: route.pattern,
|
|
374
|
+
params,
|
|
375
|
+
loaderData: loaderData ?? null,
|
|
376
|
+
timestamp: Date.now(),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// SSR ๋ ๋๋ง (๊ธฐ์กด ๋ก์ง)
|
|
303
381
|
const appCreator = createAppFn || defaultCreateApp;
|
|
304
382
|
try {
|
|
305
383
|
const app = appCreator({
|
|
306
384
|
routeId: route.id,
|
|
307
385
|
url: req.url,
|
|
308
386
|
params,
|
|
387
|
+
loaderData,
|
|
309
388
|
});
|
|
310
389
|
|
|
390
|
+
// serverData ๊ตฌ์กฐ: { [routeId]: { serverData: loaderData } }
|
|
391
|
+
const serverData = loaderData
|
|
392
|
+
? { [route.id]: { serverData: loaderData } }
|
|
393
|
+
: undefined;
|
|
394
|
+
|
|
311
395
|
return renderSSR(app, {
|
|
312
396
|
title: `${route.id} - Mandu`,
|
|
313
397
|
isDev: serverSettings.isDev,
|
|
@@ -315,6 +399,10 @@ async function handleRequest(req: Request, router: Router): Promise<Response> {
|
|
|
315
399
|
routeId: route.id,
|
|
316
400
|
hydration: route.hydration,
|
|
317
401
|
bundleManifest: serverSettings.bundleManifest,
|
|
402
|
+
serverData,
|
|
403
|
+
// Client-side Routing ํ์ฑํ ์ ๋ณด ์ ๋ฌ
|
|
404
|
+
enableClientRouter: true,
|
|
405
|
+
routePattern: route.pattern,
|
|
318
406
|
});
|
|
319
407
|
} catch (err) {
|
|
320
408
|
const ssrError = createSSRErrorResponse(
|
|
@@ -418,8 +506,9 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
418
506
|
export function clearRegistry(): void {
|
|
419
507
|
apiHandlers.clear();
|
|
420
508
|
pageLoaders.clear();
|
|
509
|
+
pageHandlers.clear();
|
|
421
510
|
routeComponents.clear();
|
|
422
511
|
createAppFn = null;
|
|
423
512
|
}
|
|
424
513
|
|
|
425
|
-
export { apiHandlers, pageLoaders, routeComponents };
|
|
514
|
+
export { apiHandlers, pageLoaders, pageHandlers, routeComponents };
|