@mandujs/core 0.19.0 → 0.19.2
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 +291 -113
- package/src/bundler/css.ts +20 -5
- package/src/bundler/dev.ts +55 -2
- package/src/bundler/prerender.ts +195 -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/panel/diff-viewer.tsx +219 -0
- package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
- package/src/devtools/client/components/panel/index.ts +45 -32
- package/src/devtools/client/components/panel/panel-container.tsx +332 -312
- package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
- package/src/devtools/client/state-manager.ts +535 -478
- package/src/devtools/design-tokens.ts +265 -264
- package/src/devtools/types.ts +345 -319
- package/src/filling/filling.ts +336 -14
- 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 +5 -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 +256 -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/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 +12 -0
- package/src/runtime/middleware.ts +263 -0
- package/src/runtime/server.ts +662 -83
- package/src/runtime/ssr.ts +55 -29
- package/src/runtime/streaming-ssr.ts +106 -82
- package/src/spec/index.ts +0 -1
- package/src/spec/schema.ts +1 -0
- package/src/testing/index.ts +144 -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;
|
|
@@ -139,7 +141,8 @@ function generateHydrationScripts(
|
|
|
139
141
|
// Island 번들 modulepreload (성능 최적화 - prefetch only)
|
|
140
142
|
const bundle = manifest.bundles[routeId];
|
|
141
143
|
if (bundle) {
|
|
142
|
-
|
|
144
|
+
const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
|
145
|
+
scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
// Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
|
|
@@ -160,8 +163,9 @@ export function wrapWithIsland(
|
|
|
160
163
|
priority: HydrationPriority = "visible",
|
|
161
164
|
bundleSrc?: string
|
|
162
165
|
): string {
|
|
163
|
-
const
|
|
164
|
-
|
|
166
|
+
const cacheBustedSrc = bundleSrc ? `${bundleSrc}?t=${Date.now()}` : undefined;
|
|
167
|
+
const srcAttr = cacheBustedSrc ? ` data-mandu-src="${escapeHtmlAttr(cacheBustedSrc)}"` : "";
|
|
168
|
+
return `<div data-mandu-island="${escapeHtmlAttr(routeId)}"${srcAttr} data-mandu-priority="${escapeHtmlAttr(priority)}" style="display:contents">${content}</div>`;
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
|
|
@@ -179,6 +183,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
179
183
|
enableClientRouter = false,
|
|
180
184
|
routePattern,
|
|
181
185
|
cssPath,
|
|
186
|
+
islandPreWrapped,
|
|
182
187
|
} = options;
|
|
183
188
|
|
|
184
189
|
// CSS 링크 태그 생성
|
|
@@ -188,51 +193,71 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
188
193
|
? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
|
|
189
194
|
: "";
|
|
190
195
|
|
|
196
|
+
// useHead/useSeoMeta SSR 수집
|
|
197
|
+
let collectedHeadTags = "";
|
|
198
|
+
let headReset: (() => void) | undefined;
|
|
199
|
+
let headGet: (() => string) | undefined;
|
|
200
|
+
try {
|
|
201
|
+
const mod = require("../client/use-head");
|
|
202
|
+
headReset = mod.resetSSRHead;
|
|
203
|
+
headGet = mod.getSSRHeadTags;
|
|
204
|
+
headReset?.();
|
|
205
|
+
} catch { /* client 모듈 로드 실패 시 무시 */ }
|
|
206
|
+
|
|
191
207
|
const renderToString = getRenderToString();
|
|
192
208
|
let content = renderToString(element);
|
|
193
209
|
|
|
210
|
+
// 렌더링 중 수집된 head 태그
|
|
211
|
+
collectedHeadTags = headGet?.() ?? "";
|
|
212
|
+
|
|
194
213
|
// Island 래퍼 적용 (hydration 필요 시)
|
|
214
|
+
// islandPreWrapped가 true이면 React 엘리먼트 레벨에서 이미 래핑됨 → HTML 래핑 건너뜀
|
|
195
215
|
const needsHydration =
|
|
196
216
|
hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
197
217
|
|
|
198
|
-
if (needsHydration) {
|
|
218
|
+
if (needsHydration && !islandPreWrapped) {
|
|
199
219
|
// v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
|
|
200
220
|
const bundle = bundleManifest.bundles[routeId];
|
|
201
221
|
const bundleSrc = bundle?.js;
|
|
202
222
|
content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
|
|
203
223
|
}
|
|
204
224
|
|
|
205
|
-
//
|
|
225
|
+
// Zero-JS 모드: island이 없는 페이지에서는 클라이언트 JS 번들을 전송하지 않음
|
|
226
|
+
// HMR/DevTools는 dev 환경에서만 유지 (CSS 핫리로드 등)
|
|
206
227
|
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
228
|
let routeScript = "";
|
|
219
|
-
if (enableClientRouter && routeId) {
|
|
220
|
-
routeScript = generateRouteScript(routeId, routePattern || "", serverData);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Hydration 스크립트
|
|
224
229
|
let hydrationScripts = "";
|
|
225
|
-
if (needsHydration && bundleManifest) {
|
|
226
|
-
hydrationScripts = generateHydrationScripts(routeId, bundleManifest);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Client-side Router 스크립트
|
|
230
230
|
let routerScript = "";
|
|
231
|
-
|
|
232
|
-
|
|
231
|
+
|
|
232
|
+
if (needsHydration) {
|
|
233
|
+
// 서버 데이터 스크립트 (클라이언트 hydration에서 사용)
|
|
234
|
+
if (serverData && routeId) {
|
|
235
|
+
const wrappedData = {
|
|
236
|
+
[routeId]: {
|
|
237
|
+
serverData,
|
|
238
|
+
timestamp: Date.now(),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
dataScript = serializeServerData(wrappedData);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Client-side Routing: 라우트 정보 주입
|
|
245
|
+
if (enableClientRouter && routeId) {
|
|
246
|
+
routeScript = generateRouteScript(routeId, routePattern || "", serverData);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Hydration 스크립트 (vendor/runtime/island preloads)
|
|
250
|
+
if (bundleManifest) {
|
|
251
|
+
hydrationScripts = generateHydrationScripts(routeId, bundleManifest);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Client-side Router 스크립트
|
|
255
|
+
if (enableClientRouter && bundleManifest) {
|
|
256
|
+
routerScript = generateClientRouterScript(bundleManifest);
|
|
257
|
+
}
|
|
233
258
|
}
|
|
234
259
|
|
|
235
|
-
// HMR 스크립트 (개발 모드)
|
|
260
|
+
// HMR 스크립트 (개발 모드 — island 유무와 무관하게 CSS 핫리로드 지원)
|
|
236
261
|
let hmrScript = "";
|
|
237
262
|
if (isDev && hmrPort) {
|
|
238
263
|
hmrScript = generateHMRScript(hmrPort);
|
|
@@ -252,6 +277,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
252
277
|
<title>${escapeHtmlText(title)}</title>
|
|
253
278
|
${cssLinkTag}
|
|
254
279
|
${headTags}
|
|
280
|
+
${collectedHeadTags}
|
|
255
281
|
</head>
|
|
256
282
|
<body>
|
|
257
283
|
<div id="root">${content}</div>
|
|
@@ -15,11 +15,12 @@ import React, { Suspense } from "react";
|
|
|
15
15
|
import type { BundleManifest } from "../bundler/types";
|
|
16
16
|
import type { HydrationConfig, HydrationPriority } from "../spec/schema";
|
|
17
17
|
import { serializeProps } from "../client/serialize";
|
|
18
|
-
import type { Metadata, MetadataItem } from "../seo/types";
|
|
19
|
-
import { injectSEOIntoOptions, resolveSEO, type SEOOptions } from "../seo/integration/ssr";
|
|
20
|
-
import { PORTS, TIMEOUTS } from "../constants";
|
|
21
|
-
import { escapeHtmlAttr, escapeJsonForInlineScript, escapeJsString } from "./escape";
|
|
22
|
-
import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
|
|
18
|
+
import type { Metadata, MetadataItem } from "../seo/types";
|
|
19
|
+
import { injectSEOIntoOptions, resolveSEO, type SEOOptions } from "../seo/integration/ssr";
|
|
20
|
+
import { PORTS, TIMEOUTS } from "../constants";
|
|
21
|
+
import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript, escapeJsString } from "./escape";
|
|
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>
|
|
@@ -699,12 +704,16 @@ export async function renderToStream(
|
|
|
699
704
|
streamingWarnings.markWarned();
|
|
700
705
|
}
|
|
701
706
|
|
|
702
|
-
const encoder = new TextEncoder();
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
707
|
+
const encoder = new TextEncoder();
|
|
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
|
+
// _skipHtmlClose가 true이면 </body></html> 생략 (deferred 스크립트 삽입용)
|
|
714
|
+
const htmlTail = resolvedOptions._skipHtmlClose
|
|
715
|
+
? generateHTMLTailContent(resolvedOptions)
|
|
716
|
+
: generateHTMLTail(resolvedOptions);
|
|
708
717
|
|
|
709
718
|
let shellSent = false;
|
|
710
719
|
let timedOut = false;
|
|
@@ -892,8 +901,23 @@ export async function renderToStream(
|
|
|
892
901
|
}
|
|
893
902
|
} catch {}
|
|
894
903
|
},
|
|
895
|
-
});
|
|
896
|
-
}
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function collectStreamingHeadTags(element: ReactElement): string {
|
|
908
|
+
try {
|
|
909
|
+
const mod = require("../client/use-head") as {
|
|
910
|
+
resetSSRHead?: () => void;
|
|
911
|
+
getSSRHeadTags?: () => string;
|
|
912
|
+
};
|
|
913
|
+
mod.resetSSRHead?.();
|
|
914
|
+
const renderToString = getRenderToString();
|
|
915
|
+
renderToString(element);
|
|
916
|
+
return mod.getSSRHeadTags?.() ?? "";
|
|
917
|
+
} catch {
|
|
918
|
+
return "";
|
|
919
|
+
}
|
|
920
|
+
}
|
|
897
921
|
|
|
898
922
|
/**
|
|
899
923
|
* Streaming SSR Response 생성
|
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,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Testing Utilities
|
|
3
|
+
* 서버 없이 라우트/filling 단위 테스트
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { ManduContext } from "../filling/context";
|
|
7
|
+
import type { ManduFilling } from "../filling/filling";
|
|
8
|
+
|
|
9
|
+
// ========== Types ==========
|
|
10
|
+
|
|
11
|
+
export interface TestRequestOptions {
|
|
12
|
+
method?: string;
|
|
13
|
+
query?: Record<string, string>;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
params?: Record<string, string>;
|
|
17
|
+
/** Action 이름 — 자동으로 _action을 body에 삽입하고 ManduAction 헤더를 추가 */
|
|
18
|
+
action?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ========== testFilling ==========
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Filling 단위 테스트 — 서버 없이 직접 실행
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* import { testFilling } from "@mandujs/core/testing";
|
|
29
|
+
* import todoRoute from "./app/api/todos/route";
|
|
30
|
+
*
|
|
31
|
+
* const res = await testFilling(todoRoute, {
|
|
32
|
+
* method: "GET",
|
|
33
|
+
* query: { page: "2" },
|
|
34
|
+
* });
|
|
35
|
+
* expect(res.status).toBe(200);
|
|
36
|
+
*
|
|
37
|
+
* const data = await res.json();
|
|
38
|
+
* expect(data.todos).toHaveLength(10);
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export async function testFilling(
|
|
42
|
+
filling: ManduFilling,
|
|
43
|
+
options: TestRequestOptions = {}
|
|
44
|
+
): Promise<Response> {
|
|
45
|
+
const {
|
|
46
|
+
method: rawMethod,
|
|
47
|
+
query,
|
|
48
|
+
body: rawBody,
|
|
49
|
+
headers: rawHeaders = {},
|
|
50
|
+
params = {},
|
|
51
|
+
action,
|
|
52
|
+
} = options;
|
|
53
|
+
|
|
54
|
+
// action 지정 시 자동으로 POST + _action body + ManduAction 헤더
|
|
55
|
+
const method = rawMethod ?? (action ? "POST" : "GET");
|
|
56
|
+
const headers = { ...rawHeaders };
|
|
57
|
+
let body = rawBody;
|
|
58
|
+
|
|
59
|
+
if (action) {
|
|
60
|
+
headers["X-Requested-With"] = "ManduAction";
|
|
61
|
+
headers["Accept"] = "application/json";
|
|
62
|
+
if (body && typeof body === "object" && !(body instanceof FormData)) {
|
|
63
|
+
body = { _action: action, ...(body as Record<string, unknown>) };
|
|
64
|
+
} else if (!body) {
|
|
65
|
+
body = { _action: action };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const url = new URL("http://localhost/test");
|
|
70
|
+
if (query) {
|
|
71
|
+
for (const [key, value] of Object.entries(query)) {
|
|
72
|
+
url.searchParams.set(key, value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const requestInit: RequestInit = {
|
|
77
|
+
method,
|
|
78
|
+
headers,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (body !== undefined && method !== "GET" && method !== "HEAD") {
|
|
82
|
+
if (body instanceof FormData) {
|
|
83
|
+
requestInit.body = body;
|
|
84
|
+
} else {
|
|
85
|
+
requestInit.body = JSON.stringify(body);
|
|
86
|
+
(requestInit.headers as Record<string, string>)["Content-Type"] = "application/json";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const request = new Request(url.toString(), requestInit);
|
|
91
|
+
return filling.handle(request, params);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 간단한 Request 생성 헬퍼
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```typescript
|
|
99
|
+
* const req = createTestRequest("/api/todos", { method: "POST", body: { title: "test" } });
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
export function createTestRequest(
|
|
103
|
+
path: string,
|
|
104
|
+
options: TestRequestOptions = {}
|
|
105
|
+
): Request {
|
|
106
|
+
const { method = "GET", query, body, headers = {} } = options;
|
|
107
|
+
|
|
108
|
+
const url = new URL(`http://localhost${path}`);
|
|
109
|
+
if (query) {
|
|
110
|
+
for (const [key, value] of Object.entries(query)) {
|
|
111
|
+
url.searchParams.set(key, value);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const requestInit: RequestInit = { method, headers: { ...headers } };
|
|
116
|
+
|
|
117
|
+
if (body !== undefined && method !== "GET" && method !== "HEAD") {
|
|
118
|
+
if (body instanceof FormData) {
|
|
119
|
+
requestInit.body = body;
|
|
120
|
+
} else {
|
|
121
|
+
requestInit.body = JSON.stringify(body);
|
|
122
|
+
(requestInit.headers as Record<string, string>)["Content-Type"] = "application/json";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return new Request(url.toString(), requestInit);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* ManduContext 테스트용 생성 헬퍼
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```typescript
|
|
134
|
+
* const ctx = createTestContext("/api/users/123", { params: { id: "123" } });
|
|
135
|
+
* expect(ctx.params.id).toBe("123");
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export function createTestContext(
|
|
139
|
+
path: string,
|
|
140
|
+
options: TestRequestOptions = {}
|
|
141
|
+
): ManduContext {
|
|
142
|
+
const request = createTestRequest(path, options);
|
|
143
|
+
return new ManduContext(request, options.params);
|
|
144
|
+
}
|
package/src/watcher/watcher.ts
CHANGED
|
@@ -56,6 +56,17 @@ const DEFAULT_CONFIG: Partial<WatcherConfig> = {
|
|
|
56
56
|
* Monitors file changes and emits warnings based on architecture rules.
|
|
57
57
|
* Never blocks operations - only warns.
|
|
58
58
|
*/
|
|
59
|
+
/**
|
|
60
|
+
* Windows reserved device names that cannot be used as file/directory names.
|
|
61
|
+
* These cause EISDIR/ENOENT errors when file watchers try to access them.
|
|
62
|
+
* See: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
|
|
63
|
+
*/
|
|
64
|
+
const WINDOWS_RESERVED_NAMES = new Set([
|
|
65
|
+
"CON", "PRN", "AUX", "NUL",
|
|
66
|
+
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
67
|
+
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
68
|
+
]);
|
|
69
|
+
|
|
59
70
|
export class FileWatcher {
|
|
60
71
|
private config: WatcherConfig;
|
|
61
72
|
private chokidarWatcher: FSWatcher | null = null;
|
|
@@ -118,6 +129,9 @@ export class FileWatcher {
|
|
|
118
129
|
const basename = path.basename(filePath);
|
|
119
130
|
// Ignore directories in the ignore list
|
|
120
131
|
if (ignoredSet.has(basename)) return true;
|
|
132
|
+
// Filter out Windows reserved device names (#12)
|
|
133
|
+
// These cause EISDIR errors when chokidar tries to scandir them
|
|
134
|
+
if (WINDOWS_RESERVED_NAMES.has(basename.toUpperCase().replace(/\..*$/, ""))) return true;
|
|
121
135
|
// For files, only watch matching extensions
|
|
122
136
|
if (stats?.isFile() && extSet.size > 0) {
|
|
123
137
|
const ext = path.extname(filePath);
|
|
@@ -158,7 +172,19 @@ export class FileWatcher {
|
|
|
158
172
|
});
|
|
159
173
|
|
|
160
174
|
this.chokidarWatcher.on("error", (error: unknown) => {
|
|
161
|
-
|
|
175
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
176
|
+
// Suppress EISDIR errors from Windows reserved device names (#12)
|
|
177
|
+
// e.g. "EISDIR: illegal operation on a directory, scandir 'C:\...\nul'"
|
|
178
|
+
if (message.includes("EISDIR")) {
|
|
179
|
+
const pathMatch = message.match(/scandir\s+'([^']+)'/);
|
|
180
|
+
if (pathMatch) {
|
|
181
|
+
const baseName = pathMatch[1].split(/[/\\]/).pop() || "";
|
|
182
|
+
if (WINDOWS_RESERVED_NAMES.has(baseName.toUpperCase())) {
|
|
183
|
+
return; // Silently ignore — these are not real directories
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
console.error(`[Watch] Error:`, message);
|
|
162
188
|
});
|
|
163
189
|
|
|
164
190
|
this._active = true;
|