@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.
Files changed (100) 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 +385 -115
  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/bundler/types.ts +20 -0
  11. package/src/change/snapshot.ts +4 -23
  12. package/src/change/types.ts +2 -3
  13. package/src/client/Form.tsx +105 -0
  14. package/src/client/__tests__/use-sse.test.ts +153 -0
  15. package/src/client/hooks.ts +105 -6
  16. package/src/client/index.ts +35 -6
  17. package/src/client/router.ts +670 -433
  18. package/src/client/rpc.ts +140 -0
  19. package/src/client/runtime.ts +24 -21
  20. package/src/client/use-fetch.ts +239 -0
  21. package/src/client/use-head.ts +197 -0
  22. package/src/client/use-sse.ts +378 -0
  23. package/src/components/Image.tsx +162 -0
  24. package/src/config/mandu.ts +5 -0
  25. package/src/config/validate.ts +34 -0
  26. package/src/content/index.ts +5 -1
  27. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  28. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  29. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  30. package/src/devtools/client/components/mandu-character.tsx +77 -53
  31. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  32. package/src/devtools/client/components/panel/errors-panel.tsx +2 -2
  33. package/src/devtools/client/components/panel/guard-panel.tsx +363 -234
  34. package/src/devtools/client/components/panel/index.ts +45 -32
  35. package/src/devtools/client/components/panel/islands-panel.tsx +30 -14
  36. package/src/devtools/client/components/panel/network-panel.tsx +2 -3
  37. package/src/devtools/client/components/panel/panel-container.tsx +273 -100
  38. package/src/devtools/client/components/panel/preview-panel.tsx +212 -0
  39. package/src/devtools/client/state-manager.ts +535 -478
  40. package/src/devtools/design-tokens.ts +265 -264
  41. package/src/devtools/init.ts +1 -1
  42. package/src/devtools/types.ts +321 -295
  43. package/src/filling/filling.ts +328 -6
  44. package/src/filling/index.ts +5 -1
  45. package/src/filling/session.ts +216 -0
  46. package/src/filling/ws.ts +78 -0
  47. package/src/generator/generate.ts +2 -2
  48. package/src/guard/auto-correct.ts +0 -29
  49. package/src/guard/check.ts +14 -31
  50. package/src/guard/presets/index.ts +296 -294
  51. package/src/guard/rules.ts +15 -19
  52. package/src/guard/validator.ts +834 -834
  53. package/src/index.ts +6 -1
  54. package/src/island/index.ts +373 -304
  55. package/src/kitchen/api/contract-api.ts +225 -0
  56. package/src/kitchen/api/diff-parser.ts +108 -0
  57. package/src/kitchen/api/file-api.ts +273 -0
  58. package/src/kitchen/api/guard-api.ts +83 -0
  59. package/src/kitchen/api/guard-decisions.ts +100 -0
  60. package/src/kitchen/api/routes-api.ts +50 -0
  61. package/src/kitchen/index.ts +21 -0
  62. package/src/kitchen/kitchen-handler.ts +335 -0
  63. package/src/kitchen/kitchen-ui.ts +1732 -0
  64. package/src/kitchen/stream/activity-sse.ts +145 -0
  65. package/src/kitchen/stream/file-tailer.ts +99 -0
  66. package/src/middleware/compress.ts +62 -0
  67. package/src/middleware/cors.ts +47 -0
  68. package/src/middleware/index.ts +10 -0
  69. package/src/middleware/jwt.ts +134 -0
  70. package/src/middleware/logger.ts +58 -0
  71. package/src/middleware/timeout.ts +55 -0
  72. package/src/observability/event-bus.ts +79 -0
  73. package/src/observability/index.ts +8 -0
  74. package/src/observability/logger-adapter.ts +36 -0
  75. package/src/paths.ts +0 -4
  76. package/src/plugins/hooks.ts +64 -0
  77. package/src/plugins/index.ts +3 -0
  78. package/src/plugins/types.ts +5 -0
  79. package/src/report/build.ts +0 -6
  80. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  81. package/src/router/fs-patterns.ts +11 -1
  82. package/src/router/fs-routes.ts +78 -14
  83. package/src/router/fs-scanner.ts +2 -2
  84. package/src/router/fs-types.ts +2 -1
  85. package/src/runtime/adapter-bun.ts +62 -0
  86. package/src/runtime/adapter.ts +47 -0
  87. package/src/runtime/cache.ts +310 -0
  88. package/src/runtime/handler.ts +65 -0
  89. package/src/runtime/image-handler.ts +195 -0
  90. package/src/runtime/index.ts +13 -0
  91. package/src/runtime/middleware.ts +263 -0
  92. package/src/runtime/ppr.ts +74 -0
  93. package/src/runtime/server.ts +706 -71
  94. package/src/runtime/ssr.ts +70 -31
  95. package/src/runtime/streaming-ssr.ts +121 -78
  96. package/src/spec/index.ts +0 -1
  97. package/src/spec/schema.ts +1 -0
  98. package/src/testing/index.ts +189 -0
  99. package/src/watcher/watcher.ts +27 -1
  100. 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;
@@ -137,9 +139,23 @@ function generateHydrationScripts(
137
139
  }
138
140
 
139
141
  // Island 번들 modulepreload (성능 최적화 - prefetch only)
140
- const bundle = manifest.bundles[routeId];
141
- if (bundle) {
142
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(bundle.js)}">`);
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 srcAttr = bundleSrc ? ` data-mandu-src="${escapeHtmlAttr(bundleSrc)}"` : "";
164
- return `<div data-mandu-island="${escapeHtmlAttr(routeId)}"${srcAttr} data-mandu-priority="${escapeHtmlAttr(priority)}">${content}</div>`;
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
- if (enableClientRouter && bundleManifest) {
232
- routerScript = generateClientRouterScript(bundleManifest);
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
- // 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>
@@ -700,11 +705,34 @@ export async function renderToStream(
700
705
  }
701
706
 
702
707
  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);
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(htmlTail));
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(htmlTail));
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(htmlTail));
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
@@ -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,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
+ }