@mandujs/core 0.19.0 → 0.20.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/README.ko.md +0 -14
- package/package.json +4 -1
- package/src/brain/architecture/analyzer.ts +4 -4
- package/src/brain/doctor/analyzer.ts +18 -14
- package/src/bundler/build.test.ts +127 -0
- package/src/bundler/build.ts +385 -115
- package/src/bundler/css.ts +20 -5
- package/src/bundler/dev.ts +55 -2
- package/src/bundler/prerender.ts +195 -0
- package/src/bundler/types.ts +20 -0
- package/src/change/snapshot.ts +4 -23
- package/src/change/types.ts +2 -3
- package/src/client/Form.tsx +105 -0
- package/src/client/__tests__/use-sse.test.ts +153 -0
- package/src/client/hooks.ts +105 -6
- package/src/client/index.ts +35 -6
- package/src/client/router.ts +670 -433
- package/src/client/rpc.ts +140 -0
- package/src/client/runtime.ts +24 -21
- package/src/client/use-fetch.ts +239 -0
- package/src/client/use-head.ts +197 -0
- package/src/client/use-sse.ts +378 -0
- package/src/components/Image.tsx +162 -0
- package/src/config/mandu.ts +5 -0
- package/src/config/validate.ts +34 -0
- package/src/content/index.ts +5 -1
- package/src/devtools/client/catchers/error-catcher.ts +17 -0
- package/src/devtools/client/catchers/network-proxy.ts +390 -367
- package/src/devtools/client/components/kitchen-root.tsx +479 -467
- package/src/devtools/client/components/mandu-character.tsx +77 -53
- package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
- package/src/devtools/client/components/panel/errors-panel.tsx +2 -2
- package/src/devtools/client/components/panel/guard-panel.tsx +363 -234
- package/src/devtools/client/components/panel/index.ts +45 -32
- package/src/devtools/client/components/panel/islands-panel.tsx +30 -14
- package/src/devtools/client/components/panel/network-panel.tsx +2 -3
- package/src/devtools/client/components/panel/panel-container.tsx +273 -100
- package/src/devtools/client/components/panel/preview-panel.tsx +212 -0
- package/src/devtools/client/state-manager.ts +535 -478
- package/src/devtools/design-tokens.ts +265 -264
- package/src/devtools/init.ts +1 -1
- package/src/devtools/types.ts +321 -295
- package/src/filling/filling.ts +328 -6
- package/src/filling/index.ts +5 -1
- package/src/filling/session.ts +216 -0
- package/src/filling/ws.ts +78 -0
- package/src/generator/generate.ts +2 -2
- package/src/guard/auto-correct.ts +0 -29
- package/src/guard/check.ts +14 -31
- package/src/guard/presets/index.ts +296 -294
- package/src/guard/rules.ts +15 -19
- package/src/guard/validator.ts +834 -834
- package/src/index.ts +6 -1
- package/src/island/index.ts +373 -304
- package/src/kitchen/api/contract-api.ts +225 -0
- package/src/kitchen/api/diff-parser.ts +108 -0
- package/src/kitchen/api/file-api.ts +273 -0
- package/src/kitchen/api/guard-api.ts +83 -0
- package/src/kitchen/api/guard-decisions.ts +100 -0
- package/src/kitchen/api/routes-api.ts +50 -0
- package/src/kitchen/index.ts +21 -0
- package/src/kitchen/kitchen-handler.ts +335 -0
- package/src/kitchen/kitchen-ui.ts +1732 -0
- package/src/kitchen/stream/activity-sse.ts +145 -0
- package/src/kitchen/stream/file-tailer.ts +99 -0
- package/src/middleware/compress.ts +62 -0
- package/src/middleware/cors.ts +47 -0
- package/src/middleware/index.ts +10 -0
- package/src/middleware/jwt.ts +134 -0
- package/src/middleware/logger.ts +58 -0
- package/src/middleware/timeout.ts +55 -0
- package/src/observability/event-bus.ts +79 -0
- package/src/observability/index.ts +8 -0
- package/src/observability/logger-adapter.ts +36 -0
- package/src/paths.ts +0 -4
- package/src/plugins/hooks.ts +64 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/types.ts +5 -0
- package/src/report/build.ts +0 -6
- package/src/resource/__tests__/backward-compat.test.ts +0 -1
- package/src/router/fs-patterns.ts +11 -1
- package/src/router/fs-routes.ts +78 -14
- package/src/router/fs-scanner.ts +2 -2
- package/src/router/fs-types.ts +2 -1
- package/src/runtime/adapter-bun.ts +62 -0
- package/src/runtime/adapter.ts +47 -0
- package/src/runtime/cache.ts +310 -0
- package/src/runtime/handler.ts +65 -0
- package/src/runtime/image-handler.ts +195 -0
- package/src/runtime/index.ts +13 -0
- package/src/runtime/middleware.ts +263 -0
- package/src/runtime/ppr.ts +74 -0
- package/src/runtime/server.ts +706 -71
- package/src/runtime/ssr.ts +70 -31
- package/src/runtime/streaming-ssr.ts +121 -78
- package/src/spec/index.ts +0 -1
- package/src/spec/schema.ts +1 -0
- package/src/testing/index.ts +189 -0
- package/src/watcher/watcher.ts +27 -1
- package/src/spec/lock.ts +0 -56
package/src/runtime/ssr.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface SSROptions {
|
|
|
48
48
|
routePattern?: string;
|
|
49
49
|
/** CSS 파일 경로 (자동 주입, 기본: /.mandu/client/globals.css) */
|
|
50
50
|
cssPath?: string | false;
|
|
51
|
+
/** Island 래핑이 이미 React 엘리먼트 레벨에서 완료됨 (중복 래핑 방지) */
|
|
52
|
+
islandPreWrapped?: boolean;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
let projectRenderToString: ((element: ReactElement) => string) | null | undefined;
|
|
@@ -137,9 +139,23 @@ function generateHydrationScripts(
|
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
// Island 번들 modulepreload (성능 최적화 - prefetch only)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
// Per-island bundles take precedence when available
|
|
143
|
+
const routeIslands = manifest.islands
|
|
144
|
+
? Object.values(manifest.islands).filter((ib) => ib.route === routeId)
|
|
145
|
+
: [];
|
|
146
|
+
|
|
147
|
+
if (routeIslands.length > 0) {
|
|
148
|
+
for (const ib of routeIslands) {
|
|
149
|
+
const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
150
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
// Fallback: route-level bundle (backward compat)
|
|
154
|
+
const bundle = manifest.bundles[routeId];
|
|
155
|
+
if (bundle) {
|
|
156
|
+
const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
157
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
158
|
+
}
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
// Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
|
|
@@ -160,8 +176,9 @@ export function wrapWithIsland(
|
|
|
160
176
|
priority: HydrationPriority = "visible",
|
|
161
177
|
bundleSrc?: string
|
|
162
178
|
): string {
|
|
163
|
-
const
|
|
164
|
-
|
|
179
|
+
const cacheBustedSrc = bundleSrc ? `${bundleSrc}?t=${Date.now()}` : undefined;
|
|
180
|
+
const srcAttr = cacheBustedSrc ? ` data-mandu-src="${escapeHtmlAttr(cacheBustedSrc)}"` : "";
|
|
181
|
+
return `<div data-mandu-island="${escapeHtmlAttr(routeId)}"${srcAttr} data-mandu-priority="${escapeHtmlAttr(priority)}" style="display:contents">${content}</div>`;
|
|
165
182
|
}
|
|
166
183
|
|
|
167
184
|
export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
|
|
@@ -179,6 +196,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
179
196
|
enableClientRouter = false,
|
|
180
197
|
routePattern,
|
|
181
198
|
cssPath,
|
|
199
|
+
islandPreWrapped,
|
|
182
200
|
} = options;
|
|
183
201
|
|
|
184
202
|
// CSS 링크 태그 생성
|
|
@@ -188,51 +206,71 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
188
206
|
? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
|
|
189
207
|
: "";
|
|
190
208
|
|
|
209
|
+
// useHead/useSeoMeta SSR 수집
|
|
210
|
+
let collectedHeadTags = "";
|
|
211
|
+
let headReset: (() => void) | undefined;
|
|
212
|
+
let headGet: (() => string) | undefined;
|
|
213
|
+
try {
|
|
214
|
+
const mod = require("../client/use-head");
|
|
215
|
+
headReset = mod.resetSSRHead;
|
|
216
|
+
headGet = mod.getSSRHeadTags;
|
|
217
|
+
headReset?.();
|
|
218
|
+
} catch { /* client 모듈 로드 실패 시 무시 */ }
|
|
219
|
+
|
|
191
220
|
const renderToString = getRenderToString();
|
|
192
221
|
let content = renderToString(element);
|
|
193
222
|
|
|
223
|
+
// 렌더링 중 수집된 head 태그
|
|
224
|
+
collectedHeadTags = headGet?.() ?? "";
|
|
225
|
+
|
|
194
226
|
// Island 래퍼 적용 (hydration 필요 시)
|
|
227
|
+
// islandPreWrapped가 true이면 React 엘리먼트 레벨에서 이미 래핑됨 → HTML 래핑 건너뜀
|
|
195
228
|
const needsHydration =
|
|
196
229
|
hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
197
230
|
|
|
198
|
-
if (needsHydration) {
|
|
231
|
+
if (needsHydration && !islandPreWrapped) {
|
|
199
232
|
// v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
|
|
200
233
|
const bundle = bundleManifest.bundles[routeId];
|
|
201
234
|
const bundleSrc = bundle?.js;
|
|
202
235
|
content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
|
|
203
236
|
}
|
|
204
237
|
|
|
205
|
-
//
|
|
238
|
+
// Zero-JS 모드: island이 없는 페이지에서는 클라이언트 JS 번들을 전송하지 않음
|
|
239
|
+
// HMR/DevTools는 dev 환경에서만 유지 (CSS 핫리로드 등)
|
|
206
240
|
let dataScript = "";
|
|
207
|
-
if (serverData && routeId) {
|
|
208
|
-
const wrappedData = {
|
|
209
|
-
[routeId]: {
|
|
210
|
-
serverData,
|
|
211
|
-
timestamp: Date.now(),
|
|
212
|
-
},
|
|
213
|
-
};
|
|
214
|
-
dataScript = serializeServerData(wrappedData);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Client-side Routing: 라우트 정보 주입
|
|
218
241
|
let routeScript = "";
|
|
219
|
-
if (enableClientRouter && routeId) {
|
|
220
|
-
routeScript = generateRouteScript(routeId, routePattern || "", serverData);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Hydration 스크립트
|
|
224
242
|
let hydrationScripts = "";
|
|
225
|
-
if (needsHydration && bundleManifest) {
|
|
226
|
-
hydrationScripts = generateHydrationScripts(routeId, bundleManifest);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Client-side Router 스크립트
|
|
230
243
|
let routerScript = "";
|
|
231
|
-
|
|
232
|
-
|
|
244
|
+
|
|
245
|
+
if (needsHydration) {
|
|
246
|
+
// 서버 데이터 스크립트 (클라이언트 hydration에서 사용)
|
|
247
|
+
if (serverData && routeId) {
|
|
248
|
+
const wrappedData = {
|
|
249
|
+
[routeId]: {
|
|
250
|
+
serverData,
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
dataScript = serializeServerData(wrappedData);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Client-side Routing: 라우트 정보 주입
|
|
258
|
+
if (enableClientRouter && routeId) {
|
|
259
|
+
routeScript = generateRouteScript(routeId, routePattern || "", serverData);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Hydration 스크립트 (vendor/runtime/island preloads)
|
|
263
|
+
if (bundleManifest) {
|
|
264
|
+
hydrationScripts = generateHydrationScripts(routeId, bundleManifest);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Client-side Router 스크립트
|
|
268
|
+
if (enableClientRouter && bundleManifest) {
|
|
269
|
+
routerScript = generateClientRouterScript(bundleManifest);
|
|
270
|
+
}
|
|
233
271
|
}
|
|
234
272
|
|
|
235
|
-
// HMR 스크립트 (개발 모드)
|
|
273
|
+
// HMR 스크립트 (개발 모드 — island 유무와 무관하게 CSS 핫리로드 지원)
|
|
236
274
|
let hmrScript = "";
|
|
237
275
|
if (isDev && hmrPort) {
|
|
238
276
|
hmrScript = generateHMRScript(hmrPort);
|
|
@@ -252,6 +290,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
252
290
|
<title>${escapeHtmlText(title)}</title>
|
|
253
291
|
${cssLinkTag}
|
|
254
292
|
${headTags}
|
|
293
|
+
${collectedHeadTags}
|
|
255
294
|
</head>
|
|
256
295
|
<body>
|
|
257
296
|
<div id="root">${content}</div>
|
|
@@ -18,8 +18,9 @@ import { serializeProps } from "../client/serialize";
|
|
|
18
18
|
import type { Metadata, MetadataItem } from "../seo/types";
|
|
19
19
|
import { injectSEOIntoOptions, resolveSEO, type SEOOptions } from "../seo/integration/ssr";
|
|
20
20
|
import { PORTS, TIMEOUTS } from "../constants";
|
|
21
|
-
import { escapeHtmlAttr, escapeJsonForInlineScript, escapeJsString } from "./escape";
|
|
21
|
+
import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript, escapeJsString } from "./escape";
|
|
22
22
|
import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
|
|
23
|
+
import { getRenderToString } from "./react-renderer";
|
|
23
24
|
|
|
24
25
|
// ========== Types ==========
|
|
25
26
|
|
|
@@ -313,9 +314,9 @@ export function SuspenseIsland({
|
|
|
313
314
|
const defaultFallback = React.createElement("div", {
|
|
314
315
|
"data-mandu-island": routeId,
|
|
315
316
|
"data-mandu-priority": priority,
|
|
316
|
-
"data-mandu-src": bundleSrc,
|
|
317
|
+
"data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc,
|
|
317
318
|
"data-mandu-loading": "true",
|
|
318
|
-
style: { minHeight: "50px" },
|
|
319
|
+
style: { display: "contents", minHeight: "50px" },
|
|
319
320
|
}, React.createElement("div", {
|
|
320
321
|
className: "mandu-loading-skeleton",
|
|
321
322
|
style: {
|
|
@@ -334,7 +335,8 @@ export function SuspenseIsland({
|
|
|
334
335
|
React.createElement("div", {
|
|
335
336
|
"data-mandu-island": routeId,
|
|
336
337
|
"data-mandu-priority": priority,
|
|
337
|
-
"data-mandu-src": bundleSrc,
|
|
338
|
+
"data-mandu-src": bundleSrc ? `${bundleSrc}${bundleSrc.includes('?') ? '&' : '?'}t=${Date.now()}` : bundleSrc,
|
|
339
|
+
style: { display: "contents" },
|
|
338
340
|
}, children)
|
|
339
341
|
);
|
|
340
342
|
}
|
|
@@ -391,15 +393,18 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
391
393
|
? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
|
|
392
394
|
: "";
|
|
393
395
|
|
|
394
|
-
//
|
|
396
|
+
// Island wrapper (hydration이 필요한 경우)
|
|
397
|
+
const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
398
|
+
|
|
399
|
+
// Import map (module scripts 전에 위치해야 함 — hydration 필요 시에만)
|
|
395
400
|
let importMapScript = "";
|
|
396
|
-
if (bundleManifest
|
|
401
|
+
if (needsHydration && bundleManifest.importMap && Object.keys(bundleManifest.importMap.imports).length > 0) {
|
|
397
402
|
const importMapJson = escapeJsonForInlineScript(JSON.stringify(bundleManifest.importMap, null, 2));
|
|
398
403
|
importMapScript = `<script type="importmap">${importMapJson}</script>`;
|
|
399
404
|
}
|
|
400
405
|
|
|
401
|
-
// Loading skeleton 애니메이션 스타일
|
|
402
|
-
const loadingStyles = `
|
|
406
|
+
// Loading skeleton 애니메이션 스타일 (hydration 필요 시에만)
|
|
407
|
+
const loadingStyles = !needsHydration ? "" : `
|
|
403
408
|
<style>
|
|
404
409
|
@keyframes mandu-shimmer {
|
|
405
410
|
0% { background-position: 200% 0; }
|
|
@@ -419,14 +424,12 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
419
424
|
}
|
|
420
425
|
</style>`;
|
|
421
426
|
|
|
422
|
-
// Island wrapper (hydration이 필요한 경우)
|
|
423
|
-
const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
424
427
|
let islandOpenTag = "";
|
|
425
428
|
if (needsHydration) {
|
|
426
429
|
const bundle = bundleManifest.bundles[routeId];
|
|
427
|
-
const bundleSrc = bundle?.js
|
|
430
|
+
const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
|
|
428
431
|
const priority = hydration.priority || "visible";
|
|
429
|
-
islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}">`;
|
|
432
|
+
islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" style="display:contents">`;
|
|
430
433
|
}
|
|
431
434
|
|
|
432
435
|
// Import map은 module 스크립트보다 먼저 정의되어야 bare specifier 해석 가능
|
|
@@ -435,7 +438,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
435
438
|
<head>
|
|
436
439
|
<meta charset="UTF-8">
|
|
437
440
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
438
|
-
<title>${
|
|
441
|
+
<title>${escapeHtmlText(title)}</title>
|
|
439
442
|
${cssLinkTag}
|
|
440
443
|
${loadingStyles}
|
|
441
444
|
${importMapScript}
|
|
@@ -463,78 +466,81 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
|
463
466
|
|
|
464
467
|
const scripts: string[] = [];
|
|
465
468
|
|
|
466
|
-
//
|
|
467
|
-
|
|
468
|
-
const wrappedData = {
|
|
469
|
-
[routeId]: {
|
|
470
|
-
serverData: criticalData,
|
|
471
|
-
timestamp: Date.now(),
|
|
472
|
-
streaming: true,
|
|
473
|
-
},
|
|
474
|
-
};
|
|
475
|
-
const json = escapeJsonForInlineScript(serializeProps(wrappedData));
|
|
476
|
-
scripts.push(`<script id="__MANDU_DATA__" type="application/json">${json}</script>`);
|
|
477
|
-
scripts.push(`<script>window.__MANDU_DATA_RAW__ = document.getElementById('__MANDU_DATA__').textContent;</script>`);
|
|
478
|
-
}
|
|
469
|
+
// Zero-JS 모드 판정: island이 없는 페이지에서는 클라이언트 번들을 전송하지 않음
|
|
470
|
+
const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
479
471
|
|
|
480
|
-
//
|
|
481
|
-
if (
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
472
|
+
// 1~8: hydration이 필요한 경우에만 클라이언트 JS 관련 스크립트 삽입
|
|
473
|
+
if (needsHydration) {
|
|
474
|
+
// 1. Critical 데이터 스크립트 (즉시 사용 가능)
|
|
475
|
+
if (criticalData && routeId) {
|
|
476
|
+
const wrappedData = {
|
|
477
|
+
[routeId]: {
|
|
478
|
+
serverData: criticalData,
|
|
479
|
+
timestamp: Date.now(),
|
|
480
|
+
streaming: true,
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
const json = escapeJsonForInlineScript(serializeProps(wrappedData));
|
|
484
|
+
scripts.push(`<script id="__MANDU_DATA__" type="application/json">${json}</script>`);
|
|
485
|
+
scripts.push(`<script>window.__MANDU_DATA_RAW__ = document.getElementById('__MANDU_DATA__').textContent;</script>`);
|
|
486
|
+
}
|
|
491
487
|
|
|
492
|
-
|
|
493
|
-
|
|
488
|
+
// 2. 라우트 정보 스크립트
|
|
489
|
+
if (enableClientRouter && routeId) {
|
|
490
|
+
const routeInfo = {
|
|
491
|
+
id: routeId,
|
|
492
|
+
pattern: routePattern || "",
|
|
493
|
+
params: {},
|
|
494
|
+
streaming: true,
|
|
495
|
+
};
|
|
496
|
+
const json = escapeJsonForInlineScript(JSON.stringify(routeInfo));
|
|
497
|
+
scripts.push(`<script>window.__MANDU_ROUTE__ = ${json};</script>`);
|
|
498
|
+
}
|
|
494
499
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
if (imports["react-dom"] && imports["react-dom"] !== bundleManifest.shared.vendor) {
|
|
502
|
-
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(imports["react-dom"])}">`);
|
|
500
|
+
// 3. Streaming 완료 마커 (클라이언트 hydration에서 감지용)
|
|
501
|
+
scripts.push(`<script>window.__MANDU_STREAMING_SHELL_READY__ = true;</script>`);
|
|
502
|
+
|
|
503
|
+
// 4. Vendor modulepreload (React, ReactDOM 등 - 캐시 효율 극대화)
|
|
504
|
+
if (bundleManifest.shared.vendor) {
|
|
505
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(bundleManifest.shared.vendor)}">`);
|
|
503
506
|
}
|
|
504
|
-
if (imports
|
|
505
|
-
|
|
507
|
+
if (bundleManifest.importMap?.imports) {
|
|
508
|
+
const imports = bundleManifest.importMap.imports;
|
|
509
|
+
if (imports["react-dom"] && imports["react-dom"] !== bundleManifest.shared.vendor) {
|
|
510
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(imports["react-dom"])}">`);
|
|
511
|
+
}
|
|
512
|
+
if (imports["react-dom/client"]) {
|
|
513
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(imports["react-dom/client"])}">`);
|
|
514
|
+
}
|
|
506
515
|
}
|
|
507
|
-
}
|
|
508
516
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
517
|
+
// 5. Runtime modulepreload (hydration 실행 전 미리 로드)
|
|
518
|
+
if (bundleManifest.shared.runtime) {
|
|
519
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(bundleManifest.shared.runtime)}">`);
|
|
520
|
+
}
|
|
513
521
|
|
|
514
|
-
|
|
515
|
-
if (bundleManifest && routeId) {
|
|
522
|
+
// 6. Island modulepreload
|
|
516
523
|
const bundle = bundleManifest.bundles[routeId];
|
|
517
524
|
if (bundle) {
|
|
518
|
-
|
|
525
|
+
const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
526
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
519
527
|
}
|
|
520
|
-
}
|
|
521
528
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
529
|
+
// 7. Runtime 로드
|
|
530
|
+
if (bundleManifest.shared.runtime) {
|
|
531
|
+
scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.runtime)}"></script>`);
|
|
532
|
+
}
|
|
526
533
|
|
|
527
|
-
|
|
528
|
-
if (hydration && hydration.strategy !== "none") {
|
|
534
|
+
// 7.5 React internals shim (must run before react-dom/client runs)
|
|
529
535
|
scripts.push(REACT_INTERNALS_SHIM_SCRIPT);
|
|
530
|
-
}
|
|
531
536
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
537
|
+
// 8. Router 스크립트
|
|
538
|
+
if (enableClientRouter && bundleManifest.shared?.router) {
|
|
539
|
+
scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.router)}"></script>`);
|
|
540
|
+
}
|
|
535
541
|
}
|
|
536
542
|
|
|
537
|
-
// 9. HMR 스크립트 (개발 모드)
|
|
543
|
+
// 9. HMR 스크립트 (개발 모드 — Zero-JS 페이지에서도 CSS 핫리로드 지원)
|
|
538
544
|
if (isDev && hmrPort) {
|
|
539
545
|
scripts.push(generateHMRScript(hmrPort));
|
|
540
546
|
}
|
|
@@ -545,7 +551,6 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
|
|
|
545
551
|
}
|
|
546
552
|
|
|
547
553
|
// Island wrapper 닫기 (hydration이 필요한 경우)
|
|
548
|
-
const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
549
554
|
const islandCloseTag = needsHydration ? "</div>" : "";
|
|
550
555
|
|
|
551
556
|
return `${islandCloseTag}</div>
|
|
@@ -700,11 +705,34 @@ export async function renderToStream(
|
|
|
700
705
|
}
|
|
701
706
|
|
|
702
707
|
const encoder = new TextEncoder();
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
+
const collectedHeadTags = collectStreamingHeadTags(element);
|
|
709
|
+
const resolvedOptions = collectedHeadTags
|
|
710
|
+
? { ...options, headTags: [options.headTags, collectedHeadTags].filter(Boolean).join("\n") }
|
|
711
|
+
: options;
|
|
712
|
+
const htmlShell = generateHTMLShell(resolvedOptions);
|
|
713
|
+
|
|
714
|
+
// Reset SSR head before real render so streaming components push fresh tags
|
|
715
|
+
try {
|
|
716
|
+
const headMod = require("../client/use-head") as { resetSSRHead?: () => void; getSSRHeadTags?: () => string };
|
|
717
|
+
headMod.resetSSRHead?.();
|
|
718
|
+
} catch {}
|
|
719
|
+
|
|
720
|
+
// Lazy tail: collect late head tags injected during streaming render
|
|
721
|
+
function buildHtmlTail(): string {
|
|
722
|
+
const baseTail = resolvedOptions._skipHtmlClose
|
|
723
|
+
? generateHTMLTailContent(resolvedOptions)
|
|
724
|
+
: generateHTMLTail(resolvedOptions);
|
|
725
|
+
try {
|
|
726
|
+
const headMod = require("../client/use-head") as { getSSRHeadTags?: () => string };
|
|
727
|
+
const lateHeadTags = headMod.getSSRHeadTags?.() ?? "";
|
|
728
|
+
if (lateHeadTags) {
|
|
729
|
+
const escaped = escapeJsonForInlineScript(JSON.stringify(lateHeadTags));
|
|
730
|
+
const script = `<script>(function(){var t=${escaped},d=document.createElement('div');d.innerHTML=t;var h=document.head;while(d.firstChild)h.appendChild(d.firstChild);})()</script>`;
|
|
731
|
+
return script + baseTail;
|
|
732
|
+
}
|
|
733
|
+
} catch {}
|
|
734
|
+
return baseTail;
|
|
735
|
+
}
|
|
708
736
|
|
|
709
737
|
let shellSent = false;
|
|
710
738
|
let timedOut = false;
|
|
@@ -823,7 +851,7 @@ export async function renderToStream(
|
|
|
823
851
|
controller.enqueue(encoder.encode(generateErrorScript(timeoutError, routeId)));
|
|
824
852
|
|
|
825
853
|
if (!tailSent) {
|
|
826
|
-
controller.enqueue(encoder.encode(
|
|
854
|
+
controller.enqueue(encoder.encode(buildHtmlTail()));
|
|
827
855
|
tailSent = true;
|
|
828
856
|
metrics.allReadyTime = Date.now() - metrics.startTime;
|
|
829
857
|
onMetrics?.(metrics);
|
|
@@ -842,7 +870,7 @@ export async function renderToStream(
|
|
|
842
870
|
|
|
843
871
|
if (done) {
|
|
844
872
|
if (!tailSent) {
|
|
845
|
-
controller.enqueue(encoder.encode(
|
|
873
|
+
controller.enqueue(encoder.encode(buildHtmlTail()));
|
|
846
874
|
tailSent = true;
|
|
847
875
|
// allReady가 아직 안 끝났을 수 있으므로 현재 시점으로 기록
|
|
848
876
|
if (metrics.allReadyTime === 0) {
|
|
@@ -875,7 +903,7 @@ export async function renderToStream(
|
|
|
875
903
|
controller.enqueue(encoder.encode(generateErrorScript(err, routeId)));
|
|
876
904
|
|
|
877
905
|
if (!tailSent) {
|
|
878
|
-
controller.enqueue(encoder.encode(
|
|
906
|
+
controller.enqueue(encoder.encode(buildHtmlTail()));
|
|
879
907
|
tailSent = true;
|
|
880
908
|
metrics.allReadyTime = Date.now() - metrics.startTime;
|
|
881
909
|
onMetrics?.(metrics);
|
|
@@ -895,6 +923,21 @@ export async function renderToStream(
|
|
|
895
923
|
});
|
|
896
924
|
}
|
|
897
925
|
|
|
926
|
+
function collectStreamingHeadTags(element: ReactElement): string {
|
|
927
|
+
try {
|
|
928
|
+
const mod = require("../client/use-head") as {
|
|
929
|
+
resetSSRHead?: () => void;
|
|
930
|
+
getSSRHeadTags?: () => string;
|
|
931
|
+
};
|
|
932
|
+
mod.resetSSRHead?.();
|
|
933
|
+
const renderToString = getRenderToString();
|
|
934
|
+
renderToString(element);
|
|
935
|
+
return mod.getSSRHeadTags?.() ?? "";
|
|
936
|
+
} catch {
|
|
937
|
+
return "";
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
898
941
|
/**
|
|
899
942
|
* Streaming SSR Response 생성
|
|
900
943
|
*
|
package/src/spec/index.ts
CHANGED
package/src/spec/schema.ts
CHANGED
|
@@ -234,5 +234,6 @@ export function getRouteHydration(route: RouteSpec): HydrationConfig {
|
|
|
234
234
|
*/
|
|
235
235
|
export function needsHydration(route: RouteSpec): boolean {
|
|
236
236
|
const hydration = getRouteHydration(route);
|
|
237
|
+
// "none" 이외의 전략만 hydration 필요 (island의 "never"는 strategy가 "none"으로 매핑됨)
|
|
237
238
|
return route.kind === "page" && hydration.strategy !== "none";
|
|
238
239
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Testing Utilities
|
|
3
|
+
* 서버 없이 라우트/filling 단위 테스트
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ManduContext } from "../filling/context";
|
|
7
|
+
import type { ManduFilling } from "../filling/filling";
|
|
8
|
+
import type { RouteSpec, RoutesManifest } from "../spec/schema";
|
|
9
|
+
|
|
10
|
+
// ========== Types ==========
|
|
11
|
+
|
|
12
|
+
export interface TestRequestOptions {
|
|
13
|
+
method?: string;
|
|
14
|
+
query?: Record<string, string>;
|
|
15
|
+
body?: unknown;
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
params?: Record<string, string>;
|
|
18
|
+
/** Action 이름 — 자동으로 _action을 body에 삽입하고 ManduAction 헤더를 추가 */
|
|
19
|
+
action?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ========== testFilling ==========
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Filling 단위 테스트 — 서버 없이 직접 실행
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* import { testFilling } from "@mandujs/core/testing";
|
|
30
|
+
* import todoRoute from "./app/api/todos/route";
|
|
31
|
+
*
|
|
32
|
+
* const res = await testFilling(todoRoute, {
|
|
33
|
+
* method: "GET",
|
|
34
|
+
* query: { page: "2" },
|
|
35
|
+
* });
|
|
36
|
+
* expect(res.status).toBe(200);
|
|
37
|
+
*
|
|
38
|
+
* const data = await res.json();
|
|
39
|
+
* expect(data.todos).toHaveLength(10);
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export async function testFilling(
|
|
43
|
+
filling: ManduFilling,
|
|
44
|
+
options: TestRequestOptions = {}
|
|
45
|
+
): Promise<Response> {
|
|
46
|
+
const {
|
|
47
|
+
method: rawMethod,
|
|
48
|
+
query,
|
|
49
|
+
body: rawBody,
|
|
50
|
+
headers: rawHeaders = {},
|
|
51
|
+
params = {},
|
|
52
|
+
action,
|
|
53
|
+
} = options;
|
|
54
|
+
|
|
55
|
+
// action 지정 시 자동으로 POST + _action body + ManduAction 헤더
|
|
56
|
+
const method = rawMethod ?? (action ? "POST" : "GET");
|
|
57
|
+
const headers = { ...rawHeaders };
|
|
58
|
+
let body = rawBody;
|
|
59
|
+
|
|
60
|
+
if (action) {
|
|
61
|
+
headers["X-Requested-With"] = "ManduAction";
|
|
62
|
+
headers["Accept"] = "application/json";
|
|
63
|
+
if (body && typeof body === "object" && !(body instanceof FormData)) {
|
|
64
|
+
body = { _action: action, ...(body as Record<string, unknown>) };
|
|
65
|
+
} else if (!body) {
|
|
66
|
+
body = { _action: action };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const url = new URL("http://localhost/test");
|
|
71
|
+
if (query) {
|
|
72
|
+
for (const [key, value] of Object.entries(query)) {
|
|
73
|
+
url.searchParams.set(key, value);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const requestInit: RequestInit = {
|
|
78
|
+
method,
|
|
79
|
+
headers,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (body !== undefined && method !== "GET" && method !== "HEAD") {
|
|
83
|
+
if (body instanceof FormData) {
|
|
84
|
+
requestInit.body = body;
|
|
85
|
+
} else {
|
|
86
|
+
requestInit.body = JSON.stringify(body);
|
|
87
|
+
(requestInit.headers as Record<string, string>)["Content-Type"] = "application/json";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const request = new Request(url.toString(), requestInit);
|
|
92
|
+
return filling.handle(request, params);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 간단한 Request 생성 헬퍼
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```typescript
|
|
100
|
+
* const req = createTestRequest("/api/todos", { method: "POST", body: { title: "test" } });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export function createTestRequest(
|
|
104
|
+
path: string,
|
|
105
|
+
options: TestRequestOptions = {}
|
|
106
|
+
): Request {
|
|
107
|
+
const { method = "GET", query, body, headers = {} } = options;
|
|
108
|
+
|
|
109
|
+
const url = new URL(`http://localhost${path}`);
|
|
110
|
+
if (query) {
|
|
111
|
+
for (const [key, value] of Object.entries(query)) {
|
|
112
|
+
url.searchParams.set(key, value);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const requestInit: RequestInit = { method, headers: { ...headers } };
|
|
117
|
+
|
|
118
|
+
if (body !== undefined && method !== "GET" && method !== "HEAD") {
|
|
119
|
+
if (body instanceof FormData) {
|
|
120
|
+
requestInit.body = body;
|
|
121
|
+
} else {
|
|
122
|
+
requestInit.body = JSON.stringify(body);
|
|
123
|
+
(requestInit.headers as Record<string, string>)["Content-Type"] = "application/json";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return new Request(url.toString(), requestInit);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* ManduContext 테스트용 생성 헬퍼
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```typescript
|
|
135
|
+
* const ctx = createTestContext("/api/users/123", { params: { id: "123" } });
|
|
136
|
+
* expect(ctx.params.id).toBe("123");
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export function createTestContext(
|
|
140
|
+
path: string,
|
|
141
|
+
options: TestRequestOptions = {}
|
|
142
|
+
): ManduContext {
|
|
143
|
+
const request = createTestRequest(path, options);
|
|
144
|
+
return new ManduContext(request, options.params);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ========== Test Factories ==========
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create a RoutesManifest from partial route definitions.
|
|
151
|
+
* Fills in sensible defaults so tests only specify the fields they care about.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```typescript
|
|
155
|
+
* const manifest = createTestManifest([
|
|
156
|
+
* { id: "home", kind: "page", pattern: "/" },
|
|
157
|
+
* { id: "api-users", kind: "api", pattern: "/api/users" },
|
|
158
|
+
* ]);
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
export function createTestManifest(routes: Partial<RouteSpec>[]): RoutesManifest {
|
|
162
|
+
return {
|
|
163
|
+
version: 1,
|
|
164
|
+
routes: routes.map((r, i) => ({
|
|
165
|
+
id: r.id ?? `test-route-${i}`,
|
|
166
|
+
kind: r.kind ?? "page",
|
|
167
|
+
pattern: r.pattern ?? `/test-${i}`,
|
|
168
|
+
module: r.module ?? `app/test-${i}/page.tsx`,
|
|
169
|
+
componentModule:
|
|
170
|
+
(r.kind ?? "page") === "page"
|
|
171
|
+
? (r.componentModule ?? r.module ?? `app/test-${i}/page.tsx`)
|
|
172
|
+
: undefined,
|
|
173
|
+
...r,
|
|
174
|
+
})) as RouteSpec[],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Create a minimal island descriptor for testing hydration logic.
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```typescript
|
|
183
|
+
* const island = createTestIsland("counter", "interaction");
|
|
184
|
+
* expect(island.__hydrate).toBe("interaction");
|
|
185
|
+
* ```
|
|
186
|
+
*/
|
|
187
|
+
export function createTestIsland(name: string, strategy: string = "visible") {
|
|
188
|
+
return { __island: true, __hydrate: strategy, __name: name };
|
|
189
|
+
}
|