@mandujs/core 0.54.1 → 0.54.3

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 (37) hide show
  1. package/package.json +4 -2
  2. package/scripts/postinstall-lock.ts +153 -0
  3. package/src/a11y/run-audit.ts +15 -15
  4. package/src/brain/doctor/analyzer.ts +7 -7
  5. package/src/bundler/__tests__/cold-start.test.ts +35 -7
  6. package/src/bundler/analyzer.ts +15 -7
  7. package/src/bundler/build.test.ts +13 -6
  8. package/src/bundler/build.ts +429 -182
  9. package/src/bundler/manifest-schema.ts +21 -14
  10. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -9
  11. package/src/bundler/plugins/block-generated-imports.ts +13 -12
  12. package/src/bundler/types.ts +31 -14
  13. package/src/client/island.ts +79 -29
  14. package/src/config/validate.ts +1 -1
  15. package/src/deploy/inference/context.ts +82 -15
  16. package/src/filling/context.ts +17 -4
  17. package/src/guard/check.ts +9 -9
  18. package/src/guard/config-guard.ts +13 -7
  19. package/src/guard/fs-routes-policy.ts +51 -0
  20. package/src/guard/index.ts +11 -6
  21. package/src/kitchen/api/file-api.ts +11 -8
  22. package/src/resource/__tests__/schema.test.ts +14 -9
  23. package/src/resource/generators/slot.ts +72 -71
  24. package/src/resource/schema.ts +21 -13
  25. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -0
  26. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -0
  27. package/src/runtime/__tests__/page-render-response.test.ts +103 -0
  28. package/src/runtime/__tests__/request-middleware.test.ts +70 -0
  29. package/src/runtime/devtools-adapter.ts +68 -0
  30. package/src/runtime/escape.ts +34 -6
  31. package/src/runtime/observability-lifecycle.ts +290 -0
  32. package/src/runtime/page-render-response.ts +106 -0
  33. package/src/runtime/request-middleware.ts +31 -0
  34. package/src/runtime/server.ts +228 -944
  35. package/src/runtime/ssr.ts +59 -37
  36. package/src/runtime/static-files.ts +289 -0
  37. package/src/runtime/streaming-ssr.ts +22 -13
@@ -6,7 +6,7 @@ import type { BundleManifest } from "../bundler/types";
6
6
  import { isSafeManduUrl } from "../bundler/manifest-schema";
7
7
  import type { HydrationConfig, HydrationPriority } from "../spec/schema";
8
8
  import { PORTS, TIMEOUTS } from "../constants";
