@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
package/src/runtime/ssr.ts
CHANGED
|
@@ -1,14 +1,132 @@
|
|
|
1
1
|
import { renderToString } from "react-dom/server";
|
|
2
2
|
import type { ReactElement } from "react";
|
|
3
|
+
import type { BundleManifest } from "../bundler/types";
|
|
4
|
+
import type { HydrationConfig, HydrationPriority } from "../spec/schema";
|
|
3
5
|
|
|
4
6
|
export interface SSROptions {
|
|
5
7
|
title?: string;
|
|
6
8
|
lang?: string;
|
|
9
|
+
/** 서버에서 로드한 데이터 (클라이언트로 전달) */
|
|
10
|
+
serverData?: Record<string, unknown>;
|
|
11
|
+
/** Hydration 설정 */
|
|
12
|
+
hydration?: HydrationConfig;
|
|
13
|
+
/** 번들 매니페스트 */
|
|
14
|
+
bundleManifest?: BundleManifest;
|
|
15
|
+
/** 라우트 ID (island 식별용) */
|
|
16
|
+
routeId?: string;
|
|
17
|
+
/** 추가 head 태그 */
|
|
18
|
+
headTags?: string;
|
|
19
|
+
/** 추가 body 끝 태그 */
|
|
20
|
+
bodyEndTags?: string;
|
|
21
|
+
/** 개발 모드 여부 */
|
|
22
|
+
isDev?: boolean;
|
|
23
|
+
/** HMR 포트 (개발 모드에서 사용) */
|
|
24
|
+
hmrPort?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* SSR 데이터를 안전하게 직렬화
|
|
29
|
+
*/
|
|
30
|
+
function serializeServerData(data: Record<string, unknown>): string {
|
|
31
|
+
// XSS 방지를 위한 이스케이프
|
|
32
|
+
const json = JSON.stringify(data)
|
|
33
|
+
.replace(/</g, "\\u003c")
|
|
34
|
+
.replace(/>/g, "\\u003e")
|
|
35
|
+
.replace(/&/g, "\\u0026")
|
|
36
|
+
.replace(/'/g, "\\u0027");
|
|
37
|
+
|
|
38
|
+
return `<script id="__MANDU_DATA__" type="application/json">${json}</script>
|
|
39
|
+
<script>window.__MANDU_DATA__ = JSON.parse(document.getElementById('__MANDU_DATA__').textContent);</script>`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Hydration 스크립트 태그 생성
|
|
44
|
+
*/
|
|
45
|
+
function generateHydrationScripts(
|
|
46
|
+
routeId: string,
|
|
47
|
+
manifest: BundleManifest
|
|
48
|
+
): string {
|
|
49
|
+
const scripts: string[] = [];
|
|
50
|
+
|
|
51
|
+
// Runtime 로드
|
|
52
|
+
if (manifest.shared.runtime) {
|
|
53
|
+
scripts.push(`<script type="module" src="${manifest.shared.runtime}"></script>`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Vendor 로드
|
|
57
|
+
if (manifest.shared.vendor) {
|
|
58
|
+
scripts.push(`<script type="module" src="${manifest.shared.vendor}"></script>`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Island 번들 로드
|
|
62
|
+
const bundle = manifest.bundles[routeId];
|
|
63
|
+
if (bundle) {
|
|
64
|
+
// Preload (선택적)
|
|
65
|
+
scripts.push(`<link rel="modulepreload" href="${bundle.js}">`);
|
|
66
|
+
scripts.push(`<script type="module" src="${bundle.js}"></script>`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return scripts.join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Island 래퍼로 컨텐츠 감싸기
|
|
74
|
+
*/
|
|
75
|
+
export function wrapWithIsland(
|
|
76
|
+
content: string,
|
|
77
|
+
routeId: string,
|
|
78
|
+
priority: HydrationPriority = "visible"
|
|
79
|
+
): string {
|
|
80
|
+
return `<div data-mandu-island="${routeId}" data-mandu-priority="${priority}">${content}</div>`;
|
|
7
81
|
}
|
|
8
82
|
|
|
9
83
|
export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
|
|
10
|
-
const {
|
|
11
|
-
|
|
84
|
+
const {
|
|
85
|
+
title = "Mandu App",
|
|
86
|
+
lang = "ko",
|
|
87
|
+
serverData,
|
|
88
|
+
hydration,
|
|
89
|
+
bundleManifest,
|
|
90
|
+
routeId,
|
|
91
|
+
headTags = "",
|
|
92
|
+
bodyEndTags = "",
|
|
93
|
+
isDev = false,
|
|
94
|
+
hmrPort,
|
|
95
|
+
} = options;
|
|
96
|
+
|
|
97
|
+
let content = renderToString(element);
|
|
98
|
+
|
|
99
|
+
// Island 래퍼 적용 (hydration 필요 시)
|
|
100
|
+
const needsHydration =
|
|
101
|
+
hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
102
|
+
|
|
103
|
+
if (needsHydration) {
|
|
104
|
+
content = wrapWithIsland(content, routeId, hydration.priority);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 서버 데이터 스크립트
|
|
108
|
+
let dataScript = "";
|
|
109
|
+
if (serverData && routeId) {
|
|
110
|
+
const wrappedData = {
|
|
111
|
+
[routeId]: {
|
|
112
|
+
serverData,
|
|
113
|
+
timestamp: Date.now(),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
dataScript = serializeServerData(wrappedData);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Hydration 스크립트
|
|
120
|
+
let hydrationScripts = "";
|
|
121
|
+
if (needsHydration && bundleManifest) {
|
|
122
|
+
hydrationScripts = generateHydrationScripts(routeId, bundleManifest);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// HMR 스크립트 (개발 모드)
|
|
126
|
+
let hmrScript = "";
|
|
127
|
+
if (isDev && hmrPort) {
|
|
128
|
+
hmrScript = generateHMRScript(hmrPort);
|
|
129
|
+
}
|
|
12
130
|
|
|
13
131
|
return `<!doctype html>
|
|
14
132
|
<html lang="${lang}">
|
|
@@ -16,13 +134,62 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
16
134
|
<meta charset="UTF-8">
|
|
17
135
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
18
136
|
<title>${title}</title>
|
|
137
|
+
${headTags}
|
|
19
138
|
</head>
|
|
20
139
|
<body>
|
|
21
140
|
<div id="root">${content}</div>
|
|
141
|
+
${dataScript}
|
|
142
|
+
${hydrationScripts}
|
|
143
|
+
${hmrScript}
|
|
144
|
+
${bodyEndTags}
|
|
22
145
|
</body>
|
|
23
146
|
</html>`;
|
|
24
147
|
}
|
|
25
148
|
|
|
149
|
+
/**
|
|
150
|
+
* HMR 스크립트 생성
|
|
151
|
+
*/
|
|
152
|
+
function generateHMRScript(port: number): string {
|
|
153
|
+
const hmrPort = port + 1;
|
|
154
|
+
return `<script>
|
|
155
|
+
(function() {
|
|
156
|
+
var ws = null;
|
|
157
|
+
var reconnectAttempts = 0;
|
|
158
|
+
var maxReconnectAttempts = 10;
|
|
159
|
+
|
|
160
|
+
function connect() {
|
|
161
|
+
try {
|
|
162
|
+
ws = new WebSocket('ws://localhost:${hmrPort}');
|
|
163
|
+
ws.onopen = function() {
|
|
164
|
+
console.log('[Mandu HMR] Connected');
|
|
165
|
+
reconnectAttempts = 0;
|
|
166
|
+
};
|
|
167
|
+
ws.onmessage = function(e) {
|
|
168
|
+
try {
|
|
169
|
+
var msg = JSON.parse(e.data);
|
|
170
|
+
if (msg.type === 'reload' || msg.type === 'island-update') {
|
|
171
|
+
console.log('[Mandu HMR] Reloading...');
|
|
172
|
+
location.reload();
|
|
173
|
+
} else if (msg.type === 'error') {
|
|
174
|
+
console.error('[Mandu HMR] Build error:', msg.data?.message);
|
|
175
|
+
}
|
|
176
|
+
} catch(err) {}
|
|
177
|
+
};
|
|
178
|
+
ws.onclose = function() {
|
|
179
|
+
if (reconnectAttempts < maxReconnectAttempts) {
|
|
180
|
+
reconnectAttempts++;
|
|
181
|
+
setTimeout(connect, 1000 * reconnectAttempts);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
} catch(err) {
|
|
185
|
+
setTimeout(connect, 1000);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
connect();
|
|
189
|
+
})();
|
|
190
|
+
</script>`;
|
|
191
|
+
}
|
|
192
|
+
|
|
26
193
|
export function createHTMLResponse(html: string, status: number = 200): Response {
|
|
27
194
|
return new Response(html, {
|
|
28
195
|
status,
|
|
@@ -36,3 +203,33 @@ export function renderSSR(element: ReactElement, options: SSROptions = {}): Resp
|
|
|
36
203
|
const html = renderToHTML(element, options);
|
|
37
204
|
return createHTMLResponse(html);
|
|
38
205
|
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Hydration이 포함된 SSR 렌더링
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```typescript
|
|
212
|
+
* const response = await renderWithHydration(
|
|
213
|
+
* <TodoList todos={todos} />,
|
|
214
|
+
* {
|
|
215
|
+
* title: "할일 목록",
|
|
216
|
+
* routeId: "todos",
|
|
217
|
+
* serverData: { todos },
|
|
218
|
+
* hydration: { strategy: "island", priority: "visible" },
|
|
219
|
+
* bundleManifest,
|
|
220
|
+
* }
|
|
221
|
+
* );
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
export async function renderWithHydration(
|
|
225
|
+
element: ReactElement,
|
|
226
|
+
options: SSROptions & {
|
|
227
|
+
routeId: string;
|
|
228
|
+
serverData: Record<string, unknown>;
|
|
229
|
+
hydration: HydrationConfig;
|
|
230
|
+
bundleManifest: BundleManifest;
|
|
231
|
+
}
|
|
232
|
+
): Promise<Response> {
|
|
233
|
+
const html = renderToHTML(element, options);
|
|
234
|
+
return createHTMLResponse(html);
|
|
235
|
+
}
|
package/src/spec/schema.ts
CHANGED
|
@@ -1,16 +1,90 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
+
// ========== Hydration 설정 ==========
|
|
4
|
+
|
|
5
|
+
export const HydrationStrategy = z.enum(["none", "island", "full", "progressive"]);
|
|
6
|
+
export type HydrationStrategy = z.infer<typeof HydrationStrategy>;
|
|
7
|
+
|
|
8
|
+
export const HydrationPriority = z.enum(["immediate", "visible", "idle", "interaction"]);
|
|
9
|
+
export type HydrationPriority = z.infer<typeof HydrationPriority>;
|
|
10
|
+
|
|
11
|
+
export const HydrationConfig = z.object({
|
|
12
|
+
/**
|
|
13
|
+
* Hydration 전략
|
|
14
|
+
* - none: 순수 Static HTML (JS 없음)
|
|
15
|
+
* - island: Slot 영역만 hydrate (기본값)
|
|
16
|
+
* - full: 전체 페이지 hydrate
|
|
17
|
+
* - progressive: 점진적 hydrate
|
|
18
|
+
*/
|
|
19
|
+
strategy: HydrationStrategy,
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hydration 우선순위
|
|
23
|
+
* - immediate: 페이지 로드 즉시
|
|
24
|
+
* - visible: 뷰포트에 보일 때 (기본값)
|
|
25
|
+
* - idle: 브라우저 idle 시
|
|
26
|
+
* - interaction: 사용자 상호작용 시
|
|
27
|
+
*/
|
|
28
|
+
priority: HydrationPriority.default("visible"),
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 번들 preload 여부
|
|
32
|
+
*/
|
|
33
|
+
preload: z.boolean().default(false),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type HydrationConfig = z.infer<typeof HydrationConfig>;
|
|
37
|
+
|
|
38
|
+
// ========== Loader 설정 ==========
|
|
39
|
+
|
|
40
|
+
export const LoaderConfig = z.object({
|
|
41
|
+
/**
|
|
42
|
+
* SSR 시 데이터 로딩 타임아웃 (ms)
|
|
43
|
+
*/
|
|
44
|
+
timeout: z.number().positive().default(5000),
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 로딩 실패 시 fallback 데이터
|
|
48
|
+
*/
|
|
49
|
+
fallback: z.record(z.unknown()).optional(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type LoaderConfig = z.infer<typeof LoaderConfig>;
|
|
53
|
+
|
|
54
|
+
// ========== Route 설정 ==========
|
|
55
|
+
|
|
3
56
|
export const RouteKind = z.enum(["page", "api"]);
|
|
4
57
|
export type RouteKind = z.infer<typeof RouteKind>;
|
|
5
58
|
|
|
59
|
+
export const HttpMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
|
|
60
|
+
export type HttpMethod = z.infer<typeof HttpMethod>;
|
|
61
|
+
|
|
6
62
|
export const RouteSpec = z
|
|
7
63
|
.object({
|
|
8
64
|
id: z.string().min(1, "id는 필수입니다"),
|
|
9
65
|
pattern: z.string().startsWith("/", "pattern은 /로 시작해야 합니다"),
|
|
10
66
|
kind: RouteKind,
|
|
67
|
+
|
|
68
|
+
// HTTP 메서드 (API용)
|
|
69
|
+
methods: z.array(HttpMethod).optional(),
|
|
70
|
+
|
|
71
|
+
// 서버 모듈 (generated route handler)
|
|
11
72
|
module: z.string().min(1, "module 경로는 필수입니다"),
|
|
73
|
+
|
|
74
|
+
// 페이지 컴포넌트 모듈 (generated)
|
|
12
75
|
componentModule: z.string().optional(),
|
|
76
|
+
|
|
77
|
+
// 서버 슬롯 (비즈니스 로직)
|
|
13
78
|
slotModule: z.string().optional(),
|
|
79
|
+
|
|
80
|
+
// 클라이언트 슬롯 (interactive 로직) [NEW]
|
|
81
|
+
clientModule: z.string().optional(),
|
|
82
|
+
|
|
83
|
+
// Hydration 설정 [NEW]
|
|
84
|
+
hydration: HydrationConfig.optional(),
|
|
85
|
+
|
|
86
|
+
// Loader 설정 [NEW]
|
|
87
|
+
loader: LoaderConfig.optional(),
|
|
14
88
|
})
|
|
15
89
|
.refine(
|
|
16
90
|
(route) => {
|
|
@@ -23,10 +97,25 @@ export const RouteSpec = z
|
|
|
23
97
|
message: "kind가 'page'인 경우 componentModule은 필수입니다",
|
|
24
98
|
path: ["componentModule"],
|
|
25
99
|
}
|
|
100
|
+
)
|
|
101
|
+
.refine(
|
|
102
|
+
(route) => {
|
|
103
|
+
// clientModule이 있으면 hydration.strategy가 none이 아니어야 함
|
|
104
|
+
if (route.clientModule && route.hydration?.strategy === "none") {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
message: "clientModule이 있으면 hydration.strategy는 'none'이 아니어야 합니다",
|
|
111
|
+
path: ["hydration"],
|
|
112
|
+
}
|
|
26
113
|
);
|
|
27
114
|
|
|
28
115
|
export type RouteSpec = z.infer<typeof RouteSpec>;
|
|
29
116
|
|
|
117
|
+
// ========== Manifest ==========
|
|
118
|
+
|
|
30
119
|
export const RoutesManifest = z
|
|
31
120
|
.object({
|
|
32
121
|
version: z.number().int().positive(),
|
|
@@ -56,3 +145,46 @@ export const RoutesManifest = z
|
|
|
56
145
|
);
|
|
57
146
|
|
|
58
147
|
export type RoutesManifest = z.infer<typeof RoutesManifest>;
|
|
148
|
+
|
|
149
|
+
// ========== 유틸리티 함수 ==========
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 기본 hydration 설정 반환
|
|
153
|
+
*/
|
|
154
|
+
export function getDefaultHydration(route: RouteSpec): HydrationConfig {
|
|
155
|
+
// clientModule이 있으면 island, 없으면 none
|
|
156
|
+
if (route.clientModule) {
|
|
157
|
+
return {
|
|
158
|
+
strategy: "island",
|
|
159
|
+
priority: "visible",
|
|
160
|
+
preload: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
strategy: "none",
|
|
165
|
+
priority: "visible",
|
|
166
|
+
preload: false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 라우트의 실제 hydration 설정 반환 (기본값 적용)
|
|
172
|
+
*/
|
|
173
|
+
export function getRouteHydration(route: RouteSpec): HydrationConfig {
|
|
174
|
+
if (route.hydration) {
|
|
175
|
+
return {
|
|
176
|
+
strategy: route.hydration.strategy,
|
|
177
|
+
priority: route.hydration.priority ?? "visible",
|
|
178
|
+
preload: route.hydration.preload ?? false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return getDefaultHydration(route);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Hydration이 필요한 라우트인지 확인
|
|
186
|
+
*/
|
|
187
|
+
export function needsHydration(route: RouteSpec): boolean {
|
|
188
|
+
const hydration = getRouteHydration(route);
|
|
189
|
+
return route.kind === "page" && hydration.strategy !== "none";
|
|
190
|
+
}
|