@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.
Files changed (90) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +291 -113
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/change/snapshot.ts +4 -23
  11. package/src/change/types.ts +2 -3
  12. package/src/client/Form.tsx +105 -0
  13. package/src/client/__tests__/use-sse.test.ts +153 -0
  14. package/src/client/hooks.ts +105 -6
  15. package/src/client/index.ts +35 -6
  16. package/src/client/router.ts +670 -433
  17. package/src/client/rpc.ts +140 -0
  18. package/src/client/runtime.ts +24 -21
  19. package/src/client/use-fetch.ts +239 -0
  20. package/src/client/use-head.ts +197 -0
  21. package/src/client/use-sse.ts +378 -0
  22. package/src/components/Image.tsx +162 -0
  23. package/src/config/mandu.ts +5 -0
  24. package/src/config/validate.ts +34 -0
  25. package/src/content/index.ts +5 -1
  26. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  27. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  28. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  29. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  30. package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
  31. package/src/devtools/client/components/panel/index.ts +45 -32
  32. package/src/devtools/client/components/panel/panel-container.tsx +332 -312
  33. package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
  34. package/src/devtools/client/state-manager.ts +535 -478
  35. package/src/devtools/design-tokens.ts +265 -264
  36. package/src/devtools/types.ts +345 -319
  37. package/src/filling/filling.ts +336 -14
  38. package/src/filling/index.ts +5 -1
  39. package/src/filling/session.ts +216 -0
  40. package/src/filling/ws.ts +78 -0
  41. package/src/generator/generate.ts +2 -2
  42. package/src/guard/auto-correct.ts +0 -29
  43. package/src/guard/check.ts +14 -31
  44. package/src/guard/presets/index.ts +296 -294
  45. package/src/guard/rules.ts +15 -19
  46. package/src/guard/validator.ts +834 -834
  47. package/src/index.ts +5 -1
  48. package/src/island/index.ts +373 -304
  49. package/src/kitchen/api/contract-api.ts +225 -0
  50. package/src/kitchen/api/diff-parser.ts +108 -0
  51. package/src/kitchen/api/file-api.ts +273 -0
  52. package/src/kitchen/api/guard-api.ts +83 -0
  53. package/src/kitchen/api/guard-decisions.ts +100 -0
  54. package/src/kitchen/api/routes-api.ts +50 -0
  55. package/src/kitchen/index.ts +21 -0
  56. package/src/kitchen/kitchen-handler.ts +256 -0
  57. package/src/kitchen/kitchen-ui.ts +1732 -0
  58. package/src/kitchen/stream/activity-sse.ts +145 -0
  59. package/src/kitchen/stream/file-tailer.ts +99 -0
  60. package/src/middleware/compress.ts +62 -0
  61. package/src/middleware/cors.ts +47 -0
  62. package/src/middleware/index.ts +10 -0
  63. package/src/middleware/jwt.ts +134 -0
  64. package/src/middleware/logger.ts +58 -0
  65. package/src/middleware/timeout.ts +55 -0
  66. package/src/paths.ts +0 -4
  67. package/src/plugins/hooks.ts +64 -0
  68. package/src/plugins/index.ts +3 -0
  69. package/src/plugins/types.ts +5 -0
  70. package/src/report/build.ts +0 -6
  71. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  72. package/src/router/fs-patterns.ts +11 -1
  73. package/src/router/fs-routes.ts +78 -14
  74. package/src/router/fs-scanner.ts +2 -2
  75. package/src/router/fs-types.ts +2 -1
  76. package/src/runtime/adapter-bun.ts +62 -0
  77. package/src/runtime/adapter.ts +47 -0
  78. package/src/runtime/cache.ts +310 -0
  79. package/src/runtime/handler.ts +65 -0
  80. package/src/runtime/image-handler.ts +195 -0
  81. package/src/runtime/index.ts +12 -0
  82. package/src/runtime/middleware.ts +263 -0
  83. package/src/runtime/server.ts +662 -83
  84. package/src/runtime/ssr.ts +55 -29
  85. package/src/runtime/streaming-ssr.ts +106 -82
  86. package/src/spec/index.ts +0 -1
  87. package/src/spec/schema.ts +1 -0
  88. package/src/testing/index.ts +144 -0
  89. package/src/watcher/watcher.ts +27 -1
  90. package/src/spec/lock.ts +0 -56
@@ -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
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(bundle.js)}">`);
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 srcAttr = bundleSrc ? ` data-mandu-src="${escapeHtmlAttr(bundleSrc)}"` : "";
164
- return `<div data-mandu-island="${escapeHtmlAttr(routeId)}"${srcAttr} data-mandu-priority="${escapeHtmlAttr(priority)}">${content}</div>`;
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
- if (enableClientRouter && bundleManifest) {
232
- routerScript = generateClientRouterScript(bundleManifest);
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
- // Import map (module scripts 전에 위치해야 함)
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?.importMap && Object.keys(bundleManifest.importMap.imports).length > 0) {
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>${escapeHtmlAttr(title)}</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
- // 1. Critical 데이터 스크립트 (즉시 사용 가능)
467
- if (criticalData && routeId) {
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
- // 2. 라우트 정보 스크립트
481
- if (enableClientRouter && routeId) {
482
- const routeInfo = {
483
- id: routeId,
484
- pattern: routePattern || "",
485
- params: {},
486
- streaming: true,
487
- };
488
- const json = escapeJsonForInlineScript(JSON.stringify(routeInfo));
489
- scripts.push(`<script>window.__MANDU_ROUTE__ = ${json};</script>`);
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
- // 3. Streaming 완료 마커 (클라이언트에서 감지용)
493
- scripts.push(`<script>window.__MANDU_STREAMING_SHELL_READY__ = true;</script>`);
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
- // 4. Vendor modulepreload (React, ReactDOM 등 - 캐시 효율 극대화)
496
- if (bundleManifest?.shared.vendor) {
497
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(bundleManifest.shared.vendor)}">`);
498
- }
499
- if (bundleManifest?.importMap?.imports) {
500
- const imports = bundleManifest.importMap.imports;
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["react-dom/client"]) {
505
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(imports["react-dom/client"])}">`);
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
- // 5. Runtime modulepreload (hydration 실행 전 미리 로드)
510
- if (bundleManifest?.shared.runtime) {
511
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(bundleManifest.shared.runtime)}">`);
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
- // 6. Island modulepreload
515
- if (bundleManifest && routeId) {
522
+ // 6. Island modulepreload
516
523
  const bundle = bundleManifest.bundles[routeId];
517
524
  if (bundle) {
518
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(bundle.js)}">`);
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
- // 7. Runtime 로드
523
- if (bundleManifest?.shared.runtime) {
524
- scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.runtime)}"></script>`);
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
- // 7.5 React internals shim (must run before react-dom/client runs)
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
- // 8. Router 스크립트
533
- if (enableClientRouter && bundleManifest?.shared?.router) {
534
- scripts.push(`<script type="module" src="${escapeHtmlAttr(bundleManifest.shared.router)}"></script>`);
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 htmlShell = generateHTMLShell(options);
704
- // _skipHtmlClose가 true이면 </body></html> 생략 (deferred 스크립트 삽입용)
705
- const htmlTail = options._skipHtmlClose
706
- ? generateHTMLTailContent(options)
707
- : generateHTMLTail(options);
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
@@ -1,3 +1,2 @@
1
1
  export * from "./schema";
2
2
  export * from "./load";
3
- export * from "./lock";
@@ -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
+ }
@@ -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
- console.error(`[Watch] Error:`, error instanceof Error ? error.message : String(error));
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;