9
- import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
9
+ import { decodeHtmlText, escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
10
10
  import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
11
11
  import { generateFastRefreshPreamble } from "../bundler/dev";
12
12
  import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
@@ -46,7 +46,7 @@ export interface SSROptions {
46
46
  title?: string;
47
47
  lang?: string;
48
48
  /** 서버에서 로드한 데이터 (클라이언트로 전달) */
49
- serverData?: Record<string, unknown>;
49
+ serverData?: unknown;
50
50
  /** Hydration 설정 */
51
51
  hydration?: HydrationConfig;
52
52
  /** 번들 매니페스트 */
@@ -255,19 +255,26 @@ function generateHydrationScripts(
255
255
  ? Object.values(manifest.islands).filter((ib) => ib.route === routeId)
256
256
  : [];
257
257
 
258
- if (routeIslands.length > 0) {
259
- for (const ib of routeIslands) {
260
- const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
261
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
262
- }
258
+ if (routeIslands.length > 0) {
259
+ for (const ib of routeIslands) {
260
+ const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
261
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
262
+ }
263
263
  } else {
264
264
  // Fallback: route-level bundle (backward compat)
265
265
  const bundle = manifest.bundles[routeId];
266
266
  if (bundle) {
267
267
  const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
268
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
269
- }
270
- }
268
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
269
+ }
270
+ }
271
+
272
+ if (manifest.partials) {
273
+ for (const partial of Object.values(manifest.partials)) {
274
+ const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
275
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
276
+ }
277
+ }
271
278
 
272
279
  // Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
273
280
  if (manifest.shared.runtime) {
@@ -612,8 +619,9 @@ export async function resolveAsyncElement(node: ReactNode): Promise<ReactNode> {
612
619
  return React.cloneElement(element, undefined, resolvedChildren);
613
620
  }
614
621
 
615
- export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
616
- const {
622
+ export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
623
+ const hasExplicitTitle = options.title !== undefined;
624
+ const {
617
625
  title = "Mandu App",
618
626
  lang = "ko",
619
627
  serverData,
@@ -705,12 +713,14 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
705
713
  const needsHydration =
706
714
  hydration && hydration.strategy !== "none" && routeId && bundleManifest;
707
715
 
708
- if (needsHydration && !islandPreWrapped) {
709
- // v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
710
- const bundle = bundleManifest.bundles[routeId];
711
- const bundleSrc = bundle?.js;
712
- content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
713
- }
716
+ if (needsHydration && !islandPreWrapped) {
717
+ // v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
718
+ const bundle = bundleManifest.bundles[routeId];
719
+ const bundleSrc = bundle?.js;
720
+ if (bundleSrc) {
721
+ content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
722
+ }
723
+ }
714
724
 
715
725
  // Zero-JS 모드: island이 없는 페이지에서는 클라이언트 JS 번들을 전송하지 않음
716
726
  // HMR/DevTools는 dev 환경에서만 유지 (CSS 핫리로드 등)
@@ -721,10 +731,10 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
721
731
 
722
732
  if (needsHydration) {
723
733
  // 서버 데이터 스크립트 (클라이언트 hydration에서 사용)
724
- if (serverData && routeId) {
725
- const wrappedData = {
726
- [routeId]: {
727
- serverData,
734
+ if (serverData !== undefined && routeId) {
735
+ const wrappedData = {
736
+ [routeId]: {
737
+ serverData,
728
738
  timestamp: Date.now(),
729
739
  },
730
740
  };
@@ -793,7 +803,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
793
803
  ? `<script>window.__MANDU_SPA__=false;</script>`
794
804
  : "";
795
805
 
796
- // #179: body 내 <link> 태그를 <head>로 호이스팅
806
+ // #179: body 내 <link> 태그를 <head>로 호이스팅
797
807
  // React 컴포넌트(layout.tsx 등)에서 <link>를 렌더링하면 body 안에 위치하게 되는데,
798
808
  // 폰트/스타일시트는 <head>에 있어야 FOUT 없이 로드됨
799
809
  const linkTagPattern = /<link\s[^>]*(?:rel=["'](?:stylesheet|preconnect|preload|icon|dns-prefetch)["'][^>]*|href=["'][^"']+["'][^>]*)\/?\s*>/gi;
@@ -802,7 +812,19 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
802
812
  hoistedLinks.push(match);
803
813
  return "";
804
814
  });
805
- const hoistedLinkTags = hoistedLinks.join("\n ");
815
+ const hoistedLinkTags = hoistedLinks.join("\n ");
816
+
817
+ // #273 F15 — React 19 renders document metadata such as <title> from a
818
+ // page component into the body string in this SSR path. Hoist the first
819
+ // body title into <head> when no metadata/generateMetadata title was
820
+ // provided, and always strip body titles to avoid duplicate/invalid HTML.
821
+ let effectiveTitle = title;
822
+ const titleTagPattern = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i;
823
+ const bodyTitleMatch = bodyContent.match(titleTagPattern);
824
+ const bodyWithoutTitle = bodyContent.replace(/<title(?:\s[^>]*)?>[\s\S]*?<\/title>/gi, "");
825
+ if (!hasExplicitTitle && bodyTitleMatch) {
826
+ effectiveTitle = decodeHtmlText(bodyTitleMatch[1] ?? title);
827
+ }
806
828
 
807
829
  // Phase 18.α — Dev Error Overlay injection.
808
830
  // Only emitted when `isDev` AND the user has not opted out (via
@@ -820,7 +842,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
820
842
  <head>
821
843
  <meta charset="UTF-8">
822
844
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
823
- <title>${escapeHtmlText(title)}</title>
845
+ <title>${escapeHtmlText(effectiveTitle)}</title>
824
846
  ${cssLinkTag}
825
847
  ${viewTransitionTag}
826
848
  ${prefetchScriptTag}
@@ -832,7 +854,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
832
854
  ${devErrorOverlayTag}
833
855
  </head>
834
856
  <body>
835
- <div id="root"${rootAttrs}>${bodyContent}</div>
857
+ <div id="root"${rootAttrs}>${bodyWithoutTitle}</div>
836
858
  ${dataScript}
837
859
  ${routeScript}
838
860
  ${hydrationScripts}
@@ -849,11 +871,11 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
849
871
  /**
850
872
  * Client-side Routing: 현재 라우트 정보 스크립트 생성
851
873
  */
852
- function generateRouteScript(
853
- routeId: string,
854
- pattern: string,
855
- _serverData?: Record<string, unknown>
856
- ): string {
874
+ function generateRouteScript(
875
+ routeId: string,
876
+ pattern: string,
877
+ _serverData?: unknown
878
+ ): string {
857
879
  const routeInfo = {
858
880
  id: routeId,
859
881
  pattern,
@@ -1154,12 +1176,12 @@ export function renderSSR(element: ReactElement, options: SSROptions = {}): Resp
1154
1176
  */
1155
1177
  export async function renderWithHydration(
1156
1178
  element: ReactElement,
1157
- options: SSROptions & {
1158
- routeId: string;
1159
- serverData: Record<string, unknown>;
1160
- hydration: HydrationConfig;
1161
- bundleManifest: BundleManifest;
1162
- }
1179
+ options: SSROptions & {
1180
+ routeId: string;
1181
+ serverData: unknown;
1182
+ hydration: HydrationConfig;
1183
+ bundleManifest: BundleManifest;
1184
+ }
1163
1185
  ): Promise<Response> {
1164
1186
  const html = renderToHTML(element, options);
1165
1187
  // Phase 7.2 R1 Agent C (H1) — same CSP header logic as renderSSR.
@@ -0,0 +1,289 @@
1
+ import type { BunFile } from "bun";
2
+ import path from "path";
3
+ import fs from "fs/promises";
4
+
5
+ export interface StaticFileSettings {
6
+ isDev: boolean;
7
+ rootDir: string;
8
+ publicDir: string;
9
+ }
10
+
11
+ export interface StaticFileResult {
12
+ handled: boolean;
13
+ response?: Response;
14
+ }
15
+
16
+ const MIME_TYPES: Record<string, string> = {
17
+ ".js": "application/javascript",
18
+ ".mjs": "application/javascript",
19
+ ".ts": "application/typescript",
20
+ ".css": "text/css",
21
+ ".html": "text/html",
22
+ ".htm": "text/html",
23
+ ".json": "application/json",
24
+ ".png": "image/png",
25
+ ".jpg": "image/jpeg",
26
+ ".jpeg": "image/jpeg",
27
+ ".gif": "image/gif",
28
+ ".svg": "image/svg+xml",
29
+ ".ico": "image/x-icon",
30
+ ".webp": "image/webp",
31
+ ".avif": "image/avif",
32
+ ".woff": "font/woff",
33
+ ".woff2": "font/woff2",
34
+ ".ttf": "font/ttf",
35
+ ".otf": "font/otf",
36
+ ".eot": "application/vnd.ms-fontobject",
37
+ ".pdf": "application/pdf",
38
+ ".txt": "text/plain",
39
+ ".xml": "application/xml",
40
+ ".mp3": "audio/mpeg",
41
+ ".mp4": "video/mp4",
42
+ ".webm": "video/webm",
43
+ ".ogg": "audio/ogg",
44
+ ".zip": "application/zip",
45
+ ".gz": "application/gzip",
46
+ ".wasm": "application/wasm",
47
+ ".map": "application/json",
48
+ };
49
+
50
+ const PUBLIC_FLAT_ASSET_EXTENSIONS = new Set<string>([
51
+ ".webp", ".avif", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico",
52
+ ".pdf", ".zip", ".mp4", ".webm", ".mp3", ".wav",
53
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
54
+ ".css", ".js", ".map",
55
+ ]);
56
+
57
+ interface EtagCacheEntry {
58
+ size: number;
59
+ mtime: number;
60
+ etag: string;
61
+ }
62
+
63
+ const etagCache = new Map<string, EtagCacheEntry>();
64
+ const ETAG_CACHE_MAX = 2048;
65
+
66
+ function getMimeType(filePath: string): string {
67
+ const ext = path.extname(filePath).toLowerCase();
68
+ return MIME_TYPES[ext] || "application/octet-stream";
69
+ }
70
+
71
+ function hasContentHashInFilename(filename: string): boolean {
72
+ return /[.\-][a-f0-9]{8,}\.[a-z0-9]+$/i.test(filename);
73
+ }
74
+
75
+ export function computeStaticCacheControl(filename: string, isDev: boolean): string {
76
+ if (isDev) return "no-cache, no-store, must-revalidate";
77
+ if (hasContentHashInFilename(filename)) {
78
+ return "public, max-age=31536000, immutable";
79
+ }
80
+ return "public, max-age=0, must-revalidate";
81
+ }
82
+
83
+ function evictEtagCacheIfNeeded(): void {
84
+ if (etagCache.size <= ETAG_CACHE_MAX) return;
85
+ const oldestKey = etagCache.keys().next().value;
86
+ if (oldestKey !== undefined) etagCache.delete(oldestKey);
87
+ }
88
+
89
+ export async function computeStrongEtag(
90
+ filePath: string,
91
+ file: BunFile,
92
+ ): Promise<string> {
93
+ const size = file.size;
94
+ const mtime = file.lastModified;
95
+
96
+ const cached = etagCache.get(filePath);
97
+ if (cached && cached.size === size && cached.mtime === mtime) {
98
+ return cached.etag;
99
+ }
100
+
101
+ let digest: string;
102
+ try {
103
+ const bytes = await file.arrayBuffer();
104
+ const hash = Bun.hash(bytes);
105
+ digest = typeof hash === "bigint" ? hash.toString(36) : Number(hash).toString(36);
106
+ } catch {
107
+ digest = `${size.toString(36)}-${mtime.toString(36)}`;
108
+ }
109
+
110
+ const etag = `"${digest}"`;
111
+ etagCache.set(filePath, { size, mtime, etag });
112
+ evictEtagCacheIfNeeded();
113
+ return etag;
114
+ }
115
+
116
+ export function __clearStaticEtagCacheForTests(): void {
117
+ etagCache.clear();
118
+ }
119
+
120
+ export function matchesEtag(ifNoneMatch: string, currentEtag: string): boolean {
121
+ const trimmed = ifNoneMatch.trim();
122
+ if (trimmed === "*") return true;
123
+
124
+ const normalize = (tag: string): string => {
125
+ const next = tag.trim();
126
+ return next.startsWith("W/") ? next.slice(2) : next;
127
+ };
128
+
129
+ const currentNormalized = normalize(currentEtag);
130
+ for (const part of trimmed.split(",")) {
131
+ if (normalize(part) === currentNormalized) return true;
132
+ }
133
+ return false;
134
+ }
135
+
136
+ async function isPathSafe(filePath: string, allowedDir: string): Promise<boolean> {
137
+ try {
138
+ const resolvedPath = path.resolve(filePath);
139
+ const resolvedAllowedDir = path.resolve(allowedDir);
140
+
141
+ if (
142
+ !resolvedPath.startsWith(resolvedAllowedDir + path.sep) &&
143
+ resolvedPath !== resolvedAllowedDir
144
+ ) {
145
+ return false;
146
+ }
147
+
148
+ try {
149
+ await fs.access(resolvedPath);
150
+ } catch {
151
+ return true;
152
+ }
153
+
154
+ const realPath = await fs.realpath(resolvedPath);
155
+ const realAllowedDir = await fs.realpath(resolvedAllowedDir);
156
+
157
+ return realPath.startsWith(realAllowedDir + path.sep) ||
158
+ realPath === realAllowedDir;
159
+ } catch (error) {
160
+ console.warn(`[Mandu Security] Path validation failed: ${filePath}`, error);
161
+ return false;
162
+ }
163
+ }
164
+
165
+ function createStaticErrorResponse(status: 400 | 403 | 404 | 500): Response {
166
+ const body = {
167
+ 400: "Bad Request",
168
+ 403: "Forbidden",
169
+ 404: "Not Found",
170
+ 500: "Internal Server Error",
171
+ }[status];
172
+
173
+ return new Response(body, { status });
174
+ }
175
+
176
+ export async function serveStaticFile(
177
+ pathname: string,
178
+ settings: StaticFileSettings,
179
+ request?: Request,
180
+ ): Promise<StaticFileResult> {
181
+ let filePath: string | null = null;
182
+ let isBundleFile = false;
183
+ let isPublicFlatFallback = false;
184
+ let allowRouteFallbackOnMissing = false;
185
+ let allowedBaseDir: string;
186
+ let relativePath: string;
187
+
188
+ if (pathname.startsWith("/.mandu/client/")) {
189
+ relativePath = pathname.slice("/.mandu/client/".length);
190
+ allowedBaseDir = path.join(settings.rootDir, ".mandu", "client");
191
+ isBundleFile = true;
192
+ } else if (pathname.startsWith("/public/")) {
193
+ relativePath = pathname.slice("/public/".length);
194
+ allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
195
+ } else if (pathname.startsWith("/.well-known/")) {
196
+ relativePath = pathname.slice(1);
197
+ allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
198
+ } else if (
199
+ pathname === "/favicon.ico" ||
200
+ pathname === "/robots.txt" ||
201
+ pathname === "/sitemap.xml" ||
202
+ pathname === "/manifest.json"
203
+ ) {
204
+ relativePath = path.basename(pathname);
205
+ allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
206
+ allowRouteFallbackOnMissing = true;
207
+ } else if (PUBLIC_FLAT_ASSET_EXTENSIONS.has(path.extname(pathname).toLowerCase())) {
208
+ relativePath = pathname.slice(1);
209
+ allowedBaseDir = path.join(settings.rootDir, settings.publicDir);
210
+ isPublicFlatFallback = true;
211
+ } else {
212
+ return { handled: false };
213
+ }
214
+
215
+ let decodedPath: string;
216
+ try {
217
+ decodedPath = decodeURIComponent(relativePath);
218
+ } catch {
219
+ return { handled: true, response: createStaticErrorResponse(400) };
220
+ }
221
+
222
+ const normalizedPath = path.posix.normalize(decodedPath);
223
+ if (normalizedPath.includes("\0")) {
224
+ console.warn(`[Mandu Security] Null byte attack detected: ${pathname}`);
225
+ return { handled: true, response: createStaticErrorResponse(400) };
226
+ }
227
+
228
+ const normalizedSegments = normalizedPath.split("/");
229
+ if (normalizedSegments.some((segment) => segment === "..")) {
230
+ return { handled: true, response: createStaticErrorResponse(403) };
231
+ }
232
+
233
+ const safeRelativePath = normalizedPath.replace(/^\/+/, "");
234
+ filePath = path.join(allowedBaseDir, safeRelativePath);
235
+
236
+ if (!(await isPathSafe(filePath, allowedBaseDir))) {
237
+ console.warn(`[Mandu Security] Path traversal attempt blocked: ${pathname}`);
238
+ return { handled: true, response: createStaticErrorResponse(403) };
239
+ }
240
+
241
+ try {
242
+ const file = Bun.file(filePath);
243
+ const exists = await file.exists();
244
+
245
+ if (!exists) {
246
+ if (isPublicFlatFallback || allowRouteFallbackOnMissing) return { handled: false };
247
+ return { handled: true, response: createStaticErrorResponse(404) };
248
+ }
249
+
250
+ const mimeType = getMimeType(filePath);
251
+ const filename = path.basename(filePath);
252
+ let cacheControl: string;
253
+ if (settings.isDev) {
254
+ cacheControl = "no-cache, no-store, must-revalidate";
255
+ } else if (isBundleFile) {
256
+ cacheControl = computeStaticCacheControl(filename, false);
257
+ } else {
258
+ cacheControl = "public, max-age=86400";
259
+ }
260
+
261
+ const etag = isBundleFile
262
+ ? await computeStrongEtag(filePath, file)
263
+ : `W/"${file.size.toString(36)}-${file.lastModified.toString(36)}"`;
264
+
265
+ const ifNoneMatch = request?.headers.get("If-None-Match");
266
+ if (ifNoneMatch && matchesEtag(ifNoneMatch, etag)) {
267
+ return {
268
+ handled: true,
269
+ response: new Response(null, {
270
+ status: 304,
271
+ headers: { "ETag": etag, "Cache-Control": cacheControl },
272
+ }),
273
+ };
274
+ }
275
+
276
+ return {
277
+ handled: true,
278
+ response: new Response(file, {
279
+ headers: {
280
+ "Content-Type": mimeType,
281
+ "Cache-Control": cacheControl,
282
+ "ETag": etag,
283
+ },
284
+ }),
285
+ };
286
+ } catch {
287
+ return { handled: true, response: createStaticErrorResponse(500) };
288
+ }
289
+ }
@@ -613,13 +613,16 @@ function generateHTMLShell(options: StreamingSSROptions): string {
613
613
  }
614
614
  </style>`;
615
615
 
616
- let islandOpenTag = "";
617
- if (needsHydration) {
618
- const bundle = bundleManifest.bundles[routeId];
619
- const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
620
- const priority = hydration.priority || "visible";
621
- islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" style="display:contents">`;
622
- }
616
+ let islandOpenTag = "";
617
+ const hasRouteBundle = !!(needsHydration && bundleManifest.bundles[routeId]?.js);
618
+ if (needsHydration) {
619
+ const bundle = bundleManifest.bundles[routeId];
620
+ const bundleSrc = bundle?.js ? `${bundle.js}?t=${Date.now()}` : "";
621
+ const priority = hydration.priority || "visible";
622
+ if (hasRouteBundle) {
623
+ islandOpenTag = `<div data-mandu-island="${escapeHtmlAttr(routeId)}" data-mandu-src="${escapeHtmlAttr(bundleSrc)}" data-mandu-priority="${escapeHtmlAttr(priority)}" style="display:contents">`;
624
+ }
625
+ }
623
626
 
624
627
  // Phase 7.1 R2 Agent D: Fast Refresh preamble. Must land in <head>
625
628
  // BEFORE any island script evaluates — the stubs it installs for
@@ -697,7 +700,7 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
697
700
  // 1~8: hydration이 필요한 경우에만 클라이언트 JS 관련 스크립트 삽입
698
701
  if (needsHydration) {
699
702
  // 1. Critical 데이터 스크립트 (즉시 사용 가능)
700
- if (criticalData && routeId) {
703
+ if (criticalData !== undefined && routeId) {
701
704
  const wrappedData = {
702
705
  [routeId]: {
703
706
  serverData: criticalData,
@@ -746,10 +749,16 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
746
749
 
747
750
  // 6. Island modulepreload
748
751
  const bundle = bundleManifest.bundles[routeId];
749
- if (bundle) {
750
- const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
751
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
752
- }
752
+ if (bundle) {
753
+ const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
754
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
755
+ }
756
+ if (bundleManifest.partials) {
757
+ for (const partial of Object.values(bundleManifest.partials)) {
758
+ const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
759
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
760
+ }
761
+ }
753
762
 
754
763
  // 7. Runtime 로드
755
764
  if (bundleManifest.shared.runtime) {
@@ -783,7 +792,7 @@ function generateHTMLTailContent(options: StreamingSSROptions): string {
783
792
  }
784
793
 
785
794
  // Island wrapper 닫기 (hydration이 필요한 경우)
786
- const islandCloseTag = needsHydration ? "</div>" : "";
795
+ const islandCloseTag = needsHydration && bundleManifest.bundles[routeId]?.js ? "</div>" : "";
787
796
 
788
797
  return `${islandCloseTag}</div>
789
798
  ${scripts.join("\n ")}`;