@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.
- package/package.json +4 -2
- package/scripts/postinstall-lock.ts +153 -0
- package/src/a11y/run-audit.ts +15 -15
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__tests__/cold-start.test.ts +35 -7
- package/src/bundler/analyzer.ts +15 -7
- package/src/bundler/build.test.ts +13 -6
- package/src/bundler/build.ts +429 -182
- package/src/bundler/manifest-schema.ts +21 -14
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -9
- package/src/bundler/plugins/block-generated-imports.ts +13 -12
- package/src/bundler/types.ts +31 -14
- package/src/client/island.ts +79 -29
- package/src/config/validate.ts +1 -1
- package/src/deploy/inference/context.ts +82 -15
- package/src/filling/context.ts +17 -4
- package/src/guard/check.ts +9 -9
- package/src/guard/config-guard.ts +13 -7
- package/src/guard/fs-routes-policy.ts +51 -0
- package/src/guard/index.ts +11 -6
- package/src/kitchen/api/file-api.ts +11 -8
- package/src/resource/__tests__/schema.test.ts +14 -9
- package/src/resource/generators/slot.ts +72 -71
- package/src/resource/schema.ts +21 -13
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -0
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -0
- package/src/runtime/__tests__/page-render-response.test.ts +103 -0
- package/src/runtime/__tests__/request-middleware.test.ts +70 -0
- package/src/runtime/devtools-adapter.ts +68 -0
- package/src/runtime/escape.ts +34 -6
- package/src/runtime/observability-lifecycle.ts +290 -0
- package/src/runtime/page-render-response.ts +106 -0
- package/src/runtime/request-middleware.ts +31 -0
- package/src/runtime/server.ts +228 -944
- package/src/runtime/ssr.ts +59 -37
- package/src/runtime/static-files.ts +289 -0
- package/src/runtime/streaming-ssr.ts +22 -13
package/src/runtime/ssr.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
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(
|
|
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}>${
|
|
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?:
|
|
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:
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
const
|
|
621
|
-
|
|
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 ")}`;
